Loading learning content...
In traditional software design, components communicate through direct method calls. Service A needs something from Service B, so it calls serviceB.doSomething(). This is intuitive, explicit, and mirrors how we naturally think about programming. But as systems grow in complexity, this direct coupling becomes a liability.
What if Service B changes its interface? Service A must change too. What if Service A needs to notify five different services? It must know about all of them. What if Service B is temporarily unavailable? Service A must handle the failure.
Event-driven design offers a fundamentally different approach: instead of telling other components what to do, a component simply announces what happened. It publishes an event—a record of a fact that occurred—and allows interested parties to react as they see fit. The component doesn't need to know who's listening, what they'll do, or even if anyone is listening at all.
By the end of this page, you will understand the fundamental concepts of event-driven design: what events are, the difference between synchronous and asynchronous communication, and the core mental model shift from 'commanding' to 'announcing.' You will be equipped to recognize when event-driven patterns apply and understand why they enable more flexible, scalable, and maintainable systems.
At its core, an event is a record of something that happened in the past. This definition is deceptively simple, but its implications are profound.
Key Characteristics of Events:
Events are immutable facts. Once an OrderPlaced event occurred, it cannot be undone—it happened. You might later have an OrderCancelled event, but the original event remains a permanent record.
Events are named in past tense. UserRegistered, PaymentProcessed, InventoryUpdated—always describing something that has already occurred, never something that should occur.
Events carry relevant data. An event contains the information that interested parties need to understand what happened: the order ID, the user details, the amount paid, the timestamp.
Events have no expectation of response. When a component publishes an event, it does so without expecting any particular outcome. It's fire-and-forget from the publisher's perspective.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Events are immutable records of facts that occurred// Notice the past-tense naming: something HAS happened interface DomainEvent { readonly eventId: string; // Unique identifier for this specific event readonly eventType: string; // The type of event (e.g., "OrderPlaced") readonly occurredAt: Date; // When the event occurred readonly aggregateId: string; // The entity this event relates to readonly payload: Readonly<object>; // The event-specific data} // Example: A concrete event representing an order being placedinterface OrderPlacedEvent extends DomainEvent { eventType: "OrderPlaced"; payload: Readonly<{ orderId: string; customerId: string; items: ReadonlyArray<{ productId: string; quantity: number; unitPrice: number; }>; totalAmount: number; shippingAddress: Readonly<{ street: string; city: string; postalCode: string; country: string; }>; }>;} // Example: Another event in the order lifecycleinterface OrderShippedEvent extends DomainEvent { eventType: "OrderShipped"; payload: Readonly<{ orderId: string; trackingNumber: string; carrier: string; estimatedDelivery: Date; }>;} // Events are facts—this order WAS placed, this order WAS shipped// Nothing about these events requests action; they simply record historyEvents must be immutable. If an event could be modified after creation, you'd lose the guarantee that it accurately represents what happened at that moment. Use readonly in TypeScript, final in Java, or frozen objects in Python. Immutability ensures that events remain trustworthy historical records.
Understanding event-driven design requires a fundamental shift in how you think about inter-component communication. Let's contrast the two approaches:
Traditional Approach (Command-Oriented):
Event-Driven Approach:
This shift has profound implications for how systems are structured and how they evolve over time.
The newspaper analogy:
Think of the command-oriented approach like making individual phone calls. You need the phone number of everyone you want to reach, you must call them one by one, and if someone doesn't answer, you have to decide what to do.
The event-driven approach is like publishing a newspaper. You announce the news once, and anyone interested can read it. You don't need to know who your subscribers are. If someone takes a vacation, the newspaper still publishes—they can catch up when they return. Adding a new subscriber doesn't require any change to your publishing process.
This analogy captures the essence of why event-driven systems are more flexible: the publisher is insulated from changes in the subscriber base.
In command-oriented design, the publisher depends on its consumers. In event-driven design, this is inverted: consumers depend on the events they subscribe to, but publishers are blissfully unaware of consumers. This is a form of the Dependency Inversion Principle at the architectural level.
A common point of confusion is the relationship between event-driven design and asynchronous processing. While event-driven systems are often asynchronous, they don't have to be. Understanding the distinction is crucial.
Synchronous Event Processing:
Asynchronous Event Processing:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// ===== SYNCHRONOUS EVENT DISPATCHING =====// Publisher waits for all handlers to complete class SynchronousEventDispatcher { private handlers: Map<string, EventHandler[]> = new Map(); subscribe(eventType: string, handler: EventHandler): void { const existing = this.handlers.get(eventType) || []; this.handlers.set(eventType, [...existing, handler]); } // Synchronous: awaits all handlers before returning async dispatch(event: DomainEvent): Promise<void> { const handlers = this.handlers.get(event.eventType) || []; // All handlers execute before this method returns // Publisher has the option to catch errors for (const handler of handlers) { await handler.handle(event); } }} // Usage: Publisher waits for completionclass OrderService { constructor(private eventDispatcher: SynchronousEventDispatcher) {} async placeOrder(order: Order): Promise<void> { // ... order creation logic ... // Publisher waits here until ALL handlers complete await this.eventDispatcher.dispatch(new OrderPlacedEvent(order)); // Code here runs AFTER all handlers finished console.log("All handlers processed the order"); }} // ===== ASYNCHRONOUS EVENT DISPATCHING =====// Publisher continues immediately; events processed later class AsynchronousEventDispatcher { constructor(private messageQueue: MessageQueue) {} // Asynchronous: returns immediately after queuing async dispatch(event: DomainEvent): Promise<void> { // Event is sent to a queue/broker // Actual processing happens later, possibly on different machines await this.messageQueue.publish("events", event); // This returns as soon as the event is queued // NOT when it's processed }} // Usage: Publisher doesn't wait for processingclass OrderServiceAsync { constructor(private eventDispatcher: AsynchronousEventDispatcher) {} async placeOrder(order: Order): Promise<void> { // ... order creation logic ... // Publisher continues immediately after queuing await this.eventDispatcher.dispatch(new OrderPlacedEvent(order)); // Code here runs BEFORE handlers might have processed // The order is "eventually" handled, not immediately console.log("Order queued for processing"); }}| Aspect | Synchronous | Asynchronous |
|---|---|---|
| Processing timing | Immediate, within same request/transaction | Deferred, possibly much later |
| Publisher behavior | Blocks until handlers complete | Continues immediately after dispatch |
| Error handling | Errors propagate to publisher | Errors handled separately (dead-letter queues, retries) |
| Consistency model | Strong consistency feasible | Eventual consistency typical |
| Scalability | Limited by slowest handler | Independent scaling of publishers/consumers |
| Debugging difficulty | Easier (synchronous flow) | Harder (distributed tracing needed) |
| Infrastructure | In-process dispatcher | Message broker/queue required |
A common mistake is jumping straight to asynchronous processing with message brokers. Start with synchronous in-process event dispatching. It provides the decoupling benefits of event-driven design without the complexity of distributed systems. Migrate to asynchronous when you have proven performance or reliability requirements that demand it.
Event-driven design isn't an all-or-nothing proposition. It's a technique you apply selectively within your architecture. Understanding where it fits helps you make principled decisions about when to use it.
Where Event-Driven Design Commonly Appears:
Within a single application (Domain Events): Components within a monolith or microservice communicate via events to stay decoupled. This is "small-scale" event-driven design and is the focus of Low-Level Design.
Between services (Integration Events): Microservices publish events to notify other services of state changes. This involves message brokers, eventual consistency, and distributed systems concerns.
Between systems (Enterprise Integration): Different applications or even different organizations communicate via events using enterprise messaging patterns.
User Interface (UI Events): Front-end frameworks use events for user interactions—clicking, typing, scrolling. The browser's event model is inherently event-driven.
Hardware and Operating Systems: Interrupts, file system watchers, network sockets—low-level systems are fundamentally event-driven.
Event-Driven Design at the LLD Level:
This module focuses on event-driven patterns within a single codebase or service. We're concerned with:
We're not (primarily) concerned with Kafka, RabbitMQ, or distributed systems—those are High-Level Design (HLD) topics. Our goal is to design classes and modules that communicate through events effectively, cleanly, and maintainably.
Every event-driven system, regardless of scale, contains the same fundamental components. Understanding these components is essential before diving into implementation patterns.
Core Components:
Events: Immutable records of facts that occurred. These are the "messages" of the system.
Event Producers (Publishers): Components that create and emit events when significant state changes occur.
Event Dispatcher (Event Bus): The mechanism that receives events from producers and delivers them to consumers. This is the "router" of the system.
Event Consumers (Handlers/Subscribers): Components that receive events and perform actions in response.
Subscriptions: The registrations that connect consumers to specific event types they're interested in.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// ===== 1. EVENT: The immutable fact record =====interface OrderPlacedEvent { readonly eventId: string; readonly eventType: "OrderPlaced"; readonly occurredAt: Date; readonly payload: Readonly<{ orderId: string; customerId: string; totalAmount: number; }>;} // ===== 2. EVENT PRODUCER: Emits events when things happen =====class OrderService { constructor(private eventDispatcher: EventDispatcher) {} async placeOrder(customerId: string, items: OrderItem[]): Promise<Order> { // Business logic: create the order const order = new Order(customerId, items); await this.orderRepository.save(order); // PRODUCE an event announcing what happened const event: OrderPlacedEvent = { eventId: crypto.randomUUID(), eventType: "OrderPlaced", occurredAt: new Date(), payload: { orderId: order.id, customerId: order.customerId, totalAmount: order.calculateTotal(), }, }; // Dispatch to the event bus await this.eventDispatcher.dispatch(event); return order; }} // ===== 3. EVENT DISPATCHER: Routes events to handlers =====interface EventHandler<T = DomainEvent> { handle(event: T): Promise<void>;} class EventDispatcher { private subscriptions: Map<string, EventHandler[]> = new Map(); // Register a handler for an event type subscribe<T extends DomainEvent>( eventType: string, handler: EventHandler<T> ): void { const handlers = this.subscriptions.get(eventType) || []; this.subscriptions.set(eventType, [...handlers, handler]); } // Deliver event to all interested handlers async dispatch(event: DomainEvent): Promise<void> { const handlers = this.subscriptions.get(event.eventType) || []; await Promise.all( handlers.map(handler => handler.handle(event)) ); }} // ===== 4. EVENT CONSUMERS: React to events =====class InventoryHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // Reserve inventory for the order console.log(`Reserving inventory for order ${event.payload.orderId}`); await this.inventoryService.reserveForOrder(event.payload.orderId); }} class NotificationHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // Send confirmation email console.log(`Sending confirmation for order ${event.payload.orderId}`); await this.emailService.sendOrderConfirmation(event.payload.customerId); }} class AnalyticsHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // Track the order for analytics console.log(`Tracking order ${event.payload.orderId}`); await this.analytics.trackOrder(event.payload); }} // ===== 5. SUBSCRIPTIONS: Wire it all together =====function configureEventHandlers(dispatcher: EventDispatcher): void { // Each subscription connects an event type to a handler dispatcher.subscribe("OrderPlaced", new InventoryHandler()); dispatcher.subscribe("OrderPlaced", new NotificationHandler()); dispatcher.subscribe("OrderPlaced", new AnalyticsHandler()); // Easy to add more handlers without changing OrderService dispatcher.subscribe("OrderPlaced", new FraudDetectionHandler()); dispatcher.subscribe("OrderPlaced", new LoyaltyPointsHandler());}Look at the OrderService class. It has no knowledge of InventoryHandler, NotificationHandler, or AnalyticsHandler. It doesn't import them, doesn't call them, doesn't even know they exist. All it does is announce 'OrderPlaced' and move on. The wiring happens elsewhere, at composition time, keeping the core business logic clean.
Understanding the complete lifecycle of an event helps you reason about event-driven systems. Every event goes through predictable phases from creation to completion.
Phase 1: Creation An event is instantiated when something significant happens. The producer creates an immutable event object with all relevant data.
Phase 2: Publication (Dispatch) The event is handed to the event dispatcher/bus. At this point, the producer's job is done.
Phase 3: Routing The dispatcher determines which handlers are subscribed to this event type. This is a lookup operation.
Phase 4: Delivery The event is delivered to each subscribed handler. Depending on the system, this might be synchronous (one at a time) or concurrent (all at once).
Phase 5: Processing Each handler receives the event and performs its specific logic. Handlers are independent—one handler's success or failure doesn't affect others.
Phase 6: Completion (or Failure) The event has been fully processed. In synchronous systems, the publisher may be notified. In asynchronous systems, completion is typically tracked separately (acknowledgments, completion logs).
┌─────────────────────────────────────────────────────────────────────┐
│ EVENT LIFECYCLE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ PRODUCER │───▶│ EVENT │───▶│DISPATCHER│───▶│ HANDLERS │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ "Something Immutable Routes to Process │
│ happened!" record subscribers event data │
│ │ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
│ │ Create │ │ Publish │ │ Deliver │ │ Handle │ │
│ │ event │ ──▶│ to │ ──▶│ to │ ──▶│ in │ │
│ │ object │ │ bus │ │ each │ │ each │ │
│ └─────────┘ └─────────┘ │ handler │ │ handler │ │
│ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Unlike method calls, events don't return values to the producer. If a handler needs to communicate results, it does so by publishing its own event. This is a key difference from request/response patterns and requires a shift in how you design interactions.
Let's solidify our understanding with a detailed analogy. Imagine a large office building where different departments need to coordinate.
The Command-Oriented Office:
In this office, whenever something happens, the responsible person must personally notify everyone who needs to know:
This office quickly becomes chaotic. HR spends more time walking around than hiring. Changes are difficult—if a new department needs to be notified, HR's checklist grows.
The Event-Driven Office:
Now imagine the office has a bulletin board system:
HR posts once and is done. They don't know or care which departments are subscribed. If a new department (say, Parking) needs to be notified, they simply subscribe to "New Employee Hired" bulletins. HR's process doesn't change at all.
| Aspect | Command-Oriented Office | Event-Driven Office |
|---|---|---|
| Communication | Personal visits to each department | Post notice on bulletin board |
| HR's knowledge | Must know every department to notify | Knows only about the bulletin board |
| Adding new recipients | Change HR's checklist and training | New department subscribes to board |
| Department unavailable | HR waits or retries | Department catches up when available |
| Failure handling | HR must handle each failure case | Each department handles its own failures |
| Scalability | HR becomes bottleneck | Scales with number of boards, not HR's capacity |
This analogy captures the essence of event-driven design's benefits:
Before we conclude, let's address common misconceptions that can lead to misuse or avoidance of event-driven patterns.
Event-driven design is a tool, not a religion. Use it where the benefits (decoupling, extensibility, resilience) outweigh the costs (indirection, complexity). Later modules will explore decision frameworks for when events are—and aren't—appropriate.
We've established the foundational concepts of event-driven design. Let's consolidate what we've learned:
What's next:
Now that we understand what events are, we need to distinguish them from a closely related but fundamentally different concept: commands. The next page explores the critical distinction between events and commands—a distinction that profoundly affects how you design inter-component communication and maintain clean architectural boundaries.
You now understand what event-driven design is, its fundamental components, and how it differs from traditional command-oriented communication. You're ready to explore the nuanced distinction between events and commands, which is essential for applying event-driven patterns correctly.