Learning pathsPricingChecking sign-in…
← LLD problem library

LLD-016 · LOW LEVEL DESIGN

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.

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.

Publication boundary

Payload immutability belongs to callers; this bus is not a durable broker.

  1. Typed topic + immutable payload
  2. ↓ Validate registered class

    Snapshot sync recipients / admit buffers
  3. ↓ Per-subscription capacity

    Callbacks outside monitor
  4. ↓ No business exactly-once claim

    Report direct outcomes and queued/dropped
Buffered lifecycle

Already claimed callbacks may complete after unsubscribe.

  1. Admitted per-subscription FIFO
  2. ↓ Bound pending count

    Single drainer claims one event
  3. ↓ Do not block registry I/O

    Handler outside lock
  4. ↓ 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.

topic: relationship map

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.

topic relationshipstopic 1 to subscription 0..*: has consumers. subscription.topic_id → topic.id. topic 1 to event_envelope 0..*: accepts publications. event_envelope.topic_id → topic.id.has consumers1 parent · 0..* childrensubscriptionid [PK]topic_id [FK]modeaccepts publications1 parent · 0..* childrenevent_envelopeid [PK]topic_id [FK]payloadtopicid [PK]payload_type
subscription: relationship map

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.

subscription relationshipssubscription 1 to event_delivery 0..*: owns backlog. event_delivery.subscription_id → subscription.id.owns backlog1 parent · 0..* childrenevent_deliveryevent_id + subscriptio [PK]statesubscriptionid [PK]topic_id [FK]mode
event_envelope: relationship map

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.

event_envelope relationshipsevent_envelope 1 to event_delivery 0..*: fans out. event_delivery.event_id → event_envelope.id.fans out1 parent · 0..* childrenevent_deliveryevent_id + subscriptio [PK]stateevent_envelopeid [PK]topic_id [FK]payload

Entity definitions and field contracts

topic

Registered routing/type boundary.

FieldType / keyMeaning and reference
idtext · PKTopic name
payload_typetext · VALUERegistered runtime type

Code mapping: Topic<T>.

  • A name cannot be re-registered under a different type.

subscription

Consumer handler/configuration.

FieldType / keyMeaning and reference
idbigint · PKSubscription token
topic_idtext · FKSubscribed topic · topic.id
modeenum · VALUESYNC or BUFFERED
capacityinteger · VALUEPending-event bound
activeboolean · VALUEFuture-selection eligibility

Code mapping: Subscription.

  • One drainer per buffered subscription; handler instance stays process-local.

event_envelope

Accepted publication metadata.

FieldType / keyMeaning and reference
idbigint · PKBus sequence
topic_idtext · FKRouting topic · topic.id
payloadbytes · VALUEOptional 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.

FieldType / keyMeaning and reference
event_idbigint · PK,FKPublication · event_envelope.id
subscription_idbigint · PK,FKConsumer · subscription.id
stateenum · VALUEQueued 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 → childMultiplicityFK / why
topic → subscription

has consumers

1 parent per child; 0..* children per parentsubscription.topic_id → topic.id

A subscriber has its own mode/capacity/lifecycle.

topic → event_envelope

accepts publications

1 parent per child; 0..* children per parentevent_envelope.topic_id → topic.id

Sequence identifies local admission order, not external completion order.

event_envelope → event_delivery

fans out

1 parent per child; 0..* children per parentevent_delivery.event_id → event_envelope.id

A publication may be admitted independently to several queues.

subscription → event_delivery

owns backlog

1 parent per child; 0..* children per parentevent_delivery.subscription_id → subscription.id

A full subscriber queue must not force every consumer to drop.

Transaction boundaries

  1. publish: select synchronous snapshot and admit buffered recipients under registry ownership; invoke handlers outside it.
  2. unsubscribe clears unclaimed queue entries and disables future selection; an already claimed/snapshotted callback may finish.

Indexes and uniqueness

  1. Local map topic/name and subscription token lookup; per-subscription FIFO queue.
  2. Durable extension: unique event/subscription, index subscription/state/sequence, explicit retention and byte quotas.

Schema traps and failure cases

  1. Publication admission is not business-effect acknowledgement.
  2. A handler can commit then throw; automatic retry requires external idempotency rather than assuming no effect.

Trace the records through one operation

  1. Topic orders registers one payload type and subscriptions A/B.
  2. Publish E snapshots A and independently admits buffered B.
  3. 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.

