Event-Driven Architecture: Patterns and Best Practices
Event-driven architecture (EDA) is a powerful pattern for building decoupled, scalable systems. Instead of services calling each other directly, they communicate through events. After building event-driven systems that handle millions of events daily, I've learned what works and what doesn't.
What is Event-Driven Architecture?
Traditional Request-Response
Service A → HTTP Call → Service B
← Response ←
Problems:
- Tight coupling
- Synchronous blocking
- Cascading failures
- Hard to scale
Event-Driven
Service A → Event → Message Broker → Event → Service B
↓
Service C
Benefits:
- Loose coupling
- Asynchronous
- Fault tolerant
- Scalable
Core Concepts
Events
An event is something that happened:
{
type: 'OrderCreated',
data: {
orderId: '123',
userId: '456',
total: 99.99,
timestamp: '2025-08-18T10:00:00Z'
}
}
Event Producers
Services that publish events:
class OrderService {
async createOrder(orderData) {
const order = await this.saveOrder(orderData);
// Publish event
await eventBus.publish('OrderCreated', {
orderId: order.id,
userId: order.userId,
total: order.total,
items: order.items
});
return order;
}
}
Event Consumers
Services that react to events:
class InventoryService {
constructor() {
eventBus.subscribe('OrderCreated', this.handleOrderCreated.bind(this));
}
async handleOrderCreated(event) {
const { orderId, items } = event.data;
for (const item of items) {
await this.reserveInventory(item.productId, item.quantity);
}
// Publish new event
await eventBus.publish('InventoryReserved', {
orderId,
reserved: true
});
}
}
Event Patterns
1. Event Notification
Simple notification that something happened:
// Order service publishes
await eventBus.publish('OrderCreated', { orderId: '123' });
// Multiple services react
// - Email service sends confirmation
// - Analytics service tracks order
// - Inventory service reserves items
2. Event-Carried State Transfer
Event contains all needed data:
await eventBus.publish('OrderCreated', {
orderId: '123',
userId: '456',
userEmail: 'user@example.com',
items: [
{ productId: '789', quantity: 2, price: 49.99 }
],
total: 99.98,
shippingAddress: { ... }
});
3. Event Sourcing
Store all events, reconstruct state:
// Store events
eventStore.append('order-123', {
type: 'OrderCreated',
data: { ... }
});
eventStore.append('order-123', {
type: 'PaymentProcessed',
data: { ... }
});
// Reconstruct state
const order = eventStore.reconstruct('order-123');
Message Brokers
RabbitMQ
const amqp = require('amqplib');
// Publisher
async function publishEvent(event) {
const connection = await amqp.connect('amqp://localhost');
const channel = await connection.createChannel();
const exchange = 'events';
await channel.assertExchange(exchange, 'topic', { durable: true });
channel.publish(exchange, 'order.created', Buffer.from(JSON.stringify(event)), {
persistent: true
});
await channel.close();
await connection.close();
}
// Consumer
async function consumeEvents() {
const connection = await amqp.connect('amqp://localhost');
const channel = await connection.createChannel();
const exchange = 'events';
await channel.assertExchange(exchange, 'topic', { durable: true });
const queue = 'order-queue';
await channel.assertQueue(queue, { durable: true });
await channel.bindQueue(queue, exchange, 'order.*');
channel.consume(queue, (msg) => {
const event = JSON.parse(msg.content.toString());
handleEvent(event);
channel.ack(msg);
});
}
Apache Kafka
const kafka = require('kafkajs');
const client = kafka({
clientId: 'my-app',
brokers: ['localhost:9092']
});
// Producer
const producer = client.producer();
await producer.connect();
await producer.send({
topic: 'orders',
messages: [{
key: 'order-123',
value: JSON.stringify({
type: 'OrderCreated',
data: { ... }
})
}]
});
// Consumer
const consumer = client.consumer({ groupId: 'order-processors' });
await consumer.connect();
await consumer.subscribe({ topic: 'orders' });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
const event = JSON.parse(message.value.toString());
await handleEvent(event);
}
});
Redis Pub/Sub
const redis = require('redis');
// Publisher
const publisher = redis.createClient();
await publisher.connect();
await publisher.publish('events', JSON.stringify({
type: 'OrderCreated',
data: { ... }
}));
// Subscriber
const subscriber = redis.createClient();
await subscriber.connect();
await subscriber.subscribe('events', (message) => {
const event = JSON.parse(message);
handleEvent(event);
});
Event Design
Naming Conventions
// Good: Past tense, descriptive
'OrderCreated'
'PaymentProcessed'
'InventoryReserved'
'UserRegistered'
// Bad: Present tense, vague
'CreateOrder'
'Process'
'Update'
Event Schema
{
id: 'event-123', // Unique event ID
type: 'OrderCreated', // Event type
source: 'order-service', // Source service
timestamp: '2025-08-18T10:00:00Z', // When it happened
data: { // Event payload
orderId: '123',
userId: '456',
// ... other data
},
metadata: { // Additional context
correlationId: 'req-789',
causationId: 'event-456'
}
}
Handling Failures
Retry with Exponential Backoff
async function handleEventWithRetry(event, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await handleEvent(event);
return; // Success
} catch (error) {
if (attempt === maxRetries - 1) {
// Final attempt failed - send to dead letter queue
await sendToDeadLetterQueue(event, error);
throw error;
}
// Wait before retry
const delay = Math.pow(2, attempt) * 1000;
await sleep(delay);
}
}
}
Dead Letter Queue
async function sendToDeadLetterQueue(event, error) {
await eventBus.publish('dlq', {
originalEvent: event,
error: error.message,
timestamp: new Date().toISOString(),
retryCount: event.retryCount || 0
});
}
Idempotency
const processedEvents = new Set();
async function handleEvent(event) {
// Check if already processed
if (processedEvents.has(event.id)) {
console.log('Event already processed:', event.id);
return;
}
// Process event
await processEvent(event);
// Mark as processed
processedEvents.add(event.id);
}
Real-World Example
System: E-commerce platform
Event Flow:
1. OrderCreated
↓
2. InventoryReserved (Inventory Service)
↓
3. PaymentProcessed (Payment Service)
↓
4. OrderConfirmed (Order Service)
↓
5. EmailSent (Email Service)
↓
6. AnalyticsTracked (Analytics Service)
Implementation:
// Order Service
class OrderService {
async createOrder(data) {
const order = await this.saveOrder(data);
await eventBus.publish('OrderCreated', {
orderId: order.id,
userId: order.userId,
items: order.items,
total: order.total
});
return order;
}
}
// Inventory Service
class InventoryService {
async handleOrderCreated(event) {
const { orderId, items } = event.data;
await this.reserveItems(items);
await eventBus.publish('InventoryReserved', {
orderId,
reserved: true
});
}
}
// Payment Service
class PaymentService {
async handleInventoryReserved(event) {
const { orderId } = event.data;
const order = await this.getOrder(orderId);
const payment = await this.processPayment(order);
await eventBus.publish('PaymentProcessed', {
orderId,
paymentId: payment.id,
success: true
});
}
}
Best Practices
- Design events carefully - They're your API
- Use idempotency - Events may be delivered multiple times
- Handle failures gracefully - Retry, dead letter queues
- Monitor event flow - Know what's happening
- Version events - Allow evolution
- Keep events small - Don't send entire objects
- Use correlation IDs - Trace events through system
- Document event schema - Others need to understand
Common Pitfalls
1. Event Storming
Too many events, hard to understand flow.
Solution: Group related events, use event hierarchies.
2. Lost Events
Events not delivered, system inconsistent.
Solution: Use persistent message brokers, acknowledge events.
3. Event Ordering
Events processed out of order.
Solution: Use partitions, sequence numbers, or accept eventual consistency.
4. Tight Coupling Through Events
Events become API contracts.
Solution: Version events, design for change.
Conclusion
Event-driven architecture is powerful for building scalable, decoupled systems. The key is:
- Design events well - They're your contracts
- Choose the right broker - Based on your needs
- Handle failures - Retry, dead letter queues
- Monitor everything - Know what's happening
Remember: Events are asynchronous by nature. Design for eventual consistency and handle failures gracefully.
What event-driven architecture challenges have you faced? What patterns have worked best for your systems?
Related Posts
Microservices vs Monoliths: When to Choose What in 2024
A practical guide to choosing between microservices and monolithic architectures. Learn when each approach makes sense, common pitfalls, and how to make the right decision for your project.
System Design Patterns: Building Resilient Distributed Systems
Explore essential system design patterns for building distributed systems that are resilient, scalable, and maintainable. Learn from real-world implementations.
Serverless Architecture: When to Use and When to Avoid
A practical guide to serverless architecture. Learn when serverless makes sense, its trade-offs, and how to build effective serverless applications.
GraphQL vs REST: Making the Right API Choice in 2025
A comprehensive comparison of GraphQL and REST APIs in 2025. Learn when to use each approach, their trade-offs, and how to make the right decision for your project.
Data Modeling for Scalable Applications: Normalization vs Denormalization
Learn when to normalize and when to denormalize your database schema. Master the art of data modeling for applications that scale to millions of users.
Scaling Applications Horizontally: Strategies for Growth
Learn how to scale applications horizontally to handle millions of users. From load balancing to database sharding, master the techniques that enable growth.