Skip to content

Pub/Sub Library (LLD)#

Problem statement (interviewer prompt)

Design an in-process publish/subscribe library: publishers post messages to topics; subscribers receive them, possibly with filters. Cover thread safety, async vs sync dispatch, error isolation, and bounded backlog per subscriber.

classDiagram
  class Broker {
    -topics : Map~String, Topic~
    +publish(topicName, message)
    +subscribe(topicName, subscriber, filter)
    +unsubscribe(topicName, subscriberId)
  }
  class Topic {
    -name : String
    -subscribers : List~Subscription~
    -dispatcher : Executor
    +deliver(message)
  }
  class Subscription {
    +id : String
    +subscriber : Subscriber
    +filter : Predicate~Message~
    +queue : BlockingQueue~Message~
  }
  class Subscriber {
    <<interface>>
    +onMessage(m)
    +onError(e)
  }
  class Message {
    +id, topic, payload, headers, timestamp
  }
  Broker *-- "many" Topic
  Topic *-- "many" Subscription
  Subscription --> Subscriber

Async dispatch with bounded per-subscription queue gives backpressure. Filter predicate runs in the dispatcher thread to avoid waking idle subscribers.

A core LLD interview shape: many subscribers, many publishers, asynchronous coordination, failure isolation.

Class diagram#

classDiagram
  class Broker {
    -topics : ConcurrentMap~String, Topic~
    -executor : Executor
    +publish(name, msg)
    +subscribe(name, sub, filter, qSize) Subscription
    +unsubscribe(s : Subscription)
  }
  class Topic {
    -name
    -subs : List~Subscription~
    +deliver(msg)
  }
  class Subscription {
    +id
    +subscriber : Subscriber
    +filter : Predicate
    -queue : BlockingQueue
    -worker : Thread
    +deliver(msg)
    +close()
  }
  class Subscriber {
    <<interface>>
    +onMessage(msg)
    +onError(e)
  }
  class Message {
    +id, topic, payload, headers, ts
  }
  Broker *-- Topic
  Topic *-- Subscription
  Subscription --> Subscriber
  Topic ..> Message

Publish path#

class Topic {
    void deliver(Message m) {
        for (Subscription s : subs) {           // CopyOnWriteArrayList or read-lock
            if (s.filter.test(m)) {
                boolean accepted = s.queue.offer(m);  // bounded; non-blocking
                if (!accepted) {
                    metrics.dropped(s);          // or call s.onOverflow()
                }
            }
        }
    }
}

Per-subscription worker#

class Subscription implements Runnable {
    void run() {
        while (!closed) {
            try {
                Message m = queue.take();
                subscriber.onMessage(m);
            } catch (Throwable t) {
                subscriber.onError(t);           // never let exception kill worker
            }
        }
    }
}

Each subscriber gets its own thread (or pulls from a shared executor). One slow subscriber doesn't block others.

Backpressure#

Option Behaviour
Drop newest Bounded queue with offer()
Drop oldest evict + offer
Block publisher Use put() instead of offer() (couples publisher to slowest sub)
Slow-consumer disconnect If queue full > N seconds, evict subscription

Filter pushdown#

broker.subscribe("orders", s, m -> m.headers.get("region").equals("US"));

Run filter in dispatcher to avoid handing the message to a worker that will discard it.

Sync vs async#

Mode Use
Async Default; no caller blocked on subscriber speed
Sync (publishSync) Test fixtures; ordering guarantees within a single thread

Replay / late subscribers#

For "give me last N messages":

  • Topic keeps a ring buffer of recent messages.
  • New subscription with replayLast=N drains the buffer first, then live.

Beyond a small buffer, use a real log (Kafka).

Error isolation#

  • Subscriber exception → onError callback; subscription survives.
  • Worker thread uncaughtExceptionHandler to log.
  • Repeated errors trigger breaker.open(); pause delivery.

Memory and lifecycle#

  • subscribe returns a Subscription token; caller MUST hold it or unsubscribe explicitly to avoid leaks.
  • close() drains queue, joins worker.

Production analogs#

Library Scope
Guava EventBus In-process, simple
RxJava / Reactor In-process with backpressure
Akka actors Per-actor mailbox
Spring ApplicationEvent In-process, app-framework

For distributed pub/sub see the pub-sub-pattern.

Where pub/sub LLD fits#

flowchart TB
  PS((Pub/Sub<br/>LLD))
  PSH["Pub/Sub pattern (HLD)<br/>distributed analog"]
  BPL[Behavioral patterns<br/>Observer]
  CON[Concurrency primitives<br/>queue + executor]
  BP[Backpressure<br/>bounded queues]
  PS -. scaled .-> PSH
  BPL --> PS
  CON --> PS
  BP --> PS

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
    classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
    classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
    classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
    classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
    classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
    class PS service;
    class PSH,BPL,CON,BP datastore;

Glossary & fundamentals#

Tag Concept What it is Page
HLD Pub Sub Pattern the distributed analog pub-sub-pattern
LLD Behavioral patterns Observer behavioral-patterns
LLD Concurrency primitives BlockingQueue, executor concurrency-primitives
HLD Backpressure the flow-control story backpressure

Quick reference#

Classes#

  • Broker (topic registry)
  • Topic (subscription list, deliver())
  • Subscription (filter, bounded queue, worker)
  • Subscriber (onMessage, onError)
  • Message (id, topic, payload, headers, ts)

Dispatch#

  • Filter in dispatcher (avoid wasted wakeups)
  • offer() to bounded queue
  • Per-subscription worker thread

Backpressure modes#

  • Drop newest (default)
  • Drop oldest
  • Block publisher (couples to slowest sub)
  • Disconnect slow consumer

Error isolation#

  • Subscriber exception → onError; subscription survives
  • Worker uncaughtExceptionHandler
  • Circuit breaker on repeated failures

Replay#

Topic ring buffer; new sub drains buffer then live. Beyond small N, use Kafka.

Sync vs async#

  • Async default
  • Sync mode for tests / ordering

Production analogs#

  • Guava EventBus
  • Reactor Sinks
  • Akka actors
  • Spring ApplicationEvent

Refs#

  • Guava EventBus docs
  • Reactor Sinks docs

FAQ#

How do you design an in-process pub/sub broker?#

A Broker holds Topic objects keyed by name; publishers post messages, and the broker fans them out to registered Subscriber callbacks, optionally with per-subscriber filters.

Should dispatch be synchronous or asynchronous?#

Async via per-subscriber bounded queues so a slow subscriber cannot block publishers. Sync dispatch is simpler but couples publisher latency to the slowest consumer.

How is back-pressure handled?#

Each subscriber has a bounded queue. When full, choose a policy: drop oldest, drop newest, block the publisher, or surface an overflow event for telemetry.

How are subscriber errors isolated?#

Each callback runs in a try/catch on its own worker thread or task, so an exception in one subscriber never blocks delivery to other subscribers on the same topic.

What is the difference between pub/sub and Observer?#

Observer is in-object and synchronous. Pub/Sub adds named topics, fan-out across many subjects, optional async dispatch, and decouples publishers from concrete subscriber types.

Further reading#