Broker/Topic/Publisher/Subscriber/Message classes for an in-memory pub-sub, and an honest accounting of what's missing compared to Kafka or RabbitMQ.
Published September 23, 2026
Design a Notification/Observer-Based Pub-Sub built an in-process event bus. This lesson is a step up in scope — a standalone broker component, still in-memory, but modeling the actual broker/topic/producer/consumer shape a real message queue uses.
class Message { String id; String payload; Instant timestamp; }
class Topic {
private final Queue<Message> messages = new ConcurrentLinkedQueue<>();
private final List<Subscriber> subscribers = new CopyOnWriteArrayList<>();
}
interface Subscriber { void onMessage(Message message); }
class Publisher {
private final Broker broker;
void publish(String topicName, String payload) {
broker.getTopic(topicName).deliver(new Message(UUID.randomUUID().toString(), payload, Instant.now()));
}
}
class Broker {
private final Map<String, Topic> topics = new ConcurrentHashMap<>();
Topic getTopic(String name) { return topics.computeIfAbsent(name, k -> new Topic()); }
}
class Topic {
// ...fields above...
void subscribe(Subscriber subscriber) { subscribers.add(subscriber); }
void deliver(Message message) {
for (Subscriber s : subscribers) {
s.onMessage(message); // synchronous fan-out to every current subscriber of this topic
}
}
}
Topic-based routing here is deliberately simple — a message published to "orders.created" only reaches subscribers of exactly that topic name, with no pattern matching or hierarchical routing (a real broker's "orders.*" wildcard subscriptions, for instance) — a reasonable, explicitly-scoped simplification for an LLD exercise.
Naming these gaps explicitly is the actual point of this exercise — recognizing the difference between "a working pub-sub toy" and "a production message queue" is a stronger signal than presenting this simplified version as complete:
Topic's messages exist only in a ConcurrentLinkedQueue in one process's memory — a process restart loses everything undelivered. Kafka/RabbitMQ durably write messages to disk before acknowledging a publish.deliver() here is fire-and-forget synchronous iteration — no acknowledgment, no retry on a subscriber failure, no offset tracking to resume from a specific point after a consumer restart. Real brokers offer at-least-once (or exactly-once, with more machinery) delivery guarantees backed by consumer acknowledgment and offset commits.Q: If you had to add ONE of these three missing features first for a real use case, which, and why? A: Usually persistence — an in-memory-only broker that loses all undelivered messages on restart is disqualifying for most real production use cases immediately, while partitioning and refined delivery guarantees are scaling/robustness refinements that matter once the basic durability requirement is already met.
Q: How would you add at-least-once delivery to this design without a full broker rewrite? A: Track per-subscriber acknowledgment (a subscriber calls back to confirm processing) and retain a message in the Topic's queue until every subscriber has acknowledged it, retrying delivery to any subscriber that hasn't acknowledged within a timeout — a meaningful step up in complexity from the current fire-and-forget deliver(), but a natural extension rather than a redesign.
Q: Is synchronous deliver() (subscribers processed in a loop on the publishing thread) a design smell? A: For an LLD exercise it's an acceptable simplification, but worth naming: a slow subscriber blocks the publisher (and every other subscriber's delivery) under this design — the same sync-vs-async tradeoff from Design a Notification/Observer-Based Pub-Sub applies, and a production version would dispatch to subscribers asynchronously (an executor per subscriber, or per topic) to decouple their processing time from the publisher's.
Q: How does ConcurrentLinkedQueue's choice here compare to using a plain synchronized Queue? A: ConcurrentLinkedQueue is a lock-free (CAS-based) concurrent queue implementation, generally offering better throughput under contention than externally synchronizing a plain LinkedList — a reasonable default choice for a queue accessed by potentially many publisher and consumer threads concurrently, without needing to hand-roll the synchronization Producer-Consumer Class Design builds explicitly for its own pedagogical purpose.