Engineering Atlas › Low-Level Design

Code an in-process publish-subscribe system

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: durability across process restarts, cross-process delivery, wildcard topic hierarchies, consumer groups that share a subscription, and exactly-once processing. Say that durability turns this into a log-based broker and point to the system design lessons; then design the in-memory core well. The Pub/Sub lesson covers durable hubs and topic wildcards in depth.

Entities and relationships

Relationships: a broker is composed of topics; a topic aggregates subscriptions (a subscription references the topic by name and can be removed independently). A subscription owns its buffer and points at, but does not own, its subscriber. Messages are referenced from many buffers and owned by none, which is why they must be immutable.

A common first draft has Topic loop over subscriber callbacks inside publish. That is Observer, not pub/sub: the publisher's thread runs every handler. The Subscription with its own buffer is what turns one into the other.

Class diagram

classDiagram
    class Broker {
      -topics: Map~string, Topic~
      -subs: Map~SubId, Subscription~
      +subscribe(topic, id, capacity, policy, filter) Subscription
      +unsubscribe(id) bool
      +publish(topic, payload, attrs) int
      +poll(id, max) Message[]
    }
    class Topic {
      +name: string
      -nextSeq: int
      -subscriptions: Subscription[]
    }
    class Message {
      +topic: string
      +seq: int
      +payload: bytes
      +attrs: Map~string, string~
      +publishedAt: int
    }
    class Subscription {
      +id: SubId
      -buffer: RingBuffer~Message~
      -dropped: int
      +offer(msg) OfferResult
      +take(max) Message[]
    }
    class MessageFilter {
      <<interface>>
      +matches(msg) bool
    }
    class OverflowPolicy {
      <<interface>>
      +onFull(buffer, msg) OfferResult
    }
    class Subscriber {
      <<interface>>
      +onMessage(msg)
    }
    class Dispatcher {
      +run(subscription)
    }
    Broker "1" *-- "many" Topic
    Topic "1" o-- "many" Subscription
    Subscription --> MessageFilter
    Subscription --> OverflowPolicy
    Subscription --> Subscriber
    Subscription ..> Message
    Dispatcher --> Subscription
    DropOldest ..|> OverflowPolicy
    DropNewest ..|> OverflowPolicy
    RejectPublish ..|> OverflowPolicy
    AttributeEquals ..|> MessageFilter

Key flows

Publish is a two-phase step under the topic's lock: first check whether any matching subscription would reject, then enqueue into every matching buffer. Delivery happens later, on each subscription's own dispatcher, so the publisher never runs subscriber code.

sequenceDiagram
    participant P as Publisher
    participant B as Broker
    participant T as Topic
    participant S1 as Subscription A
    participant S2 as Subscription B
    participant D as Dispatcher B
    participant H as Handler B
    P->>B: publish("orders", payload, attrs)
    B->>T: lock, match filters
    T->>S1: would reject?
    T->>S2: would reject?
    T->>T: assign seq 41
    T->>S1: offer(msg 41)
    T->>S2: offer(msg 41)
    T-->>B: unlock
    B-->>P: seq 41
    D->>S2: take(batch of 32)
    S2-->>D: msgs 39..41
    D->>H: onMessage(39), (40), (41)

Design patterns used and why

Argue against a Singleton broker: tests share state, and you will soon want two brokers (domain events and telemetry).

Concurrency and edge cases

Down the stack

Layer 1The contract

publish returns a sequence number once the message is in every matching buffer, or fails with back pressure and changes nothing. For each subscription, messages are delivered in increasing sequence order with no duplicates; gaps appear only where the overflow policy dropped a message, and the dropped counter accounts for every gap. No ordering holds across topics. Delivery is at most once: a crash loses whatever is buffered. Publish latency does not depend on any handler's speed.

Layer 2The mechanism

Each topic holds an array of subscriptions replaced on change (copy on write), so publish reads it without taking the subscription-set lock. Each subscription's buffer is a ring buffer: a fixed array of capacity slots and two counters, head and tail, where tail - head is the length and index = counter mod capacity. Drop-oldest advances head by one before writing; drop-newest just increments dropped. A dispatcher per subscription takes up to a batch of messages and calls the handler; Go programs often model this as one goroutine and a buffered channel per subscription, with a select and default branch to implement drop-newest without blocking.

Layer 3The machine

A message here is a pointer to an immutable object, so fan-out to 100 subscriptions writes 100 pointers (800 bytes on a 64-bit machine), not 100 payloads. A ring buffer slot write is a store into memory that is usually already in L1 or L2 cache, a few nanoseconds; an uncontended Go channel send is on the order of 50 to 100 ns because it takes the channel's internal lock. When a dispatcher sleeps on an empty buffer and is woken, the cost is a futex wake and a context switch, a few microseconds. That is why dispatchers drain in batches: one wake-up for 32 messages instead of 32 wake-ups. The head and tail counters are written by different threads; if they share a 64-byte cache line, every publish invalidates the dispatcher's copy and vice versa (false sharing), which is why high-throughput rings pad them onto separate lines. Garbage collection is the hidden cost of unbounded buffers: a backlog of a million small messages is a million live objects the collector must trace on every cycle.

Why it's built this way

  1. Subscribers run at different speeds, so each needs its own buffer, or the slowest sets the pace for everyone.
  2. Memory is finite, so buffers are bounded, which forces a decision about what to do when full.
  3. That decision depends on what the consumer values (freshness, completeness or cheapness), so it is a per-subscription strategy rather than a broker setting.
  4. Wake-ups cost microseconds while slot writes cost nanoseconds, so dispatchers batch, and publish never touches subscriber code.
  5. Fan-out multiplies every per-message cost by the subscriber count, so messages are shared immutable references and the routing step does no I/O.

Extensibility

Leadership lens

Check yourself

  1. Why is a loop over subscriber callbacks inside publish not a pub/sub system, even though it decouples the publisher from subscriber types?

    Show answer

    The publisher's thread runs every handler, so a slow or throwing handler delays or breaks the publisher and every later subscriber. Pub/sub puts a buffer and a separate dispatcher between them.

  2. Subscription C uses the reject policy and is full. A and B have room. What must publish do, and why?

    Show answer

    Enqueue nowhere and fail, without consuming a sequence number. Partial delivery would make a publisher retry duplicate the message for A and B and leave a phantom gap for C.

  3. A stock-ticker display and an audit logger subscribe to the same topic. Which overflow policy suits each?

    Show answer

    The ticker wants drop-oldest, because only the newest price matters. The audit logger needs reject (or a durable log), because losing an event silently is worse than slowing the publisher.

  4. Why do dispatchers drain buffers in batches rather than one message at a time?

    Show answer

    Waking a sleeping thread costs a futex call and a context switch, microseconds, while taking a message from a ring costs nanoseconds. Batching amortises one wake-up across many messages.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →