Loading learning content...
Consider two fundamentally different ways to design a system:
The Command Approach (Traditional):
"Order Service, please tell the Inventory Service to reserve items, then tell the Payment Service to charge the customer, then tell the Email Service to send a confirmation, then tell the Analytics Service to record this order."
The Event Approach (Event-Driven):
"An order was created. Here are the facts about what happened."
In the first approach, the Order Service is a commander—it knows about every downstream system and orchestrates their behavior. It holds a map of the entire system in its head.
In the second approach, the Order Service is a narrator—it simply announces what happened. It has no idea who's listening or what they'll do with the information. It publishes a fact about the world: "OrderCreated."
This shift—from commands to facts, from orchestration to choreography, from request-response to event-driven—is one of the most profound architectural transformations in modern software engineering. And it's only possible with asynchronous communication.
By the end of this page, you will understand event-driven architecture as a paradigm, how it differs from traditional request-response patterns, and the powerful capabilities it enables—from real-time data integration to temporal decoupling to system evolvability.
Event-Driven Architecture (EDA) is a design paradigm where the flow of the program is determined by events—significant changes in state or occurrences within the system. Instead of services directly calling each other, they communicate through events: publishing when something happens and subscribing to events they care about.
Core Concepts:
| Aspect | Request-Response | Event-Driven |
|---|---|---|
| Communication | Direct, synchronous calls | Indirect, asynchronous events |
| Coupling | High (caller knows callee) | Low (producer doesn't know consumers) |
| Control Flow | Caller controls flow | Each participant controls its own flow |
| Knowledge | Caller knows what needs to happen | Publisher knows what happened, not what will happen |
| Extensibility | Add new steps = modify caller | Add new steps = add new subscriber |
| Temporal Binding | Synchronous (same time) | Asynchronous (different times) |
| Failure Handling | Caller handles callee failures | Each consumer handles its own failures |
| Data Flow | Commands: 'Do this' | Facts: 'This happened' |
The Inversion of Control:
In traditional architectures, calling services control the behavior of called services. The Order Service tells the Inventory Service what to do.
In event-driven architectures, this control is inverted. The Order Service announces what happened. Each downstream service decides independently how to respond. The Inventory Service decides to reserve items when it sees an OrderCreated event. The Email Service decides to send a confirmation. The Analytics Service decides to record the order.
This inversion has profound implications:
Stop thinking about 'what I need to make happen' and start thinking about 'what facts I'm announcing.' Instead of 'I need to reserve inventory,' think 'An order was created—inventory reservation is someone else's concern.' This mental shift is the key to event-driven thinking.
Not all events are the same. Understanding event types helps you design effective event-driven systems.
Domain Events: Facts About Business Activity
Domain events represent significant occurrences in your business domain. They're named in past tense (the thing already happened) and carry information about what occurred.
Characteristics:
OrderCreated, PaymentCompleted, UserRegisteredExample Domain Events:
interface OrderCreatedEvent {
eventType: 'OrderCreated';
eventId: string;
timestamp: Date;
payload: {
orderId: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
price: number;
}>;
totalAmount: number;
shippingAddress: Address;
};
}
interface PaymentCompletedEvent {
eventType: 'PaymentCompleted';
eventId: string;
timestamp: Date;
payload: {
paymentId: string;
orderId: string;
amount: number;
paymentMethod: string;
transactionId: string;
};
}
Design Principle: Domain events should answer "What happened?" not "What should happen next?"
Event-driven systems use two primary patterns for coordinating work across services: choreography and orchestration. Understanding when to use each is crucial for effective design.
Choreography: Decentralized Coordination
In choreography, there's no central coordinator. Each service knows what events to listen for and what events to produce. The overall workflow emerges from the independent behaviors of services.
How It Works:
OrderCreatedOrderCreated → reserves items → publishes InventoryReservedInventoryReserved → charges customer → publishes PaymentCompletedPaymentCompleted → schedules pickup → publishes ShipmentScheduledNo service knows the full workflow. Each just reacts to relevant events.
Orchestration: Centralized Coordination
In orchestration, a central orchestrator service coordinates the workflow. It knows the full sequence of steps and commands other services.
How It Works:
OrderFulfilledThe orchestrator knows the full workflow and controls execution.
| Aspect | Choreography | Orchestration |
|---|---|---|
| Coupling | Low (services don't know each other) | Medium (orchestrator knows all services) |
| Visibility | Low (workflow is implicit) | High (workflow is explicit in orchestrator) |
| Adding Services | Add subscriber, no changes elsewhere | Modify orchestrator |
| Debugging | Harder (distributed flow) | Easier (single point to trace) |
| Single Point of Failure | None | Orchestrator is critical |
| Best For | Simple flows, high autonomy needs | Complex flows, visibility needs |
| Failure Handling | Each service handles its failures | Orchestrator handles failures centrally |
| Compensating Actions | Distributed (each service) | Centralized (orchestrator manages) |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Choreography: Each service reacts to events independently class OrderService { async createOrder(request: CreateOrderRequest): Promise<Order> { const order = await this.orderRepo.create(request); // Just announce what happened await this.eventBus.publish({ type: 'OrderCreated', payload: { orderId: order.id, items: order.items } }); return order; // Order Service has no idea what happens next }} class InventoryService { async start() { this.eventBus.subscribe('OrderCreated', async (event) => { await this.reserveItems(event.payload.items); await this.eventBus.publish({ type: 'InventoryReserved', payload: { orderId: event.payload.orderId } }); }); }} class PaymentService { async start() { // React to inventory being reserved, not order creation this.eventBus.subscribe('InventoryReserved', async (event) => { const order = await this.orderService.getOrder(event.payload.orderId); await this.chargeCustomer(order.customerId, order.totalAmount); await this.eventBus.publish({ type: 'PaymentCompleted', payload: { orderId: event.payload.orderId } }); }); }} // Adding email notifications? Just subscribe a new service.// No changes to Order, Inventory, or Payment services.Use choreography when services are truly independent and the workflow is simple. Use orchestration when you need visibility into complex workflows or centralized compensation logic. Many systems use both: choreography for cross-domain communication, orchestration within domains for complex multi-step processes.
Event Sourcing takes event-driven architecture to its logical conclusion: instead of storing current state, store the sequence of events that led to that state. The current state is derived by replaying events.
Traditional State Storage vs. Event Sourcing:
Traditional: Store Current State
ORDERS Table:
| id | status | total |
|-----|-----------|--------|
| 123 | shipped | 99.00 |
The order was created, then paid, then shipped. But the database only shows final state. History is lost.
Event Sourcing: Store Events
ORDER_EVENTS Table:
| id | event_type | data |
|----|---------------|---------------|
| 1 | OrderCreated | {total: 99} |
| 2 | PaymentReceived| {amount: 99} |
| 3 | OrderShipped | {tracking: X} |
Full history preserved. Replay to get any state at any time.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Event Sourcing: Building state from events // Define eventstype OrderEvent = | { type: 'OrderCreated'; orderId: string; customerId: string; items: OrderItem[] } | { type: 'PaymentReceived'; orderId: string; amount: number } | { type: 'OrderShipped'; orderId: string; trackingNumber: string } | { type: 'OrderCancelled'; orderId: string; reason: string }; // Current state (derived, not stored)interface OrderState { orderId: string; customerId: string; items: OrderItem[]; status: 'pending' | 'paid' | 'shipped' | 'cancelled'; totalPaid: number; trackingNumber?: string;} // State is built by replaying eventsfunction buildOrderState(events: OrderEvent[]): OrderState | null { let state: OrderState | null = null; for (const event of events) { switch (event.type) { case 'OrderCreated': state = { orderId: event.orderId, customerId: event.customerId, items: event.items, status: 'pending', totalPaid: 0, }; break; case 'PaymentReceived': if (state) { state.totalPaid += event.amount; state.status = 'paid'; } break; case 'OrderShipped': if (state) { state.status = 'shipped'; state.trackingNumber = event.trackingNumber; } break; case 'OrderCancelled': if (state) { state.status = 'cancelled'; } break; } } return state;} // Usage: Get current state of any orderasync function getOrderState(orderId: string): Promise<OrderState | null> { const events = await eventStore.getEvents(orderId); return buildOrderState(events);} // Temporal query: What was the state at a specific time?async function getOrderStateAt(orderId: string, timestamp: Date): Promise<OrderState | null> { const events = await eventStore.getEvents(orderId, { before: timestamp }); return buildOrderState(events);}Event sourcing adds significant complexity: event versioning, snapshotting for performance, eventual consistency, and CQRS patterns. It's powerful for domains requiring audit trails or temporal queries (finance, healthcare, logistics) but overkill for simple CRUD applications. Adopt carefully.
CQRS separates read and write operations into different models. Commands (writes) go to one model optimized for writes; Queries (reads) go to another model optimized for reads. Events connect them.
Why CQRS?
Traditional systems use the same model for reads and writes. This creates tension:
CQRS resolves this by using separate models for each concern.
12345678910111213141516171819202122232425262728
┌─────────────────────────────────────────────────────────────────┐│ CQRS Architecture │├─────────────────────────────────────────────────────────────────┤│ ││ ┌─────────────┐ ┌─────────────┐ ││ │ Client │ │ Client │ ││ │ (Write) │ │ (Read) │ ││ └──────┬──────┘ └──────┬──────┘ ││ │ │ ││ ▼ ▼ ││ ┌─────────────┐ ┌─────────────┐ ││ │ Command │ │ Query │ ││ │ Handler │ │ Handler │ ││ └──────┬──────┘ └──────┬──────┘ ││ │ │ ││ ▼ ▼ ││ ┌─────────────┐ ┌─────────────┐ ││ │ Write │ ┌─────────┐ │ Read │ ││ │ Model │─────▶│ Events │───────▶│ Model │ ││ │ (Postgres) │ └─────────┘ │(Elasticsearch) ││ └─────────────┘ │ └─────────────┘ ││ │ ││ ▼ ││ ┌─────────────┐ ││ │ Projector │ (builds read models) ││ └─────────────┘ ││ │└─────────────────────────────────────────────────────────────────┘| Use Case | Write Model | Read Model | Benefit |
|---|---|---|---|
| Order Search | PostgreSQL (normalized) | Elasticsearch (denormalized) | Sub-second full-text search |
| Dashboard Aggregates | Event Store | Pre-aggregated Redis hashes | Instant dashboard loads |
| Reporting | Transactional DB | Data Warehouse (Snowflake) | Complex analytics without prod impact |
| Mobile API | Full entity model | GraphQL cache (Apollo) | Tailored responses, reduced latency |
CQRS read models are eventually consistent—there's a delay between a write and when it appears in read models. This is usually milliseconds to seconds. For most use cases, this is acceptable. For operations requiring immediate consistency (e.g., checking balance before withdrawal), query the write model directly.
Event-driven architecture enables real-time data integration across systems that would traditionally batch data overnight. As events flow through the system, all interested parties receive updates immediately.
1234567891011121314151617181920212223242526272829303132333435363738
# Debezium CDC Configuration# Captures PostgreSQL changes and publishes to Kafka apiVersion: kafka.strimzi.io/v1beta2kind: KafkaConnectmetadata: name: debezium-connectspec: version: 3.4.0 replicas: 3 bootstrapServers: kafka-cluster:9092 config: group.id: debezium-connect key.converter: org.apache.kafka.connect.json.JsonConverter value.converter: org.apache.kafka.connect.json.JsonConverter ---# Connector configuration{ "name": "orders-connector", "config": { "connector.class": "io.debezium.connector.postgresql.PostgresConnector", "database.hostname": "orders-db.internal", "database.port": "5432", "database.user": "debezium", "database.dbname": "orders", "table.include.list": "public.orders,public.order_items", "topic.prefix": "orders", "slot.name": "debezium_orders", # Transformation to CloudEvents format "transforms": "unwrap", "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState" }} # Result: Every INSERT/UPDATE/DELETE on orders table# instantly published to 'orders.public.orders' Kafka topicEvent-driven integration transforms batch processing into stream processing. Instead of 'update everything nightly,' you 'update each thing as it changes.' This shift reduces data staleness from hours to seconds and eliminates the operational burden of managing batch job schedules.
One of the most powerful but underappreciated benefits of event-driven architecture is evolvability—the ability to add, modify, and remove system components without disrupting existing functionality.
Example: Adding a Feature Without Touching Existing Code
Scenario: Marketing wants to send personalized product recommendations after each order. In a traditional architecture:
In event-driven architecture:
OrderCreated eventsOrder Service is untouched. No risk to existing order flow. Recommendation Service can be slow, crash, or be removed—orders continue unaffected.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Evolution: Adding new capability through events // Existing Order Service - UNCHANGED for new featureclass OrderService { async createOrder(request: CreateOrderRequest): Promise<Order> { const order = await this.orderRepo.create(request); await this.eventBus.publish({ type: 'OrderCreated', payload: { orderId: order.id, customerId: order.customerId, items: order.items } }); return order; // This code existed before Recommendation Service // This code exists after Recommendation Service // Zero changes required }} // NEW: Recommendation Service - added without touching Order Serviceclass RecommendationService { async start() { // Subscribe to existing event this.eventBus.subscribe('OrderCreated', async (event) => { // Analyze order patterns const recommendations = await this.generateRecommendations( event.payload.customerId, event.payload.items ); // Store for next visit await this.recommendationStore.save( event.payload.customerId, recommendations ); // Optionally email recommendations await this.emailService.sendRecommendations( event.payload.customerId, recommendations ); }); }} // Future: Add more consumers without touching anything// - Loyalty Points Service: awards points on OrderCreated// - Inventory Forecast Service: updates demand predictions// - Customer 360 Service: builds unified customer profile// Each is independent, added by subscription, no producer changesEvent-driven architecture scales not just systems, but organizations. Teams can work independently. New features don't require cross-team coordination (beyond event schema agreements). This reduces time-to-market and enables parallel development across many teams.
Event-driven architecture represents a paradigm shift from command-based to fact-based communication. This shift, enabled by asynchronous messaging, unlocks capabilities impossible with traditional request-response patterns. Let's consolidate the key insights:
Module Complete: Why Asynchronous?
Across this module, we've explored four compelling reasons for asynchronous communication:
These aren't independent benefits—they compound. Decoupled systems handle spikes better. Resilient systems enable event-driven patterns. Event-driven architectures naturally decouple components. Together, they form the foundation for building systems that scale, survive failures, and evolve gracefully.
In the next module, we'll dive deep into Message Queues—the infrastructure that makes all this possible.
You now understand the four fundamental reasons for asynchronous communication: decoupling, spike handling, resilience, and event-driven architecture. These concepts form the theoretical foundation for everything that follows in asynchronous system design.