Refactor a Notification Service

Refactor a Python God class into a SOLID-compliant notification service.

Introduction

30 Second Summary

Ever worked on a codebase where adding one small feature meant rewriting half the existing code? That frustration is exactly what clean architecture prevents.

In this project, you will refactor a messy notification service into a clean, extensible system using the five SOLID principles in Python.

What You'll Build

A multi-channel notification service that sends messages via Email, SMS, and Push channels, refactored step-by-step from a monolithic class into a testable, SOLID-compliant architecture.

By the end of this project, you'll have:

  • A SOLID-compliant notification service with abstract interfaces, concrete implementations, and a service orchestrator.
  • A pytest test suite proving the architecture is testable via dependency injection.
  • Hands-on experience identifying SOLID violations in a "God class" and refactoring them into clean, focused components.
  • Secret Mission: Add a WebhookNotifier without touching any existing code, proving the Open/Closed Principle in action.

Are there any prerequisites?

This project assumes intermediate Python knowledge. You should be comfortable with classes, inheritance, and decorators.

Set Up the Project

Before you can refactor anything, you need a clean workspace. A dedicated project folder and virtual environment keep your dependencies isolated from the rest of your system.

In this step, you'll create the solid-notifications project folder, activate a virtual environment, and install pytest as your testing framework.

In this step, get ready to:
  • Create the project folder and virtual environment.
  • Install pytest and set up the project file structure.
Create the project folder and virtual environment

A virtual environment gives your project its own isolated set of Python packages. This means the libraries you install here won't conflict with anything else on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir solid-notifications
cd solid-notifications
  • Now open this folder as your VS Code workspace. Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the solid-notifications folder, then click Open.
  • Open the integrated terminal again by pressing Ctrl+`.
  • Create a virtual environment by running this command:
python -m venv venv

What does this command do?

python -m venv venv tells Python to run its built-in venv module and create a new virtual environment in a folder called venv. This folder contains a private copy of the Python interpreter and a place to install packages without affecting your system Python.

  • Activate the virtual environment by running the command for your operating system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate
  • Check your terminal prompt. You should see (venv) at the beginning of the line, confirming the virtual environment is active.

Don't see (venv) in your prompt?

  • On Windows, PowerShell may block the activation script due to its execution policy. Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser and then try the activation command again.
  • Make sure you ran the command from inside the solid-notifications folder. The venv folder must exist in your current directory.

Why isn't my virtual environment activating?

Install pytest and set up the project structure

With your virtual environment active, any packages you install stay scoped to this project. You'll install pytest for testing, then create the folder structure your code will live in.

  • Install pytest by running this command:
pip install pytest==9.1.0
  • Confirm it installed correctly by running:
pytest --version
  • You should see pytest 9.1.0 in the output.

Seeing a different version or an error?

  • If you see a different version number, make sure your (venv) prefix is showing. Without the virtual environment active, pip installs to your system Python instead.
  • If you get a "command not found" error, confirm your virtual environment is activated and try running pip install pytest==9.1.0 again.

Why can't I install or run pytest?

  • Create the project directories and files by running the commands for your operating system:

macOS

mkdir src tests
touch src/__init__.py src/bad_notifications.py src/notifiers.py src/service.py src/main.py
touch tests/__init__.py tests/test_notifications.py

Windows

New-Item -ItemType Directory -Path src, tests
New-Item -ItemType File -Path src/__init__.py, src/bad_notifications.py, src/notifiers.py, src/service.py, src/main.py
New-Item -ItemType File -Path tests/__init__.py, tests/test_notifications.py
  • Confirm the files exist by running:
ls src tests
  • You should see __init__.py, bad_notifications.py, notifiers.py, service.py, and main.py under src. You should see __init__.py and test_notifications.py under tests.

Why two directories?

The src/ folder holds your application code. The tests/ folder holds your test code. Keeping them separate is a standard Python project convention that makes it easy to run tests against your source without mixing concerns.

The __init__.py files mark each folder as a Python package, allowing imports to work across directories.

Files not showing up?

  • Make sure you ran the commands from inside the solid-notifications folder. Your terminal prompt should show this folder name before the $ or > symbol.
  • On Windows, check that you used New-Item with the -ItemType File flag. Without it, PowerShell may create directories instead of files.

Why aren't my project files appearing?

Your workspace is ready. Next up, you'll write the intentionally bad "God class" to see firsthand why SOLID principles matter.

Write the God Class

Your environment is ready with pytest installed and your project structure in place. Before building the clean version of your notification service, you need to experience why clean architecture matters.

In this step, you will write a God class that handles all notification channels in one place. After running it, you will identify exactly which SOLID principles it violates.

In this step, get ready to:
  • Write a monolithic NotificationManager class that handles email, SMS, and push notifications.
  • Run the class to verify it works.
  • Identify the SOLID violations in the design.
Create the monolithic NotificationManager

The NotificationManager class handles email, SMS, and push notifications all in one method. It uses if/elif chains to pick the channel, mixes formatting with delivery simulation, and logs everything internally.

  • In VS Code, open src/bad_notifications.py from the Explorer sidebar.
  • Copy the following code into the file:
"""The 'bad' version: a God class that violates SOLID principles."""


class NotificationManager:
    """Handles ALL notifications in one place. This is intentionally bad design."""

    def send_notification(self, channel: str, recipient: str, message: str) -> None:
        if channel == "email":
            formatted = f"Subject: Notification\n\nDear {recipient},\n{message}"
            print(f"[EMAIL] Sending to {recipient}: {formatted}")
            print("[EMAIL] Connecting to SMTP server... Done.")
        elif channel == "sms":
            truncated = message[:160]
            print(f"[SMS] Sending to {recipient}: {truncated}")
            print("[SMS] Connecting to SMS gateway... Done.")
        elif channel == "push":
            print(f"[PUSH] Sending to {recipient}: {message}")
            print("[PUSH] Connecting to push service... Done.")
        else:
            raise ValueError(f"Unknown channel: {channel}")

        print(f"[LOG] Notification sent via {channel} to {recipient}")

What does this code do?

This single class takes a channel string and uses if/elif branching to decide how to format and send the notification.

  • The email branch formats a subject line and simulates SMTP delivery.
  • The sms branch truncates the message to 160 characters and simulates a gateway call.
  • The push branch prints the message and simulates a push service connection.
  • Every branch ends with a log line, meaning logging is also embedded in this class.
  • Save src/bad_notifications.py.

You won't see a change yet. The next chunk adds a test script so you can run this class.

Run the bad code to see it works

The class itself is just a definition. You need a script entry point to actually execute it and observe the output.

  • In src/bad_notifications.py, add the following code below the class (after the last line of send_notification):
if __name__ == "__main__":
    manager = NotificationManager()
    manager.send_notification("email", "alice@example.com", "Your order has shipped!")
    print()
    manager.send_notification("sms", "+1234567890", "Your verification code is 7742")
    print()
    manager.send_notification("push", "user_42", "You have a new message from Bob")

What does this code do?

The if __name__ == "__main__" guard means this code only runs when you execute the file directly. It creates a NotificationManager and sends one test notification through each of the three channels.

  • Save src/bad_notifications.py.
  • Run the script from the project root by entering this command in your terminal:
python src/bad_notifications.py

You should see simulated output for all three channels. Each channel prints a delivery message followed by a connection simulation and a log line.

Seeing a ModuleNotFoundError or no output?

  • Make sure you are running the command from the solid-notifications folder, not from inside src/.
  • Confirm your terminal still shows the (venv) prefix. If not, reactivate with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).
  • Check for indentation errors. Python requires consistent spacing inside the class and the if/elif blocks.

Still stuck? Help me debug why python src/bad_notifications.py produces an error or no output.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/bad_notifications.py before moving on.

ⓧ I'd like to double check the full code

"""The 'bad' version: a God class that violates SOLID principles."""


class NotificationManager:
    """Handles ALL notifications in one place. This is intentionally bad design."""

    def send_notification(self, channel: str, recipient: str, message: str) -> None:
        if channel == "email":
            formatted = f"Subject: Notification\n\nDear {recipient},\n{message}"
            print(f"[EMAIL] Sending to {recipient}: {formatted}")
            print("[EMAIL] Connecting to SMTP server... Done.")
        elif channel == "sms":
            truncated = message[:160]
            print(f"[SMS] Sending to {recipient}: {truncated}")
            print("[SMS] Connecting to SMS gateway... Done.")
        elif channel == "push":
            print(f"[PUSH] Sending to {recipient}: {message}")
            print("[PUSH] Connecting to push service... Done.")
        else:
            raise ValueError(f"Unknown channel: {channel}")

        print(f"[LOG] Notification sent via {channel} to {recipient}")


if __name__ == "__main__":
    manager = NotificationManager()
    manager.send_notification("email", "alice@example.com", "Your order has shipped!")
    print()
    manager.send_notification("sms", "+1234567890", "Your verification code is 7742")
    print()
    manager.send_notification("push", "user_42", "You have a new message from Bob")
Identify the SOLID violations

The code works, but imagine you need to add a Slack channel next week. You would have to open this class, add another elif branch, and hope you do not break the existing channels.

Which SOLID principles does this class violate?

  • Single Responsibility Principle (SRP) violation. The class has multiple reasons to change: email formatting logic, SMS truncation logic, push delivery logic, and logging are all bundled together.
  • Open/Closed Principle (OCP) violation. Adding a new channel (like Slack or Webhook) requires modifying the existing class by adding another elif branch.
  • Dependency Inversion Principle (DIP) violation. There is no way to test email sending without also loading SMS and push logic. High-level code depends on concrete implementation details, not abstractions.
  • Interface Segregation Principle (ISP) violation. The single send_notification method forces all channels to share one interface, even if their delivery needs differ.

Why start with bad code?

Without feeling the pain of a God class first, SOLID principles feel like academic rules. The refactoring-first approach builds intuition for why each principle exists.

In the next step, you will refactor this into small, focused classes that each have one job. Adding a new channel will mean writing a new file, not editing an existing one.

Your God class works, but it is fragile and hard to extend. Next up, you will refactor it into a clean architecture using abstract base classes and dependency injection.

Refactor with Abstractions

In the previous step, you experienced the pain of the God class firsthand. Every notification channel lives in one bloated file, and adding a new channel means editing existing code.

Now you'll fix that by extracting each channel into its own class, connected through an abstract contract. The result is a system where high-level code never mentions email, SMS, or push directly.

In this step, get ready to:
  • Define an abstract Notifier base class as the contract for all channels.
  • Implement three concrete notifiers: email, SMS, and push.
  • Build a NotificationService orchestrator that depends only on the abstraction.
Define the Notifier abstract base class

Python's abc module lets you define Abstract Base Classes. An abstract class is a contract: it declares methods that subclasses must implement, but it cannot be instantiated on its own.

This is Dependency Inversion Principle in action. High-level code will depend on this abstraction, not on email or SMS details.

  • In VS Code, open src/notifiers.py from the Explorer sidebar.
  • Add the abstract base class by pasting this code:
"""SOLID-compliant notifiers: abstract base and concrete implementations."""

from abc import ABC, abstractmethod


class Notifier(ABC):
    """Abstract base class defining the notification contract.

    This is the core abstraction that enables:
    - DIP: High-level code depends on this, not on concrete channels.
    - OCP: New channels implement this without modifying existing code.
    - ISP: Minimal interface with only what every notifier needs.
    """

    @abstractmethod
    def send(self, recipient: str, message: str) -> bool:
        """Send a notification to a recipient.

        Args:
            recipient: Who to send to (email, phone, user ID, etc.)
            message: The notification content.

        Returns:
            True if sending succeeded, False otherwise.
        """
        pass

What does this code do?

The Notifier class inherits from ABC, making it abstract. You cannot create an instance of Notifier() directly.

The @abstractmethod decorator on send forces every subclass to implement that method. If a subclass forgets, Python raises a TypeError at instantiation time.

The send method takes a recipient and a message, and returns a boolean indicating success or failure. This is the entire interface. Nothing else is forced on implementors.

Why ABC over Protocol?

Python also offers Protocol (structural subtyping), which doesn't require inheritance. For learning SOLID, ABC is better because it makes the contract explicit. You can see the inheritance chain, and Python enforces it at runtime.

Protocol is more suited for library authors who want duck-typing compatibility without forcing users to inherit.

  • Save src/notifiers.py.

You won't see output yet. The next substep adds concrete implementations that fulfill this contract.

Implement the concrete notifiers

Each concrete notifier has exactly one responsibility (Single Responsibility Principle): delivering via its specific channel. Each can stand in wherever a Notifier is expected (Liskov Substitution Principle).

  • In src/notifiers.py, add the three concrete notifiers below the Notifier class:

class EmailNotifier(Notifier):
    """Sends notifications via email. SRP: only handles email delivery."""

    def send(self, recipient: str, message: str) -> bool:
        formatted = f"Subject: Notification\n\nDear {recipient},\n{message}"
        print(f"[EMAIL] Sending to {recipient}: {formatted}")
        return True


class SMSNotifier(Notifier):
    """Sends notifications via SMS. SRP: only handles SMS delivery."""

    def send(self, recipient: str, message: str) -> bool:
        truncated = message[:160]
        print(f"[SMS] Sending to {recipient}: {truncated}")
        return True


class PushNotifier(Notifier):
    """Sends push notifications. SRP: only handles push delivery."""

    def send(self, recipient: str, message: str) -> bool:
        print(f"[PUSH] Sending to {recipient}: {message}")
        return True

What does this code do?

  • EmailNotifier formats the message with a subject line and simulates email delivery.
  • SMSNotifier truncates the message to 160 characters (the SMS limit) and simulates sending.
  • PushNotifier passes the message through as-is and simulates a push notification.

All three return True to indicate success. Each class inherits from Notifier and implements the send method, fulfilling the abstract contract.

  • Save src/notifiers.py.

The notifiers are ready. In the next substep, you'll build the service that orchestrates them.

Seeing a red underline in VS Code?

If you see an error on the class definitions, make sure each class has (Notifier) after the class name. Without it, Python doesn't know the class should implement the abstract method.

Also check that @abstractmethod is spelled correctly and imported at the top of the file.

Help me fix my notifier classes

✔️ Awesome, I've got everything!

Great. Double check you've saved src/notifiers.py before moving on.

ⓧ I'd like to double check the full code

"""SOLID-compliant notifiers: abstract base and concrete implementations."""

from abc import ABC, abstractmethod


class Notifier(ABC):
    """Abstract base class defining the notification contract.

    This is the core abstraction that enables:
    - DIP: High-level code depends on this, not on concrete channels.
    - OCP: New channels implement this without modifying existing code.
    - ISP: Minimal interface with only what every notifier needs.
    """

    @abstractmethod
    def send(self, recipient: str, message: str) -> bool:
        """Send a notification to a recipient.

        Args:
            recipient: Who to send to (email, phone, user ID, etc.)
            message: The notification content.

        Returns:
            True if sending succeeded, False otherwise.
        """
        pass


class EmailNotifier(Notifier):
    """Sends notifications via email. SRP: only handles email delivery."""

    def send(self, recipient: str, message: str) -> bool:
        formatted = f"Subject: Notification\n\nDear {recipient},\n{message}"
        print(f"[EMAIL] Sending to {recipient}: {formatted}")
        return True


class SMSNotifier(Notifier):
    """Sends notifications via SMS. SRP: only handles SMS delivery."""

    def send(self, recipient: str, message: str) -> bool:
        truncated = message[:160]
        print(f"[SMS] Sending to {recipient}: {truncated}")
        return True


class PushNotifier(Notifier):
    """Sends push notifications. SRP: only handles push delivery."""

    def send(self, recipient: str, message: str) -> bool:
        print(f"[PUSH] Sending to {recipient}: {message}")
        return True
Build the NotificationService orchestrator

The service layer sits above the notifiers and coordinates them. It depends only on the Notifier abstraction (Dependency Inversion Principle), never on concrete types like EmailNotifier.

This is Dependency Injection in action. You pass the notifiers in through the constructor, so the service never creates its own dependencies.

  • Open src/service.py and paste the following code:
"""NotificationService: orchestrates sending via multiple channels."""

from src.notifiers import Notifier


class NotificationService:
    """Orchestrates notifications across multiple channels.

    DIP: Depends on the Notifier abstraction, not concrete implementations.
    SRP: Only responsible for coordinating notifiers, not for delivery logic.
    """

    def __init__(self, notifiers: list[Notifier]) -> None:
        """Initialize with a list of notifiers to use.

        Args:
            notifiers: List of Notifier implementations to send through.
        """
        self.notifiers = notifiers

    def notify_all(self, recipient: str, message: str) -> dict[str, bool]:
        """Send a notification through all registered channels.

        Args:
            recipient: Who to notify.
            message: The notification content.

        Returns:
            Dict mapping notifier class name to success/failure boolean.
        """
        results: dict[str, bool] = {}
        for notifier in self.notifiers:
            key = notifier.__class__.__name__
            results[key] = notifier.send(recipient, message)
        return results

What does this code do?

  • The __init__ method accepts a list of Notifier objects. It doesn't know or care which concrete types they are.
  • notify_all iterates over every notifier, calls send(), and collects results keyed by class name.
  • The service has one job (SRP): coordinate. It never formats messages or connects to servers.
  • Save src/service.py.

The service is ready. Now you'll wire everything together in main.py and run it.

✔️ Awesome, I've got everything!

Great. Make sure src/service.py is saved before continuing.

ⓧ I'd like to double check the full code

"""NotificationService: orchestrates sending via multiple channels."""

from src.notifiers import Notifier


class NotificationService:
    """Orchestrates notifications across multiple channels.

    DIP: Depends on the Notifier abstraction, not concrete implementations.
    SRP: Only responsible for coordinating notifiers, not for delivery logic.
    """

    def __init__(self, notifiers: list[Notifier]) -> None:
        """Initialize with a list of notifiers to use.

        Args:
            notifiers: List of Notifier implementations to send through.
        """
        self.notifiers = notifiers

    def notify_all(self, recipient: str, message: str) -> dict[str, bool]:
        """Send a notification through all registered channels.

        Args:
            recipient: Who to notify.
            message: The notification content.

        Returns:
            Dict mapping notifier class name to success/failure boolean.
        """
        results: dict[str, bool] = {}
        for notifier in self.notifiers:
            key = notifier.__class__.__name__
            results[key] = notifier.send(recipient, message)
        return results
  • Open src/main.py and paste the demo script:
"""Demo script showing the SOLID notification service in action."""

from src.notifiers import EmailNotifier, SMSNotifier, PushNotifier
from src.service import NotificationService


def main() -> None:
    # Create one instance of each notifier
    notifiers = [
        EmailNotifier(),
        SMSNotifier(),
        PushNotifier(),
    ]

    # Inject notifiers into the service (Dependency Injection)
    service = NotificationService(notifiers)

    print("--- Sending notification to all channels ---")
    print()
    results = service.notify_all("alice@example.com", "Your order has shipped!")
    print()
    print("--- Results ---")
    for channel, success in results.items():
        status = "OK" if success else "FAILED"
        print(f"  {channel}: {status}")


if __name__ == "__main__":
    main()

What does this code do?

  • It creates instances of all three concrete notifiers and passes them as a list to NotificationService.
  • notify_all sends the message through every channel and collects success/failure results.
  • The if __name__ == "__main__" guard ensures main() only runs when you execute the file directly.
  • Save src/main.py.
  • Run the demo by executing this command in your terminal:
python -m src.main
  • You should see output showing all three channels sending, followed by a results summary with OK next to each channel name.

Why python -m instead of python src/main.py?

The -m flag runs a module within a package. Because main.py uses from src.notifiers import ..., Python needs to resolve src as a package. Running with -m from the project root makes this work correctly.

Seeing ModuleNotFoundError?

Make sure you're running the command from the solid-notifications folder (the project root), not from inside src/.

Check that src/__init__.py exists (even if empty). Without it, Python doesn't treat src as a package.

Also confirm your virtual environment is still active. You should see (venv) at the start of your terminal prompt.

Help me fix my ModuleNotFoundError

✔️ Awesome, I've got everything!

Great. Make sure src/main.py is saved before continuing.

ⓧ I'd like to double check the full code

"""Demo script showing the SOLID notification service in action."""

from src.notifiers import EmailNotifier, SMSNotifier, PushNotifier
from src.service import NotificationService


def main() -> None:
    notifiers = [
        EmailNotifier(),
        SMSNotifier(),
        PushNotifier(),
    ]

    service = NotificationService(notifiers)

    print("--- Sending notification to all channels ---")
    print()
    results = service.notify_all("alice@example.com", "Your order has shipped!")
    print()
    print("--- Results ---")
    for channel, success in results.items():
        status = "OK" if success else "FAILED"
        print(f"  {channel}: {status}")


if __name__ == "__main__":
    main()

Your refactored system is live. Three independent notifiers, one orchestrating service, zero coupling between them. Next up, you'll prove this architecture is testable by writing a pytest test suite with mock notifiers.

Write Tests to Prove It Works

Your refactored notification service looks clean, but how do you prove it actually works the way you designed it? The answer is automated tests that exercise each piece in isolation.

The beautiful thing about Dependency Inversion is that it makes testing trivial. Because NotificationService depends on the Notifier abstraction, you can inject fake notifiers that record calls instead of actually sending messages.

In this step, get ready to:
  • Create test doubles (fake notifiers) that let you inspect behaviour.
  • Write four test functions covering success, multi-channel delivery, and failure.
  • Run pytest and confirm all tests pass.
Create test doubles for isolated testing

A test double is a stand-in object that replaces a real dependency during testing. You will create two: one that records every message it receives, and one that always fails.

  • In VS Code, open tests/test_notifications.py and paste the following code:
"""Tests for the SOLID notification service."""

from src.notifiers import Notifier
from src.service import NotificationService


class DummyNotifier(Notifier):
    """A test double that records messages instead of sending them."""

    def __init__(self) -> None:
        self.sent_messages: list[tuple[str, str]] = []

    def send(self, recipient: str, message: str) -> bool:
        self.sent_messages.append((recipient, message))
        return True


class FailingNotifier(Notifier):
    """A test double that always fails to send."""

    def send(self, recipient: str, message: str) -> bool:
        return False

What does this code do?

Both classes implement the Notifier abstract base class, so they can be injected anywhere a real notifier would go.

  • DummyNotifier appends each call to a sent_messages list so you can assert exactly what was sent.
  • FailingNotifier always returns False, simulating a channel that is down.

Why can you swap in fake notifiers so easily?

This is DIP in action. Because NotificationService only knows about the Notifier abstraction, it has no idea whether you injected a real email sender or a recording dummy.

Try imagining how you would test the old NotificationManager God class in isolation. You cannot swap anything out because every channel is hard-coded inside the same method.

  • Save tests/test_notifications.py.
Write the test functions

Each test function exercises one specific behaviour of your system. This keeps tests focused so that when one fails, you know exactly what broke.

  • In tests/test_notifications.py, add the following four test functions below the FailingNotifier class:
def test_dummy_notifier_records_message() -> None:
    notifier = DummyNotifier()
    result = notifier.send("alice@example.com", "Hello!")
    assert result is True
    assert notifier.sent_messages == [("alice@example.com", "Hello!")]


def test_notification_service_sends_to_all() -> None:
    notifier_a = DummyNotifier()
    notifier_b = DummyNotifier()
    service = NotificationService([notifier_a, notifier_b])

    service.notify_all("bob@example.com", "Test message")

    assert notifier_a.sent_messages == [("bob@example.com", "Test message")]
    assert notifier_b.sent_messages == [("bob@example.com", "Test message")]


def test_notification_service_returns_results() -> None:
    notifier_a = DummyNotifier()
    notifier_b = DummyNotifier()
    service = NotificationService([notifier_a, notifier_b])

    results = service.notify_all("carol@example.com", "Hi")

    assert results == {"DummyNotifier": True}


def test_single_notifier_failure() -> None:
    good = DummyNotifier()
    bad = FailingNotifier()
    service = NotificationService([good, bad])

    results = service.notify_all("dave@example.com", "Important!")

    assert results["DummyNotifier"] is True
    assert results["FailingNotifier"] is False
    assert good.sent_messages == [("dave@example.com", "Important!")]

What does each test verify?

  • test_dummy_notifier_records_message confirms the test double itself works correctly.
  • test_notification_service_sends_to_all proves every registered notifier receives the message.
  • test_notification_service_returns_results checks that the results dictionary maps class names to success booleans.
  • test_single_notifier_failure verifies the service handles partial failures gracefully without crashing.

Why does test_notification_service_returns_results only have one key?

The service uses each notifier's class name as the dictionary key. When you pass two DummyNotifier instances, both share the same class name, so the second write overwrites the first.

This is a known simplification for the teaching context. In production you would use unique IDs or a list of results.

  • Save tests/test_notifications.py.

Seeing red underlines on the imports?

VS Code may show import warnings if its Python interpreter is not set to your virtual environment. This will not affect running the tests from the terminal.

help me fix Python import warnings in VS Code

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

"""Tests for the SOLID notification service."""

from src.notifiers import Notifier
from src.service import NotificationService


class DummyNotifier(Notifier):
    """A test double that records messages instead of sending them."""

    def __init__(self) -> None:
        self.sent_messages: list[tuple[str, str]] = []

    def send(self, recipient: str, message: str) -> bool:
        self.sent_messages.append((recipient, message))
        return True


class FailingNotifier(Notifier):
    """A test double that always fails to send."""

    def send(self, recipient: str, message: str) -> bool:
        return False


def test_dummy_notifier_records_message() -> None:
    notifier = DummyNotifier()
    result = notifier.send("alice@example.com", "Hello!")
    assert result is True
    assert notifier.sent_messages == [("alice@example.com", "Hello!")]


def test_notification_service_sends_to_all() -> None:
    notifier_a = DummyNotifier()
    notifier_b = DummyNotifier()
    service = NotificationService([notifier_a, notifier_b])

    service.notify_all("bob@example.com", "Test message")

    assert notifier_a.sent_messages == [("bob@example.com", "Test message")]
    assert notifier_b.sent_messages == [("bob@example.com", "Test message")]


def test_notification_service_returns_results() -> None:
    notifier_a = DummyNotifier()
    notifier_b = DummyNotifier()
    service = NotificationService([notifier_a, notifier_b])

    results = service.notify_all("carol@example.com", "Hi")

    assert results == {"DummyNotifier": True}


def test_single_notifier_failure() -> None:
    good = DummyNotifier()
    bad = FailingNotifier()
    service = NotificationService([good, bad])

    results = service.notify_all("dave@example.com", "Important!")

    assert results["DummyNotifier"] is True
    assert results["FailingNotifier"] is False
    assert good.sent_messages == [("dave@example.com", "Important!")]
Run the test suite

Time to let pytest discover and run all four tests.

  • Run the tests in verbose mode from the solid-notifications project root by running this command:
pytest tests/ -v
  • You should see all 4 tests listed with PASSED next to each one, followed by a summary line showing 4 passed.

What should I see?

The verbose flag (-v) prints each test function name alongside its result. You should see four lines ending in PASSED with green text, plus a final summary.

Tests failing or not found?

  • Make sure you are running the command from the solid-notifications folder (the project root), not from inside tests/ or src/.
  • Confirm both src/__init__.py and tests/__init__.py exist (they can be empty files).
  • Check that your virtual environment is still active. Your terminal prompt should show (venv).

Help me debug my failing pytest tests

Secret mission

Add a WebhookNotifier Without Touching Existing Code

Your SOLID architecture is built. But can it handle change? In this challenge, you'll add an entirely new notification channel and prove that you can extend the system by writing new code only. Zero changes to notifiers.py or service.py.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • solid-notifications project folder (contains all source code, tests, and the venv virtual environment).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep the project as a reference for SOLID patterns in Python.

  • Your virtual environment stays activated and ready for more work.
  • Try extending the project with more notification channels (Slack, Discord, etc.) to practice the Open/Closed Principle.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. Your files stay on disk and you can reactivate later.

  • Deactivate the virtual environment by running:
deactivate

When you come back, navigate to your project folder and reactivate with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).

Delete - I don't want to use this again

Remove all project files and the virtual environment completely.

  • Deactivate the virtual environment first by running:
deactivate
  • Move up to the parent directory by running:
cd ..
  • Delete the entire project folder. On macOS/Linux, run:
rm -rf solid-notifications
  • On Windows, run:
rmdir /s /q solid-notifications
  • Confirm the folder is gone by running:
ls

You should no longer see solid-notifications in the output.

Nice Work!

Nice Work!

Nice work! You just transformed a messy monolithic class into a clean, extensible notification service that follows all five SOLID principles.

You've learned how to:

  • Refactored a God class into a clean architecture with abstract base classes, concrete implementations, and a service orchestrator.
  • Applied all 5 SOLID principles (SRP, OCP, LSP, ISP, DIP) to design extensible, testable Python code.
  • Wrote a pytest test suite using dependency injection to prove the architecture is testable with simple test doubles.
  • Added a WebhookNotifier without modifying any existing code, demonstrating the Open/Closed Principle in action.

Ready to quiz yourself?