Card 03/ 07
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.
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")EMAIL [email protected]: Your order has shipped
SMS +44 7700 900123: Your order has shipped
PUSH device-5f2a: Your order has shippedAdding 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.
← → move through sections and cards