Notification System
A service that sends the same alert over email, SMS, or push without the caller ever
knowing which one actually fired. The interesting decision isn't "how do I send an SMS" -
it's how to keep NotificationService ignorant of the channel it's using.
Requirements
Functional
- A caller sends a notification to a user through one or more channels (email, SMS, push).
- Each channel has its own delivery mechanism and its own way of formatting a message.
- A user can opt out of a channel; the service must not deliver through a channel the user has disabled.
- Failed sends should be retried a bounded number of times before being given up on.
Non-functional
- Adding a new channel (say, WhatsApp) must not require editing
NotificationServiceor any existing channel class. - Sending must not block the caller on slow network channels for longer than necessary (this page models the retry loop; a real system would hand delivery to a queue).
Design
NotificationService takes a message and a list of NotificationChannel implementations
and fires each one - it never has an if (channel == EMAIL) anywhere. Each channel owns
its own formatting and its own transport, so a new channel is a new class, not a new branch.
- 1The caller hands over a user and a message - nothing about how it should be delivered.
- 2The service asks preferences which channels this user actually wants, not which channels exist.
- 3For each enabled channel the service calls the same method, regardless of channel type.
- 4Each channel formats the message its own way - SMS truncates, email adds a subject line.
- 5Retry policy lives in the service so it applies uniformly, not per channel.
Retrying lives in the service, not the channel, because "how many times to retry" is a policy decision that should be the same regardless of which channel is flaky today.
Class diagram
Code
Design decisions
NotificationChannelis an interface, not a switch statement. The alternative - one method with a branch per channel type - means every new channel touches a shared file and risks breaking an unrelated one. An interface makes each channel additive: drop in a class, register it, done.- Opt-out lives on
NotificationPreferences, not baked into each channel. A channel only knows how to send; whether it should send for a given user is a separate question answered once, in one place, instead of every channel re-implementing the same check. - Retry is a wrapper around
send, not duplicated in every channel.NotificationServicecallssendWithRetry, which calls the channel'ssendup to a fixed number of times. Every channel gets retry behavior for free and none of them had to write it themselves. - What's missing for a real system: delivery here is synchronous and in-process; a production version would enqueue each channel send onto a message queue so a slow SMS provider can't hold up an email that would have gone through instantly, and would track delivery receipts per channel rather than a single boolean.
Common follow-ups
- What happens if a channel's
send()throws instead of returning false?sendWithRetry's loop only checks the boolean return value, so an uncaught exception propagates up and aborts the wholenotify()call, killing delivery to every channel after it for that user. A hardened version would catch per-channel exceptions the way pub-sub'sTopic.publishisolates subscriber failures. - How would you add a WhatsApp channel? A new class implementing
NotificationChannel(its own formatting and its own transport), registered into the service's channel list - no change toNotificationService,NotificationPreferences, or any existing channel. - How would different users get different retry counts (a paid tier gets more retries)?
maxRetriescurrently lives as one global value onNotificationService; per-user tuning would need it looked up per user, most naturally from a policy object alongsideNotificationPreferences, rather than being a fixed constructor argument. - The design decisions flag moving delivery to a queue - what's the concrete change?
sendWithRetry's synchronous loop-and-block becomes "enqueue once per channel," with a separate worker per channel type consuming its own queue and owning retry/backoff there -NotificationService's job narrows to routing and filtering by preference, not sending.
Check yourself
Why is NotificationChannel an interface instead of one method with a switch on ChannelType?