Design Patterns, Explained Simply · Capstone Projects
Capstone: A Notification System with Undo
Design a notification system that reacts to events, sends through vendors with different APIs, and lets admins undo a broadcast, using Observer, Adapter and Command.
Scenario
Example company: a project-management app needs notifications. When a task is assigned, the assignee should get an email and a chat message. Admins can broadcast announcements to all users, and they want an undo button for broadcasts sent by mistake (the message is retracted from the in-app inbox).
The email vendor's SDK has send_mail(address, subject, body); the chat vendor's SDK has post(channel_id, payload_dict). The team expects to add SMS and push notifications later.
Requirements
- Task events must not depend on email or chat code: new channels are added without touching task code.
- The notification code should talk to one
Channelinterface, whatever the vendor's API looks like. - Broadcasts must be undoable; undo removes the message from users' in-app inboxes and records a retraction.
- Everything must be testable without real vendors.
Constraints
- No frameworks or message brokers; in-process only for this exercise.
- Emails and chat messages already sent can't be "unsent". Undo must be honest about that.
Deliverables
- A design naming the patterns used and the role of each class.
- Code that demonstrates: a task assignment notifying through two vendors, and a broadcast followed by undo.
- A note on how the design changes if notifications move to a separate service.
Hints
- Events → reactions: Observer (in-process) now; Pub/Sub if it becomes a separate service.
- Vendor SDKs with different shapes: one Adapter per vendor behind a
Channelinterface. - Undo: a Command that stores what it changed, with a compensating action for things that can't be reversed.
One possible solution
class EmailSdk: # vendor SDKs with their own shapes
def send_mail(self, address, subject, body):
print(f" [email] to {address}: {subject}")
class ChatSdk:
def post(self, channel_id, payload):
print(f" [chat] #{channel_id}: {payload['text']}")
class EmailChannel: # ADAPTER: vendor API -> Channel.send(user, text)
def __init__(self, sdk):
self.sdk = sdk
def send(self, user, text):
self.sdk.send_mail(f"{user}@example.com", text, text)
class ChatChannel:
def __init__(self, sdk):
self.sdk = sdk
def send(self, user, text):
self.sdk.post(f"dm-{user}", {"text": text})
class Events: # OBSERVER: tasks publish, notifiers subscribe
def __init__(self):
self.handlers = []
def subscribe(self, handler):
self.handlers.append(handler)
def publish(self, event):
for handler in self.handlers:
handler(event)
class Broadcast: # COMMAND: execute + undo (compensating for what can't be reversed)
def __init__(self, inboxes, users, text):
self.inboxes, self.users, self.text = inboxes, users, text
def execute(self):
for user in self.users:
self.inboxes.setdefault(user, []).append(self.text)
print(f"broadcast sent to {len(self.users)} inboxes")
def undo(self):
for user in self.users:
self.inboxes[user].remove(self.text)
print(f"broadcast retracted; correction logged for: {self.text}")
channels = [EmailChannel(EmailSdk()), ChatChannel(ChatSdk())]
events = Events()
events.subscribe(lambda e: [c.send(e["assignee"], f"You were assigned '{e['task']}'") for c in channels])
print("task assigned:")
events.publish({"task": "Fix login bug", "assignee": "ada"})
inboxes, history = {}, []
command = Broadcast(inboxes, ["ada", "bob"], "Office closed tomorrow")
command.execute()
history.append(command)
print("ada's inbox:", inboxes["ada"])
history.pop().undo()
print("ada's inbox:", inboxes["ada"])
Output
task assigned: [email] to [email protected]: You were assigned 'Fix login bug' [chat] #dm-ada: You were assigned 'Fix login bug' broadcast sent to 2 inboxes ada's inbox: ['Office closed tomorrow'] broadcast retracted; correction logged for: Office closed tomorrow ada's inbox: []
class EmailSdk { // vendor SDKs with their own shapes
sendMail(address: string, subject: string, _body: string) { console.log(` [email] to ${address}: ${subject}`); }
}
class ChatSdk {
post(channelId: string, payload: { text: string }) { console.log(` [chat] #${channelId}: ${payload.text}`); }
}
interface Channel { send(user: string, text: string): void; }
class EmailChannel implements Channel { // ADAPTER: vendor API -> Channel.send(user, text)
constructor(private sdk: EmailSdk) {}
send(user: string, text: string) { this.sdk.sendMail(`${user}@example.com`, text, text); }
}
class ChatChannel implements Channel {
constructor(private sdk: ChatSdk) {}
send(user: string, text: string) { this.sdk.post(`dm-${user}`, { text }); }
}
type TaskEvent = { task: string; assignee: string };
class Events { // OBSERVER: tasks publish, notifiers subscribe
private handlers: ((e: TaskEvent) => void)[] = [];
subscribe(handler: (e: TaskEvent) => void) { this.handlers.push(handler); }
publish(event: TaskEvent) { for (const h of this.handlers) h(event); }
}
class Broadcast { // COMMAND: execute + undo (compensating for what can't be reversed)
constructor(private inboxes: Map<string, string[]>, private users: string[], private text: string) {}
execute() {
for (const u of this.users) this.inboxes.set(u, [...(this.inboxes.get(u) ?? []), this.text]);
console.log(`broadcast sent to ${this.users.length} inboxes`);
}
undo() {
for (const u of this.users) this.inboxes.set(u, this.inboxes.get(u)!.filter((m) => m !== this.text));
console.log(`broadcast retracted; correction logged for: ${this.text}`);
}
}
const channels: Channel[] = [new EmailChannel(new EmailSdk()), new ChatChannel(new ChatSdk())];
const events = new Events();
events.subscribe((e) => channels.forEach((c) => c.send(e.assignee, `You were assigned '${e.task}'`)));
console.log("task assigned:");
events.publish({ task: "Fix login bug", assignee: "ada" });
const inboxes = new Map<string, string[]>();
const history: Broadcast[] = [];
const command = new Broadcast(inboxes, ["ada", "bob"], "Office closed tomorrow");
command.execute();
history.push(command);
const show = (u: string) => `[${(inboxes.get(u) ?? []).map((m) => `'${m}'`).join(", ")}]`;
console.log("ada's inbox:", show("ada"));
history.pop()!.undo();
console.log("ada's inbox:", show("ada"));
Output
task assigned: [email] to [email protected]: You were assigned 'Fix login bug' [chat] #dm-ada: You were assigned 'Fix login bug' broadcast sent to 2 inboxes ada's inbox: ['Office closed tomorrow'] broadcast retracted; correction logged for: Office closed tomorrow ada's inbox: []
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
public class Main {
static String show(List<String> inbox) {
return inbox.stream().map(m -> "'" + m + "'").collect(Collectors.joining(", ", "[", "]"));
}
public static void main(String[] args) {
List<Channel> channels = List.of(new EmailChannel(new EmailSdk()), new ChatChannel(new ChatSdk()));
Events events = new Events();
events.subscribe(e -> channels.forEach(c -> c.send(e.assignee(), "You were assigned '" + e.task() + "'")));
System.out.println("task assigned:");
events.publish(new TaskEvent("Fix login bug", "ada"));
Map<String, List<String>> inboxes = new HashMap<>();
Deque<Broadcast> history = new ArrayDeque<>();
Broadcast command = new Broadcast(inboxes, List.of("ada", "bob"), "Office closed tomorrow");
command.execute();
history.push(command);
System.out.println("ada's inbox: " + show(inboxes.get("ada")));
history.pop().undo();
System.out.println("ada's inbox: " + show(inboxes.get("ada")));
}
}
class EmailSdk { // vendor SDKs with their own shapes
void sendMail(String address, String subject, String body) { System.out.println(" [email] to " + address + ": " + subject); }
}
class ChatSdk {
void post(String channelId, Map<String, String> payload) { System.out.println(" [chat] #" + channelId + ": " + payload.get("text")); }
}
interface Channel { void send(String user, String text); }
class EmailChannel implements Channel { // ADAPTER: vendor API -> Channel.send(user, text)
private final EmailSdk sdk;
EmailChannel(EmailSdk sdk) { this.sdk = sdk; }
public void send(String user, String text) { sdk.sendMail(user + "@example.com", text, text); }
}
class ChatChannel implements Channel {
private final ChatSdk sdk;
ChatChannel(ChatSdk sdk) { this.sdk = sdk; }
public void send(String user, String text) { sdk.post("dm-" + user, Map.of("text", text)); }
}
record TaskEvent(String task, String assignee) {}
class Events { // OBSERVER: tasks publish, notifiers subscribe
private final List<Consumer<TaskEvent>> handlers = new ArrayList<>();
void subscribe(Consumer<TaskEvent> handler) { handlers.add(handler); }
void publish(TaskEvent event) { handlers.forEach(h -> h.accept(event)); }
}
class Broadcast { // COMMAND: execute + undo (compensating for what can't be reversed)
private final Map<String, List<String>> inboxes;
private final List<String> users;
private final String text;
Broadcast(Map<String, List<String>> inboxes, List<String> users, String text) {
this.inboxes = inboxes; this.users = users; this.text = text;
}
void execute() {
for (String u : users) inboxes.computeIfAbsent(u, k -> new ArrayList<>()).add(text);
System.out.println("broadcast sent to " + users.size() + " inboxes");
}
void undo() {
for (String u : users) inboxes.get(u).remove(text);
System.out.println("broadcast retracted; correction logged for: " + text);
}
}
Output
task assigned: [email] to [email protected]: You were assigned 'Fix login bug' [chat] #dm-ada: You were assigned 'Fix login bug' broadcast sent to 2 inboxes ada's inbox: ['Office closed tomorrow'] broadcast retracted; correction logged for: Office closed tomorrow ada's inbox: []
Run this on your computer — see “Get Started” for setup.
<?php
class EmailSdk { // vendor SDKs with their own shapes
public function sendMail(string $address, string $subject, string $body): void { echo " [email] to $address: $subject\n"; }
}
class ChatSdk {
public function post(string $channelId, array $payload): void { echo " [chat] #$channelId: {$payload['text']}\n"; }
}
interface Channel { public function send(string $user, string $text): void; }
class EmailChannel implements Channel { // ADAPTER: vendor API -> Channel.send(user, text)
public function __construct(private EmailSdk $sdk) {}
public function send(string $user, string $text): void { $this->sdk->sendMail("$user@example.com", $text, $text); }
}
class ChatChannel implements Channel {
public function __construct(private ChatSdk $sdk) {}
public function send(string $user, string $text): void { $this->sdk->post("dm-$user", ["text" => $text]); }
}
class Events { // OBSERVER: tasks publish, notifiers subscribe
private array $handlers = [];
public function subscribe(callable $handler): void { $this->handlers[] = $handler; }
public function publish(array $event): void { foreach ($this->handlers as $h) $h($event); }
}
class Broadcast { // COMMAND: execute + undo (compensating for what can't be reversed)
public function __construct(private ArrayObject $inboxes, private array $users, private string $text) {}
public function execute(): void {
foreach ($this->users as $u) $this->inboxes[$u] = [...($this->inboxes[$u] ?? []), $this->text];
echo "broadcast sent to ", count($this->users), " inboxes\n";
}
public function undo(): void {
foreach ($this->users as $u) $this->inboxes[$u] = array_values(array_diff($this->inboxes[$u], [$this->text]));
echo "broadcast retracted; correction logged for: {$this->text}\n";
}
}
$channels = [new EmailChannel(new EmailSdk()), new ChatChannel(new ChatSdk())];
$events = new Events();
$events->subscribe(function (array $e) use ($channels) {
foreach ($channels as $c) $c->send($e["assignee"], "You were assigned '{$e['task']}'");
});
echo "task assigned:\n";
$events->publish(["task" => "Fix login bug", "assignee" => "ada"]);
$inboxes = new ArrayObject();
$history = [];
$command = new Broadcast($inboxes, ["ada", "bob"], "Office closed tomorrow");
$command->execute();
$history[] = $command;
$show = fn(string $u) => "[" . implode(", ", array_map(fn($m) => "'$m'", $inboxes[$u] ?? [])) . "]";
echo "ada's inbox: ", $show("ada"), "\n";
array_pop($history)->undo();
echo "ada's inbox: ", $show("ada"), "\n";
Output
task assigned: [email] to [email protected]: You were assigned 'Fix login bug' [chat] #dm-ada: You were assigned 'Fix login bug' broadcast sent to 2 inboxes ada's inbox: ['Office closed tomorrow'] broadcast retracted; correction logged for: Office closed tomorrow ada's inbox: []
Undo removed the broadcast from the inboxes the app controls and logged a correction. It doesn't pretend to unsend emails. Adding SMS is one more adapter in channels.
Deliverables
Which patterns does your design use, and what role does each class play?
Model answer
Observer: task code publishes events and notifiers subscribe. Adapter: one per vendor SDK behind a Channel interface. Command: broadcasts as objects with execute() and undo() kept in a history.
Rubric
Look for: task code independent of channels, vendor details isolated in adapters, undo implemented as a command with an honest compensating action. Misses: task code calling vendors directly, undo that claims to unsend emails.
How does undo work for notifications that have already left the system?
Model answer
Undo can only reverse what the app controls (in-app inbox entries). For emails and chat messages already delivered, the command records a retraction and can send a correction, a compensating action rather than a true reversal.
Rubric
Strong answers distinguish reversible internal state from irreversible external side effects and design compensations for the latter.
What changes if notifications move to a separate service?
Model answer
In-process Observer becomes Pub/Sub through a broker (topics like task.assigned), subscribers must be idempotent because of at-least-once delivery, failures go to retries and dead-letter queues, and the command history must be stored durably.
Rubric
Look for: broker/topics, idempotency, retries and dead letters, durable command history. Weak answers keep synchronous in-process calls across services.
Open in the app to mark lessons complete, take notes and track your progress.