Design an in-memory pub/sub system — full class-level sol… — Cracked Java
// Low-Level Design (LLD / OOD) · Design a Pub/Sub System (in-memory)
SeniorSystem DesignAmazon

Design an in-memory pub/sub system — full class-level solution.

1. Functional requirements

  • Publishers publish messages to a named topic; topics are created on first use.
  • Subscribers subscribe/unsubscribe to a topic and receive every message published after they subscribe.
  • A subscriber may subscribe to multiple topics; a topic may have many subscribers.
  • Delivery is asynchronouspublish returns without waiting for subscribers to process.
  • Pluggable delivery semantics: at-most-once, at-least-once (retry), exactly-once (dedup).

2. Non-functional requirements

  • Concurrency — concurrent publishers and subscribers; one slow subscriber must not block delivery to others or block the publisher.
  • Ordering — preserve per-topic, per-subscriber order (messages from one topic arrive in publish order).
  • Backpressure — bound memory; define a policy when a subscriber falls behind.
  • Durability — out of scope for the in-memory round (messages live in memory); the seam is shown. Distributed Kafka-style design is in the HLD module.

3. Core entities

EntityResponsibility
BrokerSingleton entry point; routes publishes to subscribers.
TopicNamed channel; holds its subscriber set.
PublisherClient that calls broker.publish(topic, msg).
SubscriberCallback (onMessage) plus its own delivery queue.
MessageImmutable payload + metadata (id, topic, timestamp).
SubscriptionManagerMaps topics ↔ subscribers; handles (un)subscribe.
DeliveryStrategyEncapsulates the delivery guarantee (at-most/at-least/exactly-once).

4. Class diagram

In-memory pub/sub class model

5. Key interfaces and classes

final class Message {
    final String id;
    final String topic;
    final Object payload;
    final Instant timestamp;
    Message(String topic, Object payload) {
        this.id = UUID.randomUUID().toString();
        this.topic = topic;
        this.payload = payload;
        this.timestamp = Instant.now();
    }
}

interface Subscriber {
    String id();
    void onMessage(Message m);   // may throw — strategy decides what to do
}

interface DeliveryStrategy {
    void deliver(Subscriber s, Message m);
}

final class AtMostOnce implements DeliveryStrategy {        // fire and forget
    public void deliver(Subscriber s, Message m) {
        try { s.onMessage(m); } catch (Exception ignored) { /* no retry */ }
    }
}

final class AtLeastOnce implements DeliveryStrategy {       // retry then DLQ
    private final int maxRetries; private final DeadLetterQueue dlq;
    AtLeastOnce(int maxRetries, DeadLetterQueue dlq) { this.maxRetries = maxRetries; this.dlq = dlq; }
    public void deliver(Subscriber s, Message m) {
        for (int attempt = 0; attempt <= maxRetries; attempt++) {
            try { s.onMessage(m); return; } catch (Exception e) { /* backoff */ }
        }
        dlq.add(s, m);
    }
}
final class SubscriptionManager {
    private final Map<String, Set<Subscriber>> topics = new ConcurrentHashMap<>();
    void add(String topic, Subscriber s)    { topics.computeIfAbsent(topic, t -> ConcurrentHashMap.newKeySet()).add(s); }
    void remove(String topic, Subscriber s) { topics.getOrDefault(topic, Set.of()).remove(s); }
    Set<Subscriber> subscribers(String topic) { return topics.getOrDefault(topic, Set.of()); }
}

public final class Broker {
    private static final Broker INSTANCE = new Broker();
    public static Broker get() { return INSTANCE; }

    private final SubscriptionManager subscriptions = new SubscriptionManager();
    private final ExecutorService pool = Executors.newFixedThreadPool(
        Runtime.getRuntime().availableProcessors());
    private DeliveryStrategy delivery = new AtMostOnce();
    private Broker() {}

    public void subscribe(String topic, Subscriber s)   { subscriptions.add(topic, s); }
    public void unsubscribe(String topic, Subscriber s) { subscriptions.remove(topic, s); }

    public void publish(String topic, Object payload) {
        Message m = new Message(topic, payload);
        for (Subscriber s : subscriptions.subscribers(topic)) {
            pool.submit(() -> delivery.deliver(s, m));   // async — publisher never blocks
        }
    }
}

A production-grade variant gives each subscriber its own single-threaded executor (or bounded BlockingQueue) so that ordering is preserved per subscriber and a slow subscriber only backs up its own queue.

6. Design patterns used

  • Observer — the core relationship: subscribers register with the broker and are notified on publish, with no compile-time coupling to publishers.
  • Singleton — one Broker per process is the routing hub.
  • StrategyDeliveryStrategy makes at-most-once / at-least-once / exactly-once swappable without touching the broker.
  • Factory (optional) — a SubscriberFactory or message builder centralizes construction; mention but don't over-engineer.

7. Trade-offs and alternatives

  • Push vs pull. Push (broker calls onMessage) is simple and low-latency but the broker must handle backpressure. Pull (subscriber polls its queue, Kafka-style) shifts pacing to the consumer and is more robust under load.
  • Shared thread pool vs per-subscriber queue. A shared pool is simplest but loses per-subscriber ordering and lets one bad subscriber starve threads. A per-subscriber bounded queue isolates slow consumers and preserves order, at the cost of more memory and threads.
  • Backpressure policy. When a bounded queue fills you must choose: block the publisher (flow control), drop oldest/newest (lossy), or shed to a DLQ. There is no free lunch — name the policy explicitly.
  • Exactly-once is expensive. It requires dedup (idempotency keys) and acknowledgement tracking; at-least-once + idempotent consumers is the pragmatic default.

8. Common follow-up questions

  • Filtered / wildcard subscriptions — subscribe to orders.* or with a predicate; add a Predicate<Message> filter or topic-tree matching in the SubscriptionManager.
  • Dead-letter queue — route messages that exhaust retries to a DLQ for inspection/replay (shown in AtLeastOnce).
  • Slow subscribers / backpressure — per-subscriber bounded queue plus a drop/block/DLQ policy; possibly evict chronically-lagging subscribers.
  • Ordering guarantees — single-threaded delivery per subscriber preserves per-topic order; the shared pool does not.
  • Durability / replay — persist a per-topic log with offsets so subscribers can replay (the bridge to Kafka in the HLD module).

9. What interviewers are really probing

Mark your status