Part 01 · Low Level Design
Observer and Event-Driven Design
When one change has to reach many objects: Observer, a Mediator that stops everyone talking to everyone, and an in-process event bus, before any real broker shows up.
⏱ 1-2 hours · Station 10 of 80
PlaceOrder knows too many people
When a customer places an order, the shop must send an SMS, add loyalty points, record analytics, tell the warehouse and email an invoice. The first version of PlaceOrder calls all five services directly, one after another.
Then problems pile up. The SMS gateway goes down and orders fail, because PlaceOrder stops at the first error. Marketing wants a new "first order" coupon, and someone has to edit PlaceOrder again. The analytics team wants to switch off their tracking for a week, and that's another edit. PlaceOrder has become the one function everyone needs to change, and it knows about everyone.
The fix is to flip who knows whom: the order announces "an order was placed", and anyone who cares listens. That's the Observer pattern, and the idea behind every event-driven system.
The problem: the caller knows every reaction
| Symptom | Cause |
|---|---|
| Adding a reaction (coupon, warehouse) edits the core flow | The core calls every reaction directly |
| One failing reaction (SMS) breaks the whole order | Reactions run inside the core flow with no isolation |
| You can't switch a reaction off without a deploy | Reactions are hard-wired |
| The core service imports five other modules | Dependencies point from the core outward |
Think of a mosque's loudspeaker and a WhatsApp group
The muezzin doesn't phone every household to say it's prayer time. One announcement goes out, and whoever wants to listen hears it. New families move in and hear it too; the muezzin's job doesn't change. That's Observer: one subject, many listeners, and the subject doesn't keep a list of who acts on it.
A WhatsApp group is a mediator: members don't need each other's numbers; they talk to the group, and the group delivers. An event bus is the same idea inside your program: publishers and subscribers know only the bus and the topic name.
Where the comparison breaks down. If you miss the announcement, you just miss it. In software, missing an event can mean no invoice or lost points, so you have to decide: is "in-process and best-effort" good enough, or do you need a durable queue (HLD chapters) that keeps events until they're handled?
The order service publishes 'order.placed' to a bus. SMS, loyalty and analytics subscribe. Marketing adds a 'first order coupon' reaction. Which code changes?
Observer, mediator and event bus
- Observer: a subject keeps a list of observers and notifies each when something happens. Observers register and unregister themselves. The subject depends only on a small interface ("something that can be notified"), not on concrete reactions.
- Event: a fact about the past, named in past tense:
order.placed,payment.failed. It carries the data reactions need (order ID, customer, amount), and nothing about who will react. - Event bus (in-process pub/sub): a shared object where publishers publish to a topic and subscribers subscribe to topics. Neither side knows the other. It's Observer with a middleman, so many subjects and many observers can meet without wiring each pair.
- Mediator: an object that coordinates a group of objects so they don't talk to each other directly. A chat room, an air-traffic controller, a ride-matching service: each participant talks to the mediator. The bus is a simple mediator for events; a mediator can also hold coordination logic.
- Isolation: one subscriber's failure must not stop the others or the publisher. Catch errors per subscriber, log them, and decide whether to retry.
- Trade-off: the flow becomes harder to follow. "What happens when an order is placed?" is no longer one function; it's "everything subscribed to
order.placed". Keep subscriptions registered in one visible place.
One event, many reactions
bus.Publish(order.placed,
Step 1/4 The order service announces a fact
Order #503 for Rahim, 300 taka, was placed. The service publishes it and is done. It doesn't know who's listening.
- topic
- order.placed
- order
- #503
Who knows whom?
Draw the arrows of knowledge:
- Direct calls: order → SMS, order → loyalty, order → analytics. The core knows everyone.
- With events: order → bus ← SMS, loyalty, analytics. The core knows only the bus; reactions know only the bus and the topic.
When adding a feature would mean editing the core flow just to call something new, ask whether it should be a subscriber instead.
Try it: event or direct call?
Should this be an event?
publish(user.registered)
The order event bus in code
A small in-process bus with topics, subscribe/unsubscribe, and per-subscriber error isolation. Three subscribers react to orders, one later unsubscribes, and one fails.
package main
import (
"errors"
"fmt"
)
type Event struct {
OrderID int
Customer string
Amount int // taka
}
type Handler func(e Event) error
type subscription struct {
id int
name string
fn Handler
}
// Bus is an in-process event bus: publishers and subscribers only know the bus and the topic.
type Bus struct {
subs map[string][]subscription
nextID int
}
func NewBus() *Bus { return &Bus{subs: map[string][]subscription{}} }
func (b *Bus) Subscribe(topic, name string, fn Handler) int {
b.nextID++
b.subs[topic] = append(b.subs[topic], subscription{b.nextID, name, fn})
return b.nextID
}
func (b *Bus) Unsubscribe(id int) {
for topic, list := range b.subs {
kept := list[:0:0]
for _, s := range list {
if s.id != id {
kept = append(kept, s)
}
}
b.subs[topic] = kept
}
}
// Publish calls every subscriber in order. One failing subscriber doesn't stop the others.
func (b *Bus) Publish(topic string, e Event) {
fmt.Printf("publish %s #%d\n", topic, e.OrderID)
for _, s := range b.subs[topic] {
if err := s.fn(e); err != nil {
fmt.Printf(" %-9s : failed: %v\n", s.name, err)
}
}
}
func main() {
bus := NewBus()
// The order service only publishes. It doesn't know who listens.
bus.Subscribe("order.placed", "sms", func(e Event) error {
if e.OrderID == 503 {
return errors.New("gateway down")
}
fmt.Printf(" %-9s : sent \"order #%d confirmed\" to %s\n", "sms", e.OrderID, e.Customer)
return nil
})
points := map[string]int{}
loyalty := func(sign int) Handler {
return func(e Event) error {
p := sign * (e.Amount / 100) // 1 point per 100 taka
points[e.Customer] += p
fmt.Printf(" %-9s : %s %+d points (total %d)\n", "loyalty", e.Customer, p, points[e.Customer])
return nil
}
}
bus.Subscribe("order.placed", "loyalty", loyalty(+1))
bus.Subscribe("order.cancelled", "loyalty", loyalty(-1))
analytics := bus.Subscribe("order.placed", "analytics", func(e Event) error {
fmt.Printf(" %-9s : recorded #%d\n", "analytics", e.OrderID)
return nil
})
bus.Publish("order.placed", Event{501, "Rahim", 720})
bus.Publish("order.placed", Event{502, "Nadia", 1500})
bus.Unsubscribe(analytics)
fmt.Println("analytics unsubscribed")
bus.Publish("order.placed", Event{503, "Rahim", 300})
bus.Publish("order.cancelled", Event{502, "Nadia", 1500})
}from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class Event:
order_id: int
customer: str
amount: int # taka
Handler = Callable[[Event], None]
class Bus:
"""An in-process event bus: publishers and subscribers only know the bus and the topic."""
def __init__(self) -> None:
self._subs: dict[str, list[tuple[int, str, Handler]]] = {}
self._next_id = 0
def subscribe(self, topic: str, name: str, fn: Handler) -> int:
self._next_id += 1
self._subs.setdefault(topic, []).append((self._next_id, name, fn))
return self._next_id
def unsubscribe(self, sub_id: int) -> None:
for topic, subs in self._subs.items():
self._subs[topic] = [s for s in subs if s[0] != sub_id]
def publish(self, topic: str, e: Event) -> None:
"""Calls every subscriber in order. One failing subscriber doesn't stop the others."""
print(f"publish {topic} #{e.order_id}")
for _, name, fn in self._subs.get(topic, []):
try:
fn(e)
except Exception as err:
print(f" {name:<9} : failed: {err}")
bus = Bus()
# The order service only publishes. It doesn't know who listens.
def sms(e: Event) -> None:
if e.order_id == 503:
raise RuntimeError("gateway down")
print(f' {"sms":<9} : sent "order #{e.order_id} confirmed" to {e.customer}')
bus.subscribe("order.placed", "sms", sms)
points: dict[str, int] = {}
def loyalty(sign: int) -> Handler:
def handle(e: Event) -> None:
p = sign * (e.amount // 100) # 1 point per 100 taka
points[e.customer] = points.get(e.customer, 0) + p
print(f" {'loyalty':<9} : {e.customer} {p:+d} points (total {points[e.customer]})")
return handle
bus.subscribe("order.placed", "loyalty", loyalty(+1))
bus.subscribe("order.cancelled", "loyalty", loyalty(-1))
analytics = bus.subscribe("order.placed", "analytics",
lambda e: print(f" {'analytics':<9} : recorded #{e.order_id}"))
bus.publish("order.placed", Event(501, "Rahim", 720))
bus.publish("order.placed", Event(502, "Nadia", 1500))
bus.unsubscribe(analytics)
print("analytics unsubscribed")
bus.publish("order.placed", Event(503, "Rahim", 300))
bus.publish("order.cancelled", Event(502, "Nadia", 1500))interface OrderEvent {
readonly orderId: number;
readonly customer: string;
readonly amount: number; // taka
}
type Handler = (e: OrderEvent) => void;
// An in-process event bus: publishers and subscribers only know the bus and the topic.
class Bus {
readonly #subs = new Map<string, { id: number; name: string; fn: Handler }[]>();
#nextId = 0;
subscribe(topic: string, name: string, fn: Handler): number {
const id = ++this.#nextId;
this.#subs.set(topic, [...(this.#subs.get(topic) ?? []), { id, name, fn }]);
return id;
}
unsubscribe(id: number): void {
for (const [topic, list] of this.#subs) this.#subs.set(topic, list.filter((s) => s.id !== id));
}
// Calls every subscriber in order. One failing subscriber doesn't stop the others.
publish(topic: string, e: OrderEvent): void {
console.log(`publish ${topic} #${e.orderId}`);
for (const s of this.#subs.get(topic) ?? []) {
try {
s.fn(e);
} catch (err) {
console.log(` ${s.name.padEnd(9)} : failed: ${(err as Error).message}`);
}
}
}
}
const bus = new Bus();
// The order service only publishes. It doesn't know who listens.
bus.subscribe("order.placed", "sms", (e) => {
if (e.orderId === 503) throw new Error("gateway down");
console.log(` ${"sms".padEnd(9)} : sent "order #${e.orderId} confirmed" to ${e.customer}`);
});
const points = new Map<string, number>();
const loyalty =
(sign: number): Handler =>
(e) => {
const p = sign * Math.floor(e.amount / 100); // 1 point per 100 taka
points.set(e.customer, (points.get(e.customer) ?? 0) + p);
const signed = p >= 0 ? `+${p}` : `${p}`;
console.log(` ${"loyalty".padEnd(9)} : ${e.customer} ${signed} points (total ${points.get(e.customer)})`);
};
bus.subscribe("order.placed", "loyalty", loyalty(+1));
bus.subscribe("order.cancelled", "loyalty", loyalty(-1));
const analytics = bus.subscribe("order.placed", "analytics", (e) =>
console.log(` ${"analytics".padEnd(9)} : recorded #${e.orderId}`),
);
bus.publish("order.placed", { orderId: 501, customer: "Rahim", amount: 720 });
bus.publish("order.placed", { orderId: 502, customer: "Nadia", amount: 1500 });
bus.unsubscribe(analytics);
console.log("analytics unsubscribed");
bus.publish("order.placed", { orderId: 503, customer: "Rahim", amount: 300 });
bus.publish("order.cancelled", { orderId: 502, customer: "Nadia", amount: 1500 });use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
#[derive(Clone)]
struct Event { order_id: u32, customer: String, amount: i32 } // amount in taka
type Handler = Box<dyn Fn(&Event) -> Result<(), String>>;
// An in-process event bus: publishers and subscribers only know the bus and the topic.
#[derive(Default)]
struct Bus { subs: HashMap<String, Vec<(u32, String, Handler)>>, next_id: u32 }
impl Bus {
fn subscribe(&mut self, topic: &str, name: &str, f: Handler) -> u32 {
self.next_id += 1;
self.subs.entry(topic.into()).or_default().push((self.next_id, name.into(), f));
self.next_id
}
fn unsubscribe(&mut self, id: u32) {
for list in self.subs.values_mut() { list.retain(|(i, _, _)| *i != id); }
}
// Calls every subscriber in order. One failing subscriber doesn't stop the others.
fn publish(&self, topic: &str, e: &Event) {
println!("publish {topic} #{}", e.order_id);
for (_, name, f) in self.subs.get(topic).into_iter().flatten() {
if let Err(err) = f(e) { println!(" {name:<9} : failed: {err}"); }
}
}
}
fn main() {
let mut bus = Bus::default();
// The order service only publishes. It doesn't know who listens.
bus.subscribe("order.placed", "sms", Box::new(|e| {
if e.order_id == 503 { return Err("gateway down".into()); }
println!(" {:<9} : sent \"order #{} confirmed\" to {}", "sms", e.order_id, e.customer);
Ok(())
}));
// Two handlers share the points table, so it lives behind Rc<RefCell<...>>.
let points = Rc::new(RefCell::new(HashMap::<String, i32>::new()));
let loyalty = |sign: i32| -> Handler {
let points = Rc::clone(&points);
Box::new(move |e: &Event| {
let p = sign * (e.amount / 100); // 1 point per 100 taka
let mut pts = points.borrow_mut();
let total = pts.entry(e.customer.clone()).or_insert(0);
*total += p;
println!(" {:<9} : {} {:+} points (total {})", "loyalty", e.customer, p, total);
Ok(())
})
};
bus.subscribe("order.placed", "loyalty", loyalty(1));
bus.subscribe("order.cancelled", "loyalty", loyalty(-1));
let analytics = bus.subscribe("order.placed", "analytics", Box::new(|e| {
println!(" {:<9} : recorded #{}", "analytics", e.order_id);
Ok(())
}));
let ev = |id, c: &str, a| Event { order_id: id, customer: c.into(), amount: a };
bus.publish("order.placed", &ev(501, "Rahim", 720));
bus.publish("order.placed", &ev(502, "Nadia", 1500));
bus.unsubscribe(analytics);
println!("analytics unsubscribed");
bus.publish("order.placed", &ev(503, "Rahim", 300));
bus.publish("order.cancelled", &ev(502, "Nadia", 1500));
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
public class Main {
record Event(int orderId, String customer, int amount) {} // amount in taka
record Subscription(int id, String name, Consumer<Event> fn) {}
// An in-process event bus: publishers and subscribers only know the bus and the topic.
static final class Bus {
private final Map<String, List<Subscription>> subs = new HashMap<>();
private int nextId = 0;
int subscribe(String topic, String name, Consumer<Event> fn) {
subs.computeIfAbsent(topic, t -> new ArrayList<>()).add(new Subscription(++nextId, name, fn));
return nextId;
}
void unsubscribe(int id) {
for (List<Subscription> list : subs.values()) list.removeIf(s -> s.id() == id);
}
// Calls every subscriber in order. One failing subscriber doesn't stop the others.
void publish(String topic, Event e) {
System.out.printf("publish %s #%d%n", topic, e.orderId());
for (Subscription s : subs.getOrDefault(topic, List.of())) {
try {
s.fn().accept(e);
} catch (RuntimeException err) {
System.out.printf(" %-9s : failed: %s%n", s.name(), err.getMessage());
}
}
}
}
public static void main(String[] args) {
Bus bus = new Bus();
// The order service only publishes. It doesn't know who listens.
bus.subscribe("order.placed", "sms", e -> {
if (e.orderId() == 503) throw new IllegalStateException("gateway down");
System.out.printf(" %-9s : sent \"order #%d confirmed\" to %s%n", "sms", e.orderId(), e.customer());
});
Map<String, Integer> points = new HashMap<>();
java.util.function.IntFunction<Consumer<Event>> loyalty = sign -> e -> {
int p = sign * (e.amount() / 100); // 1 point per 100 taka
int total = points.merge(e.customer(), p, Integer::sum);
System.out.printf(" %-9s : %s %+d points (total %d)%n", "loyalty", e.customer(), p, total);
};
bus.subscribe("order.placed", "loyalty", loyalty.apply(1));
bus.subscribe("order.cancelled", "loyalty", loyalty.apply(-1));
int analytics = bus.subscribe("order.placed", "analytics",
e -> System.out.printf(" %-9s : recorded #%d%n", "analytics", e.orderId()));
bus.publish("order.placed", new Event(501, "Rahim", 720));
bus.publish("order.placed", new Event(502, "Nadia", 1500));
bus.unsubscribe(analytics);
System.out.println("analytics unsubscribed");
bus.publish("order.placed", new Event(503, "Rahim", 300));
bus.publish("order.cancelled", new Event(502, "Nadia", 1500));
}
}#include <algorithm>
#include <cstdio>
#include <functional>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>
struct Event {
int orderId;
std::string customer;
int amount; // taka
};
using Handler = std::function<void(const Event&)>;
// An in-process event bus: publishers and subscribers only know the bus and the topic.
class Bus {
public:
int subscribe(const std::string& topic, const std::string& name, Handler fn) {
subs_[topic].push_back({++nextId_, name, std::move(fn)});
return nextId_;
}
void unsubscribe(int id) {
for (auto& [topic, list] : subs_) std::erase_if(list, [&](const Sub& s) { return s.id == id; });
}
// Calls every subscriber in order. One failing subscriber doesn't stop the others.
void publish(const std::string& topic, const Event& e) {
std::printf("publish %s #%d\n", topic.c_str(), e.orderId);
for (const auto& s : subs_[topic]) {
try {
s.fn(e);
} catch (const std::exception& err) {
std::printf(" %-9s : failed: %s\n", s.name.c_str(), err.what());
}
}
}
private:
struct Sub {
int id;
std::string name;
Handler fn;
};
std::map<std::string, std::vector<Sub>> subs_;
int nextId_ = 0;
};
int main() {
Bus bus;
// The order service only publishes. It doesn't know who listens.
bus.subscribe("order.placed", "sms", [](const Event& e) {
if (e.orderId == 503) throw std::runtime_error("gateway down");
std::printf(" %-9s : sent \"order #%d confirmed\" to %s\n", "sms", e.orderId, e.customer.c_str());
});
std::map<std::string, int> points;
auto loyalty = [&points](int sign) -> Handler {
return [&points, sign](const Event& e) {
int p = sign * (e.amount / 100); // 1 point per 100 taka
int total = (points[e.customer] += p);
std::printf(" %-9s : %s %+d points (total %d)\n", "loyalty", e.customer.c_str(), p, total);
};
};
bus.subscribe("order.placed", "loyalty", loyalty(1));
bus.subscribe("order.cancelled", "loyalty", loyalty(-1));
int analytics = bus.subscribe("order.placed", "analytics",
[](const Event& e) { std::printf(" %-9s : recorded #%d\n", "analytics", e.orderId); });
bus.publish("order.placed", {501, "Rahim", 720});
bus.publish("order.placed", {502, "Nadia", 1500});
bus.unsubscribe(analytics);
std::printf("analytics unsubscribed\n");
bus.publish("order.placed", {503, "Rahim", 300});
bus.publish("order.cancelled", {502, "Nadia", 1500});
}All six print exactly this:
publish order.placed #501
sms : sent "order #501 confirmed" to Rahim
loyalty : Rahim +7 points (total 7)
analytics : recorded #501
publish order.placed #502
sms : sent "order #502 confirmed" to Nadia
loyalty : Nadia +15 points (total 15)
analytics : recorded #502
analytics unsubscribed
publish order.placed #503
sms : failed: gateway down
loyalty : Rahim +3 points (total 10)
publish order.cancelled #502
loyalty : Nadia -15 points (total 0)
Reading the output:
- #501 and #502: each publish reaches all three subscribers, in the order they subscribed. The publisher never named any of them.
- Points: 1 point per 100 taka, so 720 taka is 7 points and 1500 is 15.
- analytics unsubscribed: from now on it isn't called; no other code changed.
- #503: SMS fails ("gateway down"), the bus logs it, and loyalty still runs: Rahim goes to 10 points.
- order.cancelled #502: only loyalty subscribed to cancellations; it takes Nadia's 15 points back.
- What each language changed: Go, TypeScript, Java and C++ store handlers as functions or lambdas. Rust needs
Box<dyn Fn>for handlers andRc<RefCell<…>>for the points table, because two handlers share and change it; the borrow checker makes that sharing explicit. Python catches any exception per subscriber; in Go the handler returns anerrorinstead of throwing.
Events in real systems
- In-process events are fire-and-forget. If the process crashes after publishing, the reactions are lost. For anything that must happen (invoice, refund), write the event to the database in the same transaction as the order (the outbox pattern) and deliver it from there.
- Decide sync or async. This chapter's bus calls subscribers synchronously, one by one. A slow subscriber slows the publisher. Real systems often hand events to a worker or a queue.
- Make subscribers idempotent. When events are retried, the same event may arrive twice; awarding points twice is a bug. Use the event or order ID to deduplicate.
- Register subscriptions in one place. A
wiring.goormainthat lists every subscription keeps the flow discoverable. - Name events as facts, past tense, with enough data to act on.
order.placed{id, customer, amount}is better thandoStuff{id}.
Designs that cause problems later
Pick the event design that will hurt
for s := range subs { if err := s(e); err != nil { return err } }Pause and think
The finance team says: "An invoice must be emailed for every order, no exceptions." Is an in-process subscriber enough?
Reveal
No. An in-process bus loses events if the process crashes between the order being saved and the subscriber running, and a failed email is just logged. For "no exceptions", save an invoice_pending record (or an outbox event) in the same database transaction as the order, and have a worker send invoices from that table and mark them done, retrying failures. The event idea stays the same; the delivery becomes durable. The HLD part covers queues and the outbox pattern in detail.
Quick quiz
1. What does Observer change about who depends on whom?
Answer
The subject no longer depends on concrete reactions. Observers depend on the subject (or the bus), and the subject only knows a small notification interface.
2. Why should each subscriber's errors be isolated?
Answer
So one broken reaction (a down SMS gateway) doesn't stop the others or the publisher. Each failure can be logged and retried on its own.
3. When is an event the wrong tool?
Answer
When the caller must wait for an answer to continue (validating an address, authorising a card). That's a direct call.
4. What's the difference between an event bus and a mediator?
Answer
A bus just routes events from publishers to subscribers by topic. A mediator can also hold coordination logic for a group of objects (a chat room deciding who receives a message, a dispatcher matching riders and drivers).
Your challenge: a first-order coupon
Add a coupon subscriber to order.placed that gives a coupon only on a customer's first order: it prints <customer> gets FIRST50 (in the same name : format as the others) the first time it sees a customer, and nothing after that.
- Register it after analytics, before the first publish.
- Don't change the bus or any existing subscriber.
Before you run it, predict where the new lines appear.
You've succeeded when the output gains exactly two lines, coupon : Rahim gets FIRST50 after #501's analytics line and coupon : Nadia gets FIRST50 after #502's analytics line, #503 prints no coupon line, and every other line is unchanged.
In one line
Publish facts, don't call every reaction: the publisher announces "this happened" to a topic, subscribers decide what to do, and neither knows the other. Isolate each subscriber's failures, keep subscriptions listed in one place, and use a durable queue when a reaction must never be lost.
Station stamp
Finish the chapter
Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.
Go Pro to earn XP →