Machine coding · not HLD
Pub/sub
PaymentService just marked a charge complete. A receipt needs printing, fraud wants a look, and metrics wants a counter. If complete() calls all three by name, the next email template edits the payment class. That is the wrong seam.
In-process pub/sub is the pattern as a tiny : publishers emit , and a reacts without being named. PaymentService publishes PaymentSucceeded; receipt, fraud, and metrics subscribe. That keeps PaymentService about payments. It is not Kafka. Brokers, offsets, consumer groups, and at-least-once delivery belong on the related System Design note: Pub/sub .
Who talks to whom
Event
An immutable fact, for example PaymentSucceeded with a payment id.
Bus
Owns subscribe, unsubscribe, and publish for typed events on the bus.
Handles on(event). Must not need to know about other subscribers.
Publisher
Depends on EventBus only, never on Emailer or Metrics by name.
Who must change when a new subscriber appears?
The payment story above is the default interview prompt: a new listener appears, and the publisher must not change. A topic is a name. A implements handle(Event) or on(event). The keeps lists and delivers. Publishers depend on the bus, not on Emailer. See Observer.
Start synchronous: publish runs each handler on the same thread. Mention async as an extension. If you add a queue and worker threads, you have producer-consumer and must talk about back-pressure (what happens when the queue cannot accept more work). Do not build partitions. Those sentences belong on the related System Design note (see the hero link).
Unsubscribe matters. Forgotten listeners leak in tests and in long-running processes. matters too: a handler that publishes again on the same bus can stack-overflow, so document whether handlers may publish. Topics can be a (class PaymentSucceeded) or strings; typed events are easier to test.
A logging subscriber is a good demo. A subscriber that calls back into the publisher is a bug you should name out loud. Related: notification channels (factory + strategy) can subscribe here without PaymentService importing SMTP.
How to explain this in an interview
Once the payment story is on the table, three questions decide how much bus you draw.
1. Can this stay a synchronous for-loop? In machine coding, yes. An that maps a to a listener list is the product. Start there.
2. Do they ask about threads? Then the bus offers to a BlockingQueue (a queue that can block when full) in front of handlers. That is producer-consumer, still not a broker. Name the full policy: block, drop, or reject. Mention back-pressure.
3. Did someone say Kafka? Offsets, replay, and consumer groups belong on the related System Design note (see the hero link). Keep the map of Class to list.
Simple rule of thumb
A new must not edit PaymentService. Synchronous for-loop first. Async is a bounded queue plus a full policy. Kafka-scale offsets are not this class.
From problem to mechanism
Here is the payment scenario slowed down so you can see which object owns each verb, then the fan-out and the Java sketch.
Problem. Receipt, fraud, and metrics all need PaymentSucceeded. Naming each listener on PaymentService means the next email template touches payments.
Naive system. PaymentService.emailer, PaymentService.fraud, PaymentService.metrics. That is the coupling removes.
Mechanism. PaymentService publishes. The owns subscribe, unsubscribe, and publish for typed events, and copies the list before iterating if subscribers mutate. A implements on(event) and knows nothing about peers. Event types are immutable facts: PaymentSucceeded is a value with an id.
EventBus
Owns subscribe, unsubscribe, and publish. Copy the list if subscribers mutate.
Subscriber<T>
on(event) only. No knowledge of other subscribers on the same type.
PaymentService
Publishes PaymentSucceeded. Does not name printers or mailers.
Event types
Immutable facts. PaymentSucceeded is a value with an id.
Publish fans out
The mechanism above is one publish and three handlers. PaymentService never names Emailer.
Read the diagram definition
sequenceDiagram
participant P as PaymentService
participant B as EventBus
participant E as ReceiptPrinter
participant F as FraudChecker
participant M as Metrics
P->>B: publish(PaymentSucceeded)
B->>E: on(event)
B->>F: on(event)
B->>M: on(event)
Note over P: PaymentService never names EmailerJava API sketch
The same four roles as Java. Notice List.copyOf before the loop: that is the copy the mechanism required.
interface Subscriber<T> {
void on(T event);
}
class EventBus {
private final Map<Class<?>, List<Subscriber<?>>> byType = new HashMap<>();
<T> void subscribe(Class<T> type, Subscriber<T> sub) {
byType.computeIfAbsent(type, k -> new ArrayList<>()).add(sub);
}
<T> void unsubscribe(Class<T> type, Subscriber<T> sub) {
List<Subscriber<?>> list = byType.get(type);
if (list != null) list.remove(sub);
}
void publish(Object event) {
List<Subscriber<?>> subs = byType.getOrDefault(event.getClass(), List.of());
for (Subscriber<?> sub : List.copyOf(subs)) {
@SuppressWarnings("unchecked")
Subscriber<Object> s = (Subscriber<Object>) sub;
s.on(event);
}
}
}
class PaymentService {
private final EventBus bus;
void complete(Payment payment) {
bus.publish(new PaymentSucceeded(payment.id()));
}
}PaymentSucceeded, three listeners
The sketch above is the API. This is the playbook you write in the round, plus the dangerous test that proves you thought about .
Write PaymentService.complete that only publishes. Subscribe a RecordingSubscriber in the test. Assert it saw the id. Add a second . Assert both saw the event, in registration order (say the order). Unsubscribe the first, publish again, assert only the second fired. That is . Kafka never entered the room.
Then the dangerous test: a subscriber that publishes PaymentSucceeded again. Either you document "handlers must not re-publish this type," or you detect re-entrancy with a ThreadLocal depth and throw. Silent stack-overflow is not a design. CopyOnWrite lists (they snapshot on write) or List.copyOf before iterate so subscribe during publish does not throw ConcurrentModificationException (Java's error when a list mutates while you iterate it).
Sync default
publish is a for-loop. Exceptions: catch-and-continue vs fail-fast. Say which. Tests need a handler that throws.
Async extension
Offer to BlockingQueue. Bounded queue plus full policy (block, drop, reject). Still not a broker.
related System Design note
Offsets, replay, consumer groups. Point at the pub/sub note. Keep the Class-to-list map.
Tests that prove the seams
@Test
void unsubscribeStopsDelivery() {
EventBus bus = new EventBus();
RecordingSubscriber rec = new RecordingSubscriber();
bus.subscribe(PaymentSucceeded.class, rec);
bus.unsubscribe(PaymentSucceeded.class, rec);
bus.publish(new PaymentSucceeded("p1"));
assertTrue(rec.seen().isEmpty());
}Invalid cases, concurrency, and follow-ups
The playbook works until someone names listeners, skips unsubscribe, or designs Kafka in forty-five minutes. These are the misses, then the lock you start with, then what a follow-up actually changes.
Invalid cases
- Publisher names every listener: the next email template then edits PaymentService.
- Subscribe without unsubscribe: tests and long-running processes leak handlers.
- Handler republishes the same type: stack overflow unless you document a ban.
- Subscribe during publish without a list copy: ConcurrentModificationException or missed delivery.
- Designing Kafka in the LLD round: a type-to-list map is the product.
Concurrency
Start with a lock around the map, or CopyOnWrite lists. Async delivery is a queue in front of handlers: producer-consumer. Exceptions in one handler should not silently skip the rest unless you say so (catch-and-continue vs fail-fast; pick one).
What the follow-up changes
- Async workers: the bus offers to a BlockingQueue; mention back-pressure.
- Wildcard or string topics need an extra map; typed classes are enough first.
- Scale questions: open the System Design note; offsets are not this class.
Still fails when…
- A subscriber calls back into the publisher; name that bug out loud.
- You skip the list copy and a subscribe-during-publish test starts throwing.
- Handler exceptions vanish because nobody said catch-and-continue or fail-fast.
Trade-offs
- Typed events beat string topics in tests, but wildcards need another map.
- A lock or CopyOnWrite is enough here; a worker queue is a later extension.
- Catch-and-continue isolates one bad handler; fail-fast makes the failure obvious.
Wrap-up
You have the story, the bus, and the tests. Use the forty-five minute split as a clock, then follow the related notes.
Forty-five minutes
- 0–8Write EventBus subscribe and publish with one RecordingSubscriber that records the id.
- 8–20Add a second subscriber and name the registration-order delivery contract.
- 20–32Implement unsubscribe and copy the list before iterating on publish.
- 32–40Add a throwing handler and say catch-and-continue or fail-fast.
- 40–45Mention an async queue plus back-pressure as the extension sentence.
Read next
You're done when
A new listener can subscribe without editing PaymentService. You have subscribe, publish that copies the listener list before iterating, and unsubscribe. Re-entrant publish of the same event type is a named bug with a documented policy. Kafka-scale offsets and consumer groups belong on the related System Design note, not as a dependency here.