Solution.java Java 21
import java.util.*;
/** In-process typed bus. Payloads must be immutable or externally owned snapshots. */
public final class Solution {
    private Solution() {}
    public enum Mode { SYNC, BUFFERED }
    public record Topic<T>(String name,Class<T> type){public Topic{if(name==null||name.isBlank())throw new IllegalArgumentException("topic");Objects.requireNonNull(type);}}
    @FunctionalInterface public interface Handler<T>{void accept(T value)throws Exception;}
    private record Envelope(long sequence,Object value){}
    public record Report(long sequence,int succeeded,int failed,int queued,int dropped){}
    private static final class Subscription {
        final long id;final String topic;final Mode mode;final int capacity;final Handler<Object> handler;
        final ArrayDeque<Envelope> queue=new ArrayDeque<>();boolean active=true,draining;
        Subscription(long id,String topic,Mode mode,int capacity,Handler<Object> handler){this.id=id;this.topic=topic;this.mode=mode;this.capacity=capacity;this.handler=handler;}
    }
    public static final class Bus {
        private final Map<String,Class<?>> topics=new HashMap<>();
        private final Map<Long,Subscription> subscriptions=new LinkedHashMap<>();
        private final ThreadLocal<Integer> depth=ThreadLocal.withInitial(()->0);
        private long sequence,ids;private boolean closed;
        public synchronized <T> Topic<T> topic(String name,Class<T> type){if(closed)throw new IllegalStateException("closed");var result=new Topic<>(name,type);var prior=topics.get(name);if(prior!=null&&prior!=type)throw new IllegalArgumentException("topic type mismatch");topics.put(name,type);return result;}
        private void registered(Topic<?> topic){if(topics.get(topic.name())!=topic.type())throw new IllegalArgumentException("unregistered or mismatched topic");}
        public synchronized <T> long subscribe(Topic<T> topic,Mode mode,int capacity,Handler<T> handler){
            registered(topic);Objects.requireNonNull(mode);Objects.requireNonNull(handler);if(closed)throw new IllegalStateException("closed");if(mode==Mode.BUFFERED&&capacity<1)throw new IllegalArgumentException("capacity");
            long id=++ids;subscriptions.put(id,new Subscription(id,topic.name(),mode,capacity,value->handler.accept(topic.type().cast(value))));return id;
        }
        public synchronized void unsubscribe(long id){var s=subscriptions.get(id);if(s==null)throw new IllegalArgumentException("subscription");s.active=false;s.queue.clear();}
        public <T> Report publish(Topic<T> topic,T value){
            if(depth.get()>=32)throw new IllegalStateException("reentrant publish limit");depth.set(depth.get()+1);
            try{
                final List<Subscription> synchronous=new ArrayList<>();final long eventSequence;int queued=0,dropped=0;
                synchronized(this){registered(topic);Objects.requireNonNull(value);if(!topic.type().isInstance(value))throw new IllegalArgumentException("payload type");if(closed)throw new IllegalStateException("closed");eventSequence=++sequence;
                    for(var s:subscriptions.values())if(s.active&&s.topic.equals(topic.name())){
                        if(s.mode==Mode.SYNC)synchronous.add(s);else if(s.queue.size()==s.capacity)dropped++;else{s.queue.addLast(new Envelope(eventSequence,value));queued++;}
                    }
                }
                int ok=0,failed=0;for(var s:synchronous)try{s.handler.accept(value);ok++;}catch(Exception ex){if(ex instanceof InterruptedException)Thread.currentThread().interrupt();failed++;}
                return new Report(eventSequence,ok,failed,queued,dropped);
            }finally{int remaining=depth.get()-1;if(remaining==0)depth.remove();else depth.set(remaining);}
        }
        public int drain(long id,int maxEvents){
            if(maxEvents<1)throw new IllegalArgumentException("budget");final Subscription s;
            synchronized(this){s=subscriptions.get(id);if(s==null||s.mode!=Mode.BUFFERED)throw new IllegalArgumentException("buffered subscription");if(s.draining)throw new IllegalStateException("already draining");s.draining=true;}
            int failed=0;
            try{for(int i=0;i<maxEvents;i++){final Envelope e;synchronized(this){e=s.queue.pollFirst();}if(e==null)break;try{s.handler.accept(e.value());}catch(Exception ex){failed++;if(ex instanceof InterruptedException){Thread.currentThread().interrupt();break;}}}return failed;}
            finally{synchronized(this){s.draining=false;}}
        }
        public synchronized int queued(long id){var s=subscriptions.get(id);if(s==null)throw new IllegalArgumentException("subscription");return s.queue.size();}
        public synchronized void close(){closed=true;}
    }
}

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
ActionState after actionInvariant
Snapshot A,B,failing handlerCurrent publish owns this sync recipient listCallbacks run outside registry monitor
A removes B and adds new handlerB still sees current event; new handler waits for nextSnapshot 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
ActionState after actionInvariant
Publish a into capacities1 and2Both admitEach subscription owns its queue
Publish b before drainingSmall drops; larger admitsSlow consumer cannot grow another queue
Close then drain accepted larger queuea,b delivered in admission orderClose 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

Choose a deep dive

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.

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.

Solution.java
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.

Open focused practice →

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.