LLD-016 · DESIGN PRACTICE
Pub/Sub Event Bus
Define subscriber snapshots, callback isolation and per-consumer backpressure before claiming delivery guarantees.
Local Java 21 reference verified: 17 checks. Learner drafts are not executed. Author self-review; independent review pending.
Existing introductory lesson · Version 1
Understand the problem
Design a typed in-process event bus with synchronous and buffered subscribers. Support subscribe/unsubscribe, isolated failures, bounded queues, explicit drain and close. Explain what publication acknowledges and how subscription changes during a callback affect the current event.
Before you start
- Observer and narrow callback contracts
- Type-safe topic identity
- Snapshot iteration and reentrancy
- Bounded producer/consumer queues
Scope and explicit decisions
- One in-process bus. No persistence, broker, replay log, automatic retry, distributed delivery, wildcard topics or exactly-once side-effect guarantee. Payloads must be immutable or caller-owned snapshots; generic T does not deep-copy mutable objects.
- A topic name is registered with one runtime Class. Reusing its name with another class rejects. Topic<T> gives compile-time help, while runtime registration/type checks protect the bus boundary.
- Synchronous recipients are snapshotted at publish. Subscription changes made inside a callback affect later publications. A synchronous subscriber removed after that snapshot still receives this event; a newly added one waits until a future snapshot.
- Buffered admission happens under the same registry monitor at publication. Each subscription owns a fixed-capacity FIFO queue; full queue drops that subscriber’s newest event and increments the per-publication dropped count. Other subscriptions admit independently.
- unsubscribe disables future selection and discards any still-buffered events. A callback already claimed by a drainer or included in a synchronous snapshot may finish; unsubscribe does not retract in-flight work. Subscription records are retained, so tokens remain inspectable and can be drained to empty.
- Callbacks run outside the registry monitor. Ordinary handler exceptions are counted and siblings continue. Interruptions restore the thread flag; buffered drain stops on interruption, leaving remaining work. JVM Errors are not caught as ordinary failures.
- Synchronous recursive publish is allowed up to 32 nested publish calls on one thread. The next recursive call rejects before admitting an event. This prevents unbounded direct recursion, not every possible cross-thread handler dependency deadlock.
- Buffered drain is explicitly invoked with an event-count budget and one drainer per subscription. No background thread or deadline exists. close stops new topics/subscriptions/publications, but already accepted buffered work can still drain.
- Publication sequence is allocated under the monitor. Buffered FIFO follows admission order; concurrent synchronous callback completion can differ from sequence order. Sequence is not a durable offset or acknowledgement of external business effects.
Worked examples
Input: Sync recipients A,B,failing. A unsubscribes B and registers C inside its callback.
Outcome: Current event reaches A and B; next reaches A and C. Failing callback is counted independently.
The snapshot boundary, not incidental list mutation timing, defines eligibility.
Input: Buffered capacities1 and2 receive a then b before drain.
Outcome: Small admits a and drops b; larger admits both. Close still permits draining accepted a,b.
Admission, delivery and lifecycle shutdown are separate events.
Your design attempt
Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.
Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.
Run program · Java 21
No unsaved changes · 208/20000
Self-review
Checks apply to this visit. Self-review is not a passed submission or verified mastery.
Try before revealing
Hint 1
Who belongs to a publication if subscriptions change during a callback?
Hint 2
How should one slow subscriber affect others?
Hint 3
What does unsubscribe actually own the power to stop?
Requirements and invariants
R1 — One topic name has one registered payload class. topic protects I1.
R2 — Current synchronous callbacks use a stable recipient snapshot outside the registry lock. publish protects I2.
R3 — Each buffered queue stays within its own capacity and has one drainer. drain protects I3.
R4 — A handler failure cannot silently skip later synchronous subscribers. publish protects I2.
I1: Registered topic name→Class identity cannot change while the bus is open.
I2: Publish selects synchronous recipients once, and invokes each outside registry ownership with isolated ordinary failures.
I3: Each buffered queue depth<=capacity, with at most one claimed callback in flight under one drainer.
Derive the design
A mutable list of callbacks needs a snapshot contract
A basic topic→list of handlers can deliver synchronously, but iterating a live mutable list breaks when a handler subscribes or unsubscribes during delivery. Copying a selected recipient set under the registry owner establishes one clear publication boundary. The reference then adds independent bounded queues rather than letting a slow handler require an unbounded global buffer.
Alternative: A mature broker adds durable offsets, acknowledgements and redelivery, but those are different contracts. Replacing this bus with a broker requires changing event identity, consumer acknowledgement and idempotent side-effect expectations; it is not merely swapping a transport class.
Define what publish acknowledges
The Report includes direct callback returns plus buffered admission/drop counts. It does not mean all side effects committed or remote consumers processed anything.
Choose the registry snapshot boundary
Selecting callbacks and admitting buffered records under one owner makes unsubscribe behavior explainable. Handler invocation occurs after releasing that owner.
Bound consumer backlog independently
A slow subscription loses or defers its own work under a declared policy. Other subscribers should not inherit its queue size.
Make lifecycle races visible
An unsubscribe can clear pending buffers but cannot stop a callback that already owns its event. Close is not a magical flush or durable acknowledgement.
Responsibilities and ownership
Bus
Owns: Topic registry, subscription admission, publication sequence and close state
Methods: topic, subscribe, publish, unsubscribe, drain, close
Local routing and queues only; not a distributed broker.
Topic<T>
Owns: Name and runtime payload class
Methods: Validated immutable record
Runtime registration must agree with generic intent.
Subscription
Owns: Handler, mode, capacity, active state and queue
Methods: Private registry/queue state
One slow consumer cannot expand another consumer’s buffer.
Report / Envelope
Owns: Publication counts and immutable routing metadata
Methods: Record accessors
Payload reference ownership and handler side effects remain explicit contracts.
Payload immutability belongs to callers; this bus is not a durable broker.
- Typed topic + immutable payload
↓ Validate registered class
Snapshot sync recipients / admit buffers↓ Per-subscription capacity
Callbacks outside monitor↓ No business exactly-once claim
Report direct outcomes and queued/dropped
Already claimed callbacks may complete after unsubscribe.
- Admitted per-subscription FIFO
↓ Bound pending count
Single drainer claims one event↓ Do not block registry I/O
Handler outside lock↓ Close retains; unsubscribe discards pending
Close or unsubscribe policy
Data model, schema and relationships
Topics define payload type identity, subscriptions define independent consumer eligibility/backpressure, and envelopes represent publication sequence. Delivery ownership is per subscription, not one global consumed flag.
Implementation boundary: The reference retains topic/subscription registries and per-subscriber in-memory queues. Durable envelope/delivery tables are a teaching extension; local payload references are not serialized or deep-copied.
These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Entity definitions and field contracts
topic
Registered routing/type boundary.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | text · PK | Topic name |
payload_type | text · VALUE | Registered runtime type |
Code mapping: Topic<T>.
- A name cannot be re-registered under a different type.
subscription
Consumer handler/configuration.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | bigint · PK | Subscription token |
topic_id | text · FK | Subscribed topic · topic.id |
mode | enum · VALUE | SYNC or BUFFERED |
capacity | integer · VALUE | Pending-event bound |
active | boolean · VALUE | Future-selection eligibility |
Code mapping: Subscription.
- One drainer per buffered subscription; handler instance stays process-local.
event_envelope
Accepted publication metadata.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | bigint · PK | Bus sequence |
topic_id | text · FK | Routing topic · topic.id |
payload | bytes · VALUE | Optional versioned serialization |
Code mapping: Envelope; bytes persistence is extension.
- Local reference holds a payload reference; persistence requires explicit immutable schema/serialization.
event_delivery
Per-consumer pending/delivery evidence.
| Field | Type / key | Meaning and reference |
|---|---|---|
event_id | bigint · PK,FK | Publication · event_envelope.id |
subscription_id | bigint · PK,FK | Consumer · subscription.id |
state | enum · VALUE | Queued claimed complete discarded |
Code mapping: Buffered queues; durable delivery state extension.
- PK(event_id,subscription_id); queue bounds and claim exclusivity remain transactional constraints.
Relationship cardinalities and their reasons
| Parent → child | Multiplicity | FK / why |
|---|---|---|
| topic → subscription has consumers | 1 parent per child; 0..* children per parent | subscription.topic_id → topic.idA subscriber has its own mode/capacity/lifecycle. |
| topic → event_envelope accepts publications | 1 parent per child; 0..* children per parent | event_envelope.topic_id → topic.idSequence identifies local admission order, not external completion order. |
| event_envelope → event_delivery fans out | 1 parent per child; 0..* children per parent | event_delivery.event_id → event_envelope.idA publication may be admitted independently to several queues. |
| subscription → event_delivery owns backlog | 1 parent per child; 0..* children per parent | event_delivery.subscription_id → subscription.idA full subscriber queue must not force every consumer to drop. |
Transaction boundaries
- publish: select synchronous snapshot and admit buffered recipients under registry ownership; invoke handlers outside it.
- unsubscribe clears unclaimed queue entries and disables future selection; an already claimed/snapshotted callback may finish.
Indexes and uniqueness
- Local map topic/name and subscription token lookup; per-subscription FIFO queue.
- Durable extension: unique event/subscription, index subscription/state/sequence, explicit retention and byte quotas.
Schema traps and failure cases
- Publication admission is not business-effect acknowledgement.
- A handler can commit then throw; automatic retry requires external idempotency rather than assuming no effect.
Trace the records through one operation
- Topic orders registers one payload type and subscriptions A/B.
- Publish E snapshots A and independently admits buffered B.
- Removing A during a callback affects future events; removing B discards only its unclaimed backlog.
Method contracts
topic(name,Class<T>); subscribe(topic,mode,capacity,handler)
Register a consistent payload class and return a local subscription token. Buffered mode requires positive capacity.
publish(topic,value) → Report
Validate registration/type/open state, snapshot sync recipients and perform buffered admissions atomically; invoke sync handlers after releasing the monitor. Report separates success/failure/queued/dropped.
unsubscribe(token)
Disable future selection and discard unclaimed buffered records. It cannot cancel a snapshotted or already claimed callback.
drain(token,maxEvents) → failedCount
Buffered subscriptions only. Claim one drainer, process at most budget, release the monitor for callbacks and preserve remaining queue on interruption.
close(); queued(token)
Close is admission shutdown, not a durable flush. Existing subscription records and queued accepted data remain accessible.
What, when, why and how
Each concept is attached to the requirement it protects, its implementation, an alternative and a test.
Observer with typed topic contracts
- what
- Publish to registered consumers without knowing their concrete implementations.
- when
- Producers and consumers evolve independently inside one process.
- why
- Direct dependencies on every consumer couple business producers to unrelated handler details.
- how
- Topic<T> binds name/class, subscribe wraps Handler<T> with runtime cast and publish validates the registered type.
Code: Topic, topic, subscribe, publish · Invariants: I1
Alternatives, cost and failure example
Alternative: Direct calls are simpler for one required dependency with a response contract; pub/sub is not automatically better for every interaction.
Cost: Type erasure and external serialization still need runtime/version contracts; a Java Class is not an event schema registry.
Counterexample: Register the same name as String and Integer and route values to the wrong handler.
Test: Reject mismatched registration and exercise typed delivery without unchecked raw casts.
Snapshot eligibility and callback isolation
- what
- Freeze who should receive the current synchronous event before callbacks can change the registry.
- when
- Handlers mutate subscriptions or publish recursively.
- why
- Live-list iteration can skip handlers, throw mutation exceptions or accidentally include new consumers mid-event.
- how
- publish selects sync callbacks under the monitor, then invokes outside it. Each ordinary exception is counted, and later siblings still run.
Code: publish, unsubscribe · Invariants: I2
Alternatives, cost and failure example
Alternative: A copy-on-write registry is another implementation of the same snapshot contract; it trades cheaper reads for more expensive writes.
Cost: Snapshot allocation is O(subscribers). Reentrant publish can create deep work and is explicitly bounded here.
Counterexample: A removes B during delivery and B unexpectedly misses an event it was selected to receive.
Test: A removes B/adds C; current event still reaches B, next event reaches C; a failing sibling does not halt fan-out.
Per-consumer backpressure
- what
- Each subscriber admits work under its own bounded policy.
- when
- One consumer is slower than others.
- why
- A shared unbounded queue can turn a slow optional consumer into process memory exhaustion.
- how
- BUFFERED subscribers have independent capacities and drop-new policy. Publish reports admission/drop per event; explicit drain invokes handlers outside queue ownership.
Code: Subscription, publish, drain · Invariants: I3
Alternatives, cost and failure example
Alternative: Reject, block, drop-oldest or durable spool are other policies with different producer-latency/loss semantics.
Cost: Count capacity is not a byte limit; payloads and subscriber count can still consume large memory.
Counterexample: Assume publish success means every buffered handler ran successfully.
Test: Publish twice into capacities one/two; assert small drops independently while larger preserves FIFO.
Lifecycle and in-flight ownership
- what
- Stopping future work is different from cancelling already selected work.
- when
- Unsubscribe or close races callback execution.
- why
- A boolean closed flag cannot retract a callback already running outside the lock.
- how
- unsubscribe disables future selection and clears pending buffers, but claimed callbacks/sync snapshots may complete. close only stops admission; drain still processes accepted queued work.
Code: unsubscribe, close, drain · Invariants: I2, I3
Alternatives, cost and failure example
Alternative: A stronger cancellation contract needs per-delivery tokens and cooperative handlers, and still cannot undo external effects already committed.
Cost: Retained subscription records grow; reclamation needs a lifecycle/handle policy beyond this reference.
Counterexample: Close drops accepted events silently while caller believed close meant flush.
Test: Close then drain accepted records; block one handler and prove new admission/registry operations are not held behind its I/O.
Read and run the Java reference
Reveal complete Solution.java
Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.
publish admits buffers before invoking callbacks
The registry lock establishes recipient/admission order. Synchronous callbacks then run on a stable list outside the monitor.
unsubscribe clears pending work only
It changes eligibility and discards queued records; already claimed work belongs to its current executor/caller.
drain owns one consumer loop
The drainer flag prevents concurrent consumption/reordering. Poll is brief; callback may block without holding registry ownership.
depth guard bounds same-thread recursion
A recursive publish at depth32 rejects before routing, and finally releases thread-local depth on every return path.
Complexity and limits
- publish scans S retained subscription records: O(S) routing plus selected synchronous handler costs. Buffered enqueue/drop is O(1) per selected subscriber.
- drain processes at most N events with O(N) queue work plus arbitrary handler latency; maxEvents bounds count, not wall-clock time.
- Space is retained registry plus per-buffer capacity×payload size, with one possible in-flight callback per drainer. Payloads are not copied or byte-bounded by the bus.
- Topic/subscription histories and tokens are not reclaimed automatically. A production lifecycle needs safe registry compaction and durable broker semantics if required.
Walk through the actual reference
These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.
Snapshot subscribers and isolated failures
Step 1 of 2: Snapshot A,B,failing handler
Current publish owns this sync recipient list
Invariant: Callbacks run outside registry monitor
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Snapshot A,B,failing handler | Current publish owns this sync recipient list | Callbacks run outside registry monitor |
| A removes B and adds new handler | B still sees current event; new handler waits for next | Snapshot boundary defines eligibility |
Independent subscriber backpressure
Step 1 of 3: Publish a into capacities1 and2
Both admit
Invariant: Each subscription owns its queue
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Publish a into capacities1 and2 | Both admit | Each subscription owns its queue |
| Publish b before draining | Small drops; larger admits | Slow consumer cannot grow another queue |
| Close then drain accepted larger queue | a,b delivered in admission order | Close stops admission, not durable delivery |
Mistakes and counterexamples
Iterate a live handler list
Failing case: A callback removes the next handler during iteration.
Repair: Snapshot eligibility under an owner.
Treat queued as delivered
Failing case: Unsubscribe discards admitted but unclaimed records.
Repair: Separate admission, discard and execution acknowledgements.
Call handlers while holding the registry lock
Failing case: One slow callback blocks all producers and subscription changes.
Repair: Select/poll under lock, invoke outside it.
Promise exactly-once business processing
Failing case: A handler commits an external effect then throws.
Repair: Define handler idempotency and durable acknowledgement separately.
Engineer → Senior → Staff
Implement typed synchronous fan-out.
Executed: consistent topic class, snapshot handlers, exception isolation and subscription changes.
Change exercise
Add a required consumer that returns a business result; decide whether pub/sub is still the right interface.
Worked answer guidance
A direct service call may be clearer if the producer requires a specific result. Avoid turning a required request/response dependency into fire-and-forget by accident.
Interview follow-ups
Why keep runtime type validation?
Generic intent can be bypassed at external boundaries or by mismatched topic construction; the registered Class provides a concrete check.
Does unsubscribe cancel the current sync callback?
No. The current publication already selected its snapshot. Future selection changes.
Provenance and scope of evidence
Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.
Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.