Event bus
Low-level design of a publish-subscribe event bus -- topic registration, subscriber management, synchronous vs asynchronous dispatch, wildcard routing, dead-letter handling, and thread safety.
The Problem
Your microservices team has 12 services that all need to react when an order is placed. The checkout service imports every downstream service and calls them one by one: inventory, billing, notifications, analytics, fraud detection. Every time someone adds a new listener, the checkout service gets another dependency, another failure path, and another deploy. A single slow subscriber blocks the entire checkout flow.
An in-process event bus decouples publishers from subscribers. The publisher fires an event into the bus and walks away. The bus routes the event to every registered subscriber without the publisher knowing (or caring) who those subscribers are. Subscribers register themselves, the bus handles dispatch, and failed deliveries land in a dead-letter queue instead of crashing the publisher.
Design the core classes for an event bus that supports topic-based publish/subscribe, wildcard topic matching, synchronous and asynchronous dispatch modes, subscriber error isolation, dead-letter handling, and thread-safe registration.
Requirements
Clarifying Questions
Before jumping into class design, ask questions to turn the vague prompt into a concrete specification. Cover four areas: core actions, error handling, boundaries, and future extensions.
You: "Are events dispatched synchronously on the publisher's thread, or asynchronously via a thread pool?"
Interviewer: "Support both. The default is synchronous, but subscribers should be able to opt into async delivery. Some events need ordering guarantees, so we need an ordered-async mode too."
Three dispatch modes: sync (same thread), async (thread pool, no ordering), and ordered-async (single-threaded executor per topic). That means dispatch strategy is a first-class design concern.
You: "Do we support wildcard subscriptions? For example, subscribing to order.* and receiving both order.created and order.cancelled?"
Interviewer: "Yes. Support single-level wildcards with * so that order.* matches order.created but not order.payment.failed."
Wildcard matching at subscribe time. We need a matching algorithm that splits topics by . and compares segments. This is similar to MQTT topic filters.
You: "What happens when a subscriber throws an exception during event handling?"
Interviewer: "The bus should never crash. Isolate the failure, retry up to a configurable number of times, and if it still fails, send the event to a dead-letter queue. Other subscribers must not be affected."
Error isolation with retry-then-DLQ. Each subscriber invocation is wrapped in a try-catch. The dead-letter queue records the event, the subscriber, and the exception for later inspection.
You: "Should subscribers receive events for parent topics? If I subscribe to order, do I get order.created events?"
Interviewer: "No, that would be too noisy. Only exact matches and explicit wildcard subscriptions. order only matches order, not order.created."
Good. No implicit hierarchy. Wildcards are explicit opt-in. That simplifies the matching logic.
You: "Do events need to be persisted, or is this purely in-memory?"
Interviewer: "In-memory for now. Persistence is an extension, not a core requirement."
In-memory only. We will note persistence as an extensibility point but keep the core design simple.
You: "Can subscribers have priority? Should some subscribers always run before others for the same topic?"
Interviewer: "Yes, support an optional priority. Higher-priority subscribers run first. Default priority is 0."
Priority ordering on subscribers. We need a sorted data structure in the subscriber registry.
You: "Is there a concept of event filtering, where a subscriber only receives events that match certain criteria beyond the topic?"
Interviewer: "Not for the core design. But design it so filters could be added later without changing existing code."
Filtering is out of scope but should be easy to plug in via middleware or a filter chain. Keep the extension point in mind.
You: "Should unsubscribe be explicit, or should we support auto-cleanup when subscribers are garbage collected?"
Interviewer: "Explicit via a subscription handle. The subscriber calls cancel() on the returned subscription object."
Subscription token pattern. Subscribe returns a Subscription object, and calling cancel() removes the handler.
Perfect. You have clarified scope and ruled out unnecessary complexity.
Final Requirements
Functional Requirements:
- Publish an event to a named topic; all matching subscribers receive it.
- Subscribe to a topic (exact match) and receive a cancellable
Subscriptionhandle. - Subscribe with a wildcard pattern (
order.*) matching any single segment after the prefix. - Dispatch events synchronously (default), asynchronously, or ordered-async per subscriber.
- Retry failed subscriber invocations up to N times before routing to a dead-letter queue.
- Support subscriber priority ordering (lower number = higher priority = runs first).
Non-Functional Requirements:
- Thread safety: concurrent publish and subscribe from different threads must not corrupt state.
- Error isolation: one subscriber's failure never affects other subscribers or the publisher.
- Extensibility: adding new dispatch modes or event filters requires new classes, not editing existing ones.
Out of Scope:
- Event persistence or replay
- Distributed event routing across processes
- UI or REST API
- Event schema validation
Example Inputs and Outputs
Scenario 1: Basic publish-subscribe
- Setup: Subscriber A registers for topic
order.created. Subscriber B registers for topicorder.cancelled. - Action: Publisher fires an event on topic
order.created. - Expected: Subscriber A receives the event. Subscriber B does not.
- Why: Validates exact topic matching and subscriber isolation.
Scenario 2: Wildcard subscription
- Setup: Subscriber C registers for pattern
order.*. - Action: Publisher fires events on
order.createdandorder.cancelled. - Expected: Subscriber C receives both events. An event on topic
order.payment.faileddoes NOT reach Subscriber C (wildcard matches one segment only). - Why: Validates single-level wildcard matching.
Scenario 3: Failed delivery with retry and DLQ
- Setup: Subscriber D registers for
payment.processedbut throws an exception on every invocation. Retry limit is 3. - Action: Publisher fires a
payment.processedevent. - Expected: The bus retries delivery 3 times. After all retries fail, the event lands in the dead-letter queue with the exception details. Other subscribers for the same topic still receive the event successfully.
- Why: Validates error isolation, retry logic, and dead-letter routing.
Scenario 4: Priority ordering
- Setup: Subscriber E (priority 1) and Subscriber F (priority 5) both register for
inventory.updated. - Action: Publisher fires an
inventory.updatedevent. - Expected: Subscriber E runs before Subscriber F because lower priority number means higher precedence.
- Why: Validates priority-based dispatch ordering.
Try It Yourself
Try it yourself
Before reading the solution, spend 15-20 minutes sketching your own class diagram. Start with the publish and subscribe methods. Think about how wildcard matching works, how you would support multiple dispatch modes, and where error handling belongs. Compare your approach with the walkthrough below.
Step 1: Identify Core Entities
Start by asking: what are the main "things" in this problem? Scan your requirements for nouns and think about what responsibility each one owns. A common mistake is lumping everything into one EventBus god-class. Good design means every class has a single, clear job.
| Entity | Responsibility | Key attributes |
|---|---|---|
| Event | Immutable data carrier. Holds the topic name and a payload. | topic, payload, timestamp, eventId |
| EventBus | The orchestrator. Accepts subscriptions, routes published events to matching subscribers. | subscriberRegistry, dispatcher, deadLetterQueue |
| Subscription | Cancellation token returned to the subscriber. Calling cancel() removes the handler. | topic, handler, active flag |
| SubscriberEntry | Metadata wrapper around a handler: priority, dispatch mode, retry config. | handler, priority, dispatchMode, maxRetries |
| TopicMatcher | Matching logic. Determines whether a published topic matches a subscription pattern (exact or wildcard). | (stateless utility) |
| EventDispatcher | Strategy interface for dispatch mode. Sync, async, and ordered-async are implementations. | (depends on implementation) |
| DeadLetterQueue | Stores events that failed all delivery retries. Records the event, subscriber, exception, and timestamp. | entries list |
| DeadLetterEntry | Single record of a failed delivery attempt. | event, subscriberEntry, exception, timestamp, attemptCount |
Notice we separated TopicMatcher from EventBus because matching logic is independently testable and could change (e.g., adding multi-level wildcards) without touching dispatch. EventDispatcher is separate because dispatch mode is a strategy that varies per subscriber.
Step 2: Define Relationships and Class Design
Class Diagram
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.