Event-Driven Architecture: Patterns and Best Practices

Event-Driven Architecture: Patterns and Best Practices

BySanjay Goraniya
2 min read
Share:

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

Code
Service A → HTTP Call → Service B
          ← Response ←

Problems:

  • Tight coupling
  • Synchronous blocking
  • Cascading failures
  • Hard to scale

Event-Driven

Code
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:

Code
{
  type: 'OrderCreated',
  data: {
    orderId: '123',
    userId: '456',
    total: 99.99,
    timestamp: '2025-08-18T10:00:00Z'
  }
}

Event Producers

Services that publish events:

Code
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:

Code
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:

Code
// 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:

Code
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:

Code
// 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

Code
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

Code
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

Code
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

Code
// Good: Past tense, descriptive
'OrderCreated'
'PaymentProcessed'
'InventoryReserved'
'UserRegistered'

// Bad: Present tense, vague
'CreateOrder'
'Process'
'Update'

Event Schema

Code
{
  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

Code
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

Code
async function sendToDeadLetterQueue(event, error) {
  await eventBus.publish('dlq', {
    originalEvent: event,
    error: error.message,
    timestamp: new Date().toISOString(),
    retryCount: event.retryCount || 0
  });
}

Idempotency

Code
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:

Code
1. OrderCreated
   ↓
2. InventoryReserved (Inventory Service)
   ↓
3. PaymentProcessed (Payment Service)
   ↓
4. OrderConfirmed (Order Service)
   ↓
5. EmailSent (Email Service)
   ↓
6. AnalyticsTracked (Analytics Service)

Implementation:

Code
// 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

  1. Design events carefully - They're your API
  2. Use idempotency - Events may be delivered multiple times
  3. Handle failures gracefully - Retry, dead letter queues
  4. Monitor event flow - Know what's happening
  5. Version events - Allow evolution
  6. Keep events small - Don't send entire objects
  7. Use correlation IDs - Trace events through system
  8. 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?

Share:

Related Posts