Event-Driven Architecture: Connecting Services with Async Messages
1. The Synchronous Trap
When teams first adopt microservices, they usually start here:
Order Service → HTTP → Inventory Service
Order Service → HTTP → Payment Service
Order Service → HTTP → Notification Service
Straightforward. Order comes in, check inventory, process payment, send notification. Simple and explicit.
But as the system grows, this falls apart.
Scenario: The order service calls inventory (200ms), then payment (500ms), then notification (100ms). Total response time: 800ms. Now the notification service crashes:
Order Service: POST /notifications
Notification Service: 503 Service Unavailable
Order Service: 😱 Fail the entire order?
Notification isn't business-critical, but the whole order fails because of it. That's wrong.
This is the fundamental problem with synchronous communication:
- Temporal coupling: Both services must be alive at the same time
- Cascading failures: One service going down can take others down too
- Additive latency: Response times stack up
2. What Event-Driven Architecture Is
EDA replaces synchronous request-response with event publishing and subscribing.
Key concepts:
- Producer: The service that emits events
- Event: A message describing something that happened
- Message Broker: The intermediary that receives and delivers events (Kafka, RabbitMQ, SQS)
- Consumer: The service that subscribes to and processes events
Order Service → [OrderPlaced event] → Message Broker
↓
Inventory Service (subscriber)
Payment Service (subscriber)
Notification Service (subscriber)
The order service no longer needs to know about inventory, payment, or notification services. It just announces: "an order was placed." Done.
If the notification service crashes? The order service doesn't know or care. The broker will redeliver when it comes back up.
3. Event Types: Domain Events vs Integration Events
Domain Events
Facts that happen within the same service (bounded context).
interface OrderPlacedDomainEvent {
type: 'ORDER_PLACED';
orderId: string;
userId: string;
items: OrderItem[];
totalAmount: number;
placedAt: Date;
}
class Order {
private domainEvents: DomainEvent[] = [];
place(): void {
if (this.status !== 'PENDING') {
throw new Error('Order has already been processed');
}
this.status = 'PLACED';
this.domainEvents.push({
type: 'ORDER_PLACED',
orderId: this.id,
userId: this.userId,
items: this.items,
totalAmount: this.totalAmount,
placedAt: new Date(),
});
}
pullDomainEvents(): DomainEvent[] {
const events = [...this.domainEvents];
this.domainEvents = [];
return events;
}
}
Integration Events
Events that cross service boundaries — contracts with other teams.
interface OrderPlacedIntegrationEvent {
eventId: string; // For idempotency
eventType: 'order.placed';
occurredAt: string; // ISO 8601
version: '1.0';
payload: {
orderId: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
unitPrice: number;
}>;
totalAmount: number;
};
}
Keep them separate because:
- Domain events can expose internal details freely
- Integration events are contracts — changing them breaks consumers
- Internal domain model refactoring shouldn't automatically change external event schemas
4. Broker Comparison: Kafka vs RabbitMQ vs SQS
Apache Kafka
Characteristics:
- Distributed log-based streaming platform
- Messages are not deleted after consumption (retained until the configured period)
- Consumers manage their own offset — replay past messages at any time
- Millions of messages per second throughput
Best for:
- Event sourcing, audit logs
- Real-time streaming analytics
- Large-scale data pipelines
- Multiple consumer groups processing the same events independently
RabbitMQ
Characteristics:
- AMQP protocol-based message broker
- Flexible routing via Exchanges, Queues, Bindings
- Messages typically deleted after consumption
- Complex routing patterns: Direct, Topic, Fanout, Headers
Best for:
- Task queues, worker patterns
- Complex message routing requirements
- Message priority support
- RPC-style patterns
AWS SQS
Characteristics:
- Fully managed — no infrastructure to operate
- Standard Queue vs FIFO Queue
- At-least-once delivery (Standard allows duplicates)
- Combine with SNS for fan-out patterns
Best for:
- AWS-native infrastructure
- Simple async task processing
- Lambda integrations
| Aspect | Kafka | RabbitMQ | SQS |
|---|
| Throughput | Highest | High | High |
| Message retention | Long-term (7 days default) | Deleted after consumption | Up to 14 days |
| Ordering | Within partition | Within queue | FIFO queue only |
| Reprocessing | Easy (adjust offset) | Difficult | Dead Letter Queue |
| Operational complexity | High | Medium | Low (managed) |
| Best scale | Large | Medium | Small-medium |
5. Practical Node.js Example: Kafka for Order Processing
// order-service/OrderEventPublisher.ts
import { Kafka, Producer } from 'kafkajs';
import { v4 as uuidv4 } from 'uuid';
class OrderEventPublisher {
private producer: Producer;
constructor(kafka: Kafka) {
this.producer = kafka.producer();
}
async connect(): Promise<void> {
await this.producer.connect();
}
async publishOrderPlaced(order: Order): Promise<void> {
const event: OrderPlacedIntegrationEvent = {
eventId: uuidv4(),
occurredAt: new Date().toISOString(),
version: '1.0',
eventType: 'order.placed',
payload: {
orderId: order.id,
customerId: order.customerId,
items: order.items,
totalAmount: order.totalAmount,
},
};
await this.producer.send({
topic: 'order-events',
messages: [
{
key: order.id, // Same orderId → same partition (ordering guarantee)
value: JSON.stringify(event),
headers: { 'event-type': 'order.placed' },
},
],
});
}
}
// inventory-service/InventoryEventConsumer.ts
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
class InventoryEventConsumer {
private consumer: Consumer;
constructor(kafka: Kafka, private inventoryService: InventoryService) {
this.consumer = kafka.consumer({ groupId: 'inventory-service' });
}
async start(): Promise<void> {
await this.consumer.connect();
await this.consumer.subscribe({ topic: 'order-events', fromBeginning: false });
await this.consumer.run({
eachMessage: async (payload: EachMessagePayload) => {
const eventType = payload.message.headers?.['event-type']?.toString();
if (eventType !== 'order.placed') return;
const event: OrderPlacedIntegrationEvent = JSON.parse(
payload.message.value!.toString()
);
try {
await this.inventoryService.decreaseStock(event.payload.items);
} catch (error) {
// Dead letter topic or compensating event
throw error; // Let Kafka retry
}
},
});
}
}
6. Idempotency: Defending Against Duplicate Delivery
Most brokers guarantee at-least-once delivery. The same message can arrive twice. Your consumer must be idempotent.
class IdempotentInventoryConsumer {
constructor(
private inventoryService: InventoryService,
private processedEvents: ProcessedEventRepository, // Redis or DB
) {}
async handleOrderPlaced(event: OrderPlacedIntegrationEvent): Promise<void> {
const alreadyProcessed = await this.processedEvents.exists(event.eventId);
if (alreadyProcessed) {
console.log(`Skipping already-processed event: ${event.eventId}`);
return;
}
await this.processedEvents.markAsProcessing(event.eventId);
try {
await this.inventoryService.decreaseStock(event.payload.items);
await this.processedEvents.markAsCompleted(event.eventId);
} catch (error) {
await this.processedEvents.markAsFailed(event.eventId);
throw error;
}
}
}
7. Event Sourcing Basics
Traditional systems store current state. Event sourcing treats the event log itself as the source of truth.
Traditional: Store current state
orders table: { id: '123', status: 'SHIPPED', totalAmount: 50000 }
Event sourcing: Store event log
order_events:
{ eventId: '1', orderId: '123', type: 'ORDER_PLACED', data: {...} }
{ eventId: '2', orderId: '123', type: 'PAYMENT_CONFIRMED', data: {...} }
{ eventId: '3', orderId: '123', type: 'ORDER_SHIPPED', data: {...} }
To get current state, replay all events.
class OrderAggregate {
id: string = '';
status: string = 'PENDING';
totalAmount: number = 0;
static rehydrate(events: OrderEvent[]): OrderAggregate {
const order = new OrderAggregate();
for (const event of events) {
order.apply(event);
}
return order;
}
private apply(event: OrderEvent): void {
switch (event.type) {
case 'ORDER_PLACED':
this.id = event.orderId;
this.status = 'PLACED';
this.totalAmount = event.data.totalAmount as number;
break;
case 'PAYMENT_CONFIRMED':
this.status = 'PAID';
break;
case 'ORDER_SHIPPED':
this.status = 'SHIPPED';
break;
}
}
}
Benefits of event sourcing:
- Complete audit log, always
- Query state at any past point in time (time travel)
- Build new projections (read models) by replaying events
- Easy bug reproduction
Watch out for:
- Schema evolution is hard — old event formats must remain supported forever
- Current state queries can be slow — use snapshots with CQRS
8. Eventual Consistency
With EDA, you give up immediate consistency in exchange for eventual consistency.
User: Order placed!
Order Service: publishes OrderPlaced → responds immediately to user
Inventory Service: (100ms later) processes event → reduces stock
Notification Service: (200ms later) processes event → sends email
For those 100-200ms, inventory hasn't been reduced yet. That's eventual consistency.
When it causes problems:
// Compensating transaction pattern
class InventoryConsumer {
async handleOrderPlaced(event: OrderPlacedIntegrationEvent): Promise<void> {
try {
await this.inventoryService.reserveStock(event.payload.items);
} catch (error) {
if (error instanceof InsufficientStockError) {
// Publish compensating event to cancel the order
await this.eventPublisher.publish({
eventType: 'inventory.reservation.failed',
payload: {
orderId: event.payload.orderId,
reason: 'INSUFFICIENT_STOCK',
},
});
}
}
}
}
This is the Saga pattern — handling distributed transactions through events and compensating actions.
9. When to Use EDA vs Synchronous Calls
You don't need to convert every service interaction to events.
Use event-driven when:
- Results don't need to be awaited (notifications, logs, analytics updates)
- Multiple services react to the same event
- Producers and consumers need independent deployment
- Traffic is bursty and you need buffering
Use synchronous calls when:
- You need an answer back (queries: "is this item in stock?")
- The transaction boundary sits within a single service
- Real-time response is required (show payment result immediately)
- Team size is small and EDA complexity isn't worth it yet
Closing Thought
EDA in one sentence:
"Producers announce what happened, consumers listen and react independently."
That's the biggest shift from synchronous calling. The producer doesn't know the consumer, doesn't wait for it.
In exchange, you accept complexity: broker operations, idempotency handling, eventual consistency debugging, Saga patterns. Be ready for that before adopting it.
Start small. Extract a non-critical flow (notifications, emails) to event-driven first. Get comfortable. Then expand to more critical paths.