Design Pub Sub System
A small-town post office runs on two pieces of furniture and one rule. The first piece is a wall of mail slots, one slot per family, each one labeled with the family name. The second piece is a stack of magazine subscriptions on the postmaster's desk, sorted by the magazine name — Sports Illustrated, National Geographic, the local paper — with a list of which families want each one. When a fresh bundle of Sports Illustrated lands on the dock, the postmaster reads the subscription list under "sports," walks to each subscriber's slot, and drops a copy in. Families never wait at the dock. Magazines never get handed off person-to-person. The dock pushes into slots and the slots fill up.

The shape goes back to IBM in the late 1980s, when message-oriented middleware first showed up to solve a real problem in big banks. A trading floor had one program that computed prices and 30 programs that wanted to read them — risk, accounting, settlement, every desk. Wiring 30 direct connections meant the pricing program had to know every reader and a new reader meant a code change to the publisher. IBM's MQSeries put a broker in the middle. The pricing program shouted into a named channel called a topic, and any program that had subscribed to that topic got the message. Adding a reader meant subscribing — the publisher never learned the new name. Sun and TIBCO shipped their own versions in the 1990s, Apache Kafka rebuilt the idea for the web era in 2011 at LinkedIn, and the basic shape has not changed. There is a broker. There are topics. Subscribers join topics. Publishers fire at topics.
The whole design fits in two enums and a struct. The topic is just a label. The subscriber is just a name. The broker holds the wall of slots and the subscription list.
#[derive(Debug, Clone)]
enum Event {
Subscribe { sub: &'static str, topic: &'static str },
Unsubscribe { sub: &'static str, topic: &'static str },
Publish { topic: &'static str, message: &'static str },
}
struct Broker {
topics: HashMap<&'static str, Vec<String>>,
inboxes: HashMap<String, Vec<(&'static str, &'static str)>>,
}Event is the list of things a user of the broker can ask for. Subscribe joins a name to a topic. Unsubscribe removes a name from a topic. Publish fires a message at a topic. Three knobs, no fourth. A version that lumped subscribe and unsubscribe into a single SetSubscription(bool) event would technically work and would also make every call site harder to read. Two named events name the intent at the call site, and the compiler's exhaustive match check makes sure no future event silently slips through.
Broker holds two maps. The topics map is the postmaster's subscription list — for each topic name, who wants it. The inboxes map is the wall of slots — for each subscriber name, the messages waiting for them. Holding both maps is the whole point. The first map answers "who do I deliver to" when a publish comes in. The second map is where the delivery lands. Without the inbox map, a publish would have to hand messages directly to subscriber objects, and now the broker has to know what a subscriber actually is. With the inbox map, a subscriber is just a string and a list — no callbacks, no threads, no shared references. The driver can dump the wall and see every message that ever landed.

The methods are the table that turns events into changes.
impl Broker {
fn new() -> Self {
Self {
topics: HashMap::new(),
inboxes: HashMap::new(),
}
}
fn subscribe(&mut self, sub: &str, topic: &'static str) {
let list = self.topics.entry(topic).or_default();
if !list.iter().any(|s| s == sub) {
list.push(sub.to_string());
}
self.inboxes.entry(sub.to_string()).or_default();
}
fn unsubscribe(&mut self, sub: &str, topic: &'static str) {
if let Some(list) = self.topics.get_mut(topic) {
list.retain(|s| s != sub);
}
}
fn publish(&mut self, topic: &'static str, message: &'static str) -> usize {
let subs: Vec<String> = self
.topics
.get(topic)
.cloned()
.unwrap_or_default();
for sub in &subs {
let inbox = self.inboxes.entry(sub.clone()).or_default();
inbox.push((topic, message));
}
subs.len()
}
}Read subscribe first. It looks up the topic, makes sure the subscriber is not already on the list, and adds the name if not. It also touches the inboxes map with entry(sub).or_default() so the subscriber gets an empty inbox the moment they join. Without that line, a brand-new subscriber would have no inbox until the first publish, and the broker would have to special-case a missing key on every read. Creating the inbox at subscribe time means every other piece of code can assume the inbox exists.
unsubscribe walks the list for the topic and removes the matching name. retain is the right tool because it does the keep-or-drop check in one pass without allocating a new vector. A version that built a fresh filtered vector would do the same thing in twice the memory.
publish is where the fanout happens. It pulls the subscriber list for the topic, clones it into a local vector, then walks each name and appends the message to that subscriber's inbox. The clone matters — without it the loop would hold a borrow into self.topics while also trying to mutate self.inboxes, and Rust's borrow checker refuses to let one method hold two mutable handles into the same struct at once. Cloning a Vec<String> of names is cheap, and it lets the rest of the method touch inboxes freely. The method returns the count so the caller can log it.
One detail that looks like an afterthought but is not — when the topic has no subscribers, the publish still succeeds and returns zero. The post office does not refuse a magazine just because nobody on this block subscribes. It logs the bundle, walks an empty list, and goes home. A broker that errored on "no subscribers" would push every publisher to check the subscription count first, which is the same broken pattern as a database client that has to check whether a table exists before every insert.
fn show(broker: &Broker) {
let mut topics: Vec<&&'static str> = broker.topics.keys().collect();
topics.sort();
println!(" subscriptions:");
for topic in topics {
let mut subs = broker.topics[topic].clone();
subs.sort();
println!(" {topic}: [{}]", subs.join(", "));
}
let mut subs: Vec<&String> = broker.inboxes.keys().collect();
subs.sort();
println!(" inboxes:");
for sub in subs {
let msgs = &broker.inboxes[sub];
let rendered: Vec<String> = msgs
.iter()
.map(|(t, m)| format!("{t}:{m}"))
.collect();
println!(" {sub}: [{}]", rendered.join(", "));
}
}The render helper is the part that makes the lesson visible. It walks both maps, sorts the keys, and prints subscriptions then inboxes. Sorting matters because Rust's HashMap uses a randomized hash to defend against denial-of-service attacks, which means the iteration order is different on every run. A printed dump that flips order between runs is useless as a teaching tool and would also break the snapshot test on the very next build. Sorting the keys into a Vec first gives a stable view that reads the same on every machine.
The driver fires nine hardcoded events through a fresh broker and shows the wall after every one.
fn main() {
let events = [
Event::Subscribe { sub: "alice", topic: "weather" },
Event::Subscribe { sub: "bob", topic: "weather" },
Event::Subscribe { sub: "bob", topic: "sports" },
Event::Publish { topic: "weather", message: "rain at 4pm" },
Event::Subscribe { sub: "carol", topic: "sports" },
Event::Publish { topic: "sports", message: "lakers won" },
Event::Unsubscribe { sub: "bob", topic: "weather" },
Event::Publish { topic: "weather", message: "clear tomorrow" },
Event::Publish { topic: "news", message: "no subscribers here" },
];
let mut broker = Broker::new();
for (i, event) in events.iter().enumerate() {
match event {
Event::Subscribe { sub, topic } => {
println!("event {}: subscribe {sub} -> {topic}", i + 1);
broker.subscribe(sub, topic);
}
Event::Unsubscribe { sub, topic } => {
println!("event {}: unsubscribe {sub} -> {topic}", i + 1);
broker.unsubscribe(sub, topic);
}
Event::Publish { topic, message } => {
let delivered = broker.publish(topic, message);
println!(
"event {}: publish {topic} \"{message}\" (delivered to {delivered})",
i + 1
);
}
}
show(&broker);
println!();
}
}event 1: subscribe alice -> weather
subscriptions:
weather: [alice]
inboxes:
alice: []
event 2: subscribe bob -> weather
subscriptions:
weather: [alice, bob]
inboxes:
alice: []
bob: []
event 3: subscribe bob -> sports
subscriptions:
sports: [bob]
weather: [alice, bob]
inboxes:
alice: []
bob: []
event 4: publish weather "rain at 4pm" (delivered to 2)
subscriptions:
sports: [bob]
weather: [alice, bob]
inboxes:
alice: [weather:rain at 4pm]
bob: [weather:rain at 4pm]
event 5: subscribe carol -> sports
subscriptions:
sports: [bob, carol]
weather: [alice, bob]
inboxes:
alice: [weather:rain at 4pm]
bob: [weather:rain at 4pm]
carol: []
event 6: publish sports "lakers won" (delivered to 2)
subscriptions:
sports: [bob, carol]
weather: [alice, bob]
inboxes:
alice: [weather:rain at 4pm]
bob: [weather:rain at 4pm, sports:lakers won]
carol: [sports:lakers won]
event 7: unsubscribe bob -> weather
subscriptions:
sports: [bob, carol]
weather: [alice]
inboxes:
alice: [weather:rain at 4pm]
bob: [weather:rain at 4pm, sports:lakers won]
carol: [sports:lakers won]
event 8: publish weather "clear tomorrow" (delivered to 1)
subscriptions:
sports: [bob, carol]
weather: [alice]
inboxes:
alice: [weather:rain at 4pm, weather:clear tomorrow]
bob: [weather:rain at 4pm, sports:lakers won]
carol: [sports:lakers won]
event 9: publish news "no subscribers here" (delivered to 0)
subscriptions:
sports: [bob, carol]
weather: [alice]
inboxes:
alice: [weather:rain at 4pm, weather:clear tomorrow]
bob: [weather:rain at 4pm, sports:lakers won]
carol: [sports:lakers won]Read the output as a flipbook. Event 1 puts Alice on the weather list and gives her an empty slot. Event 2 adds Bob to weather. Event 3 adds Bob to sports — the sports topic appears for the first time. Event 4 publishes to weather and the postmaster delivers two copies because Alice and Bob are both on the list. Look at the inboxes line — both Alice's and Bob's slots now hold weather:rain at 4pm. Event 5 adds Carol to sports. Event 6 publishes to sports and Bob and Carol both get a copy. Bob's slot now holds two messages because he was already holding the weather one. Event 7 unsubscribes Bob from weather but leaves his sports subscription and his entire inbox alone — old mail does not disappear when you cancel the subscription. Event 8 publishes to weather and only Alice gets it, because Bob is no longer on that list. Event 9 publishes to a topic nobody subscribed to and delivers zero, and every inbox stays exactly where it was.
One question worth asking — why does Bob still have weather:rain at 4pm in his inbox after event 7 unsubscribes him from weather? The unsubscribe removed his name from the topic's subscriber list, which controls future fanout. It did not walk back through his inbox to delete old messages. That is the right call. Mail that was delivered before you canceled the subscription is yours to keep, and a broker that retroactively scrubbed inboxes would be lying to the subscriber about what it had already promised.
The thing this design cannot do is push messages to subscribers in real time. Every subscriber pulls from their inbox whenever they want, and the broker has no way to tell a subscriber that new mail just arrived. The next bottleneck is wiring up channels or callbacks so the broker can hand a message to a live consumer the moment it lands instead of leaving it in a slot for later.