Card 03/ 07

ExampleDifficulty: Intermediate2 min

SOLID Example: A Notification Service

Email today, SMS next month, push the month after, and a test suite that stays fast.

The same reasoning that fixed the checkout produces a design from scratch, and this example shows it on a notification service. Three questions drive it: who asks for changes, what is expected to vary, and what the policy needs from the outside world.

The service sends a user a message on the channel they prefer. Today that is email; SMS is planned for next month and push notifications after that. Asking the three questions gives the design. Marketing asks for changes to which channel a user gets, and operations asks for changes to how each channel is delivered: two reasons to change, two places. The channel is what varies, so it becomes an interface with one implementation per channel. The service needs to deliver a message and to look up a preference, so it owns two small interfaces for exactly those.

The notification service and its two interfaces
py
from typing import Protocol


class Channel(Protocol):
    def deliver(self, address: str, message: str) -> None: ...


class PreferenceSource(Protocol):
    def channel_for(self, user_id: int) -> tuple[str, str]: ...


class NotificationService:
    def __init__(self, channels: dict[str, Channel], preferences: PreferenceSource):
        self.channels = channels
        self.preferences = preferences

    def notify(self, user_id: int, message: str) -> None:
        kind, address = self.preferences.channel_for(user_id)
        self.channels[kind].deliver(address, message)


class EmailChannel:
    def deliver(self, address, message):
        print(f"EMAIL {address}: {message}")


class SmsChannel:
    def deliver(self, address, message):
        print(f"SMS {address}: {message}")


class PushChannel:
    def deliver(self, address, message):
        print(f"PUSH {address}: {message}")


class FixedPreferences:
    def __init__(self, table):
        self.table = table

    def channel_for(self, user_id):
        return self.table[user_id]


preferences = FixedPreferences({
    1: ("email", "[email protected]"),
    2: ("sms", "+44 7700 900123"),
    3: ("push", "device-5f2a"),
})
service = NotificationService(
    {"email": EmailChannel(), "sms": SmsChannel(), "push": PushChannel()},
    preferences,
)
for user_id in (1, 2, 3):
    service.notify(user_id, "Your order has shipped")
OutputRan with python:3.12-slim (Python 3.12.13)
EMAIL [email protected]: Your order has shipped
SMS +44 7700 900123: Your order has shipped
PUSH device-5f2a: Your order has shipped
One service, three channels, and preferences supplied from outside.

Adding push next month is a new PushChannel class and one entry in the dictionary; NotificationService is not edited, which is open/closed. Each channel can stand in for any other, which is Liskov. Each interface has one method, because the service calls one method on each, which is interface segregation shaped by a real client. And because the preferences arrive through the constructor, a test can pass a dictionary of fixed preferences, as the listing does, which is dependency inversion paying for itself.

What this example shares with the checkout is the method, not the code: find the reasons to change, find what varies, and give the policy interfaces for what it needs.