Skip to main content

Command Palette

Search for a command to run...

Event-Driven Architecture: Decoupling Microservices

Event sourcing and CQRS patterns with Kafka and EventBridge

Published
7 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Content Role: pillar

Event-Driven Architecture: Decoupling Microservices

Event sourcing and CQRS patterns with Kafka and EventBridge

Microservices architectures promise independent deployability and scalability, but synchronous HTTP-based communication creates tight coupling that undermines these benefits. When Service A directly calls Service B, you've created a distributed monolith—cascading failures, deployment dependencies, and performance bottlenecks follow inevitably.

Event-driven architecture patterns solve this by inverting the dependency model. Services emit events about state changes without knowing who consumes them. Consumers react to events without knowing their origin. This fundamental shift enables true service autonomy, but introduces complexity around consistency, ordering, and observability that demands careful architectural decisions.

Why Request-Response Patterns Break Down at Scale

Traditional REST APIs work well for simple CRUD operations, but three critical problems emerge in distributed systems:

Temporal coupling: Service A must wait for Service B's response, creating latency chains. A checkout flow calling inventory, payment, and shipping services sequentially can take seconds, with each service's p99 latency compounding.

Availability coupling: If Service B is down, Service A fails. Circuit breakers help, but don't solve the fundamental problem—you can't complete business operations when dependencies are unavailable.

Knowledge coupling: Service A must know Service B's API contract, deployment location, and authentication requirements. Adding a new consumer requires modifying producers, violating the Open-Closed Principle at the system level.

These issues aren't theoretical. In production systems handling millions of requests daily, synchronous dependencies create cascading failures during traffic spikes, deployment windows, and infrastructure issues.

Core Event-Driven Architecture Patterns

Event Notification Pattern

The simplest pattern: services emit lightweight notifications when state changes occur. Consumers fetch additional details if needed.

// Producer: Order Service
interface OrderCreatedEvent {
  eventId: string;
  eventType: 'order.created';
  timestamp: string;
  orderId: string;
  customerId: string;
  totalAmount: number;
}

class OrderService {
  async createOrder(orderData: CreateOrderDTO): Promise<Order> {
    const order = await this.repository.save(orderData);

    await this.eventBridge.publish({
      eventId: crypto.randomUUID(),
      eventType: 'order.created',
      timestamp: new Date().toISOString(),
      orderId: order.id,
      customerId: order.customerId,
      totalAmount: order.total
    });

    return order;
  }
}

// Consumer: Inventory Service
class InventoryEventHandler {
  async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
    // Fetch full order details if needed
    const orderItems = await this.orderClient.getOrderItems(event.orderId);
    await this.reserveInventory(orderItems);
  }
}

This pattern works well for loosely coupled systems where consumers need awareness but not immediate data transfer.

Event-Carried State Transfer

Events contain complete state snapshots, eliminating the need for consumers to query back to producers. This reduces coupling but increases event size and network overhead.

interface OrderCreatedEvent {
  eventId: string;
  eventType: 'order.created';
  timestamp: string;
  order: {
    id: string;
    customerId: string;
    items: Array<{
      productId: string;
      quantity: number;
      price: number;
    }>;
    shippingAddress: Address;
    totalAmount: number;
  };
}

class InventoryEventHandler {
  async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
    // No need to call back to order service
    await this.reserveInventory(event.order.items);
  }
}

Event Sourcing

Instead of storing current state, persist all state changes as an immutable event log. Current state is derived by replaying events.

// Event store implementation
interface Event {
  aggregateId: string;
  aggregateType: string;
  eventType: string;
  eventData: unknown;
  version: number;
  timestamp: string;
}

class EventStore {
  async appendEvent(event: Event): Promise<void> {
    await this.db.query(
      `INSERT INTO events (aggregate_id, aggregate_type, event_type, 
       event_data, version, timestamp) 
       VALUES ($1, $2, $3, $4, $5, $6)`,
      [event.aggregateId, event.aggregateType, event.eventType, 
       event.eventData, event.version, event.timestamp]
    );
  }

  async getEvents(aggregateId: string): Promise<Event[]> {
    const result = await this.db.query(
      `SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version`,
      [aggregateId]
    );
    return result.rows;
  }
}

// Aggregate reconstruction
class Order {
  private id: string;
  private items: OrderItem[] = [];
  private status: OrderStatus = 'pending';
  private version: number = 0;

  static async load(eventStore: EventStore, orderId: string): Promise<Order> {
    const events = await eventStore.getEvents(orderId);
    const order = new Order();

    for (const event of events) {
      order.apply(event);
    }

    return order;
  }

  private apply(event: Event): void {
    switch (event.eventType) {
      case 'OrderCreated':
        this.id = event.aggregateId;
        this.items = event.eventData.items;
        break;
      case 'OrderShipped':
        this.status = 'shipped';
        break;
    }
    this.version = event.version;
  }
}

Event sourcing provides complete audit trails and temporal queries but requires careful schema evolution and snapshot strategies for performance.

CQRS (Command Query Responsibility Segregation)

Separate write models (commands) from read models (queries). Commands modify state and emit events. Read models subscribe to events and maintain optimized query structures.

// Write side: Command handler
class CreateOrderCommandHandler {
  async handle(command: CreateOrderCommand): Promise<void> {
    const order = Order.create(command);
    const events = order.getUncommittedEvents();

    await this.eventStore.appendEvents(events);
    await this.eventBus.publish(events);
  }
}

// Read side: Projection
class OrderSummaryProjection {
  async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
    await this.db.query(
      `INSERT INTO order_summary (order_id, customer_id, total, status)
       VALUES ($1, $2, $3, 'pending')`,
      [event.orderId, event.customerId, event.totalAmount]
    );
  }

  async handleOrderShipped(event: OrderShippedEvent): Promise<void> {
    await this.db.query(
      `UPDATE order_summary SET status = 'shipped', 
       shipped_at = $2 WHERE order_id = $1`,
      [event.orderId, event.shippedAt]
    );
  }
}

// Query handler uses read model
class GetOrderSummaryQueryHandler {
  async handle(query: GetOrderSummaryQuery): Promise<OrderSummary> {
    const result = await this.db.query(
      `SELECT * FROM order_summary WHERE order_id = $1`,
      [query.orderId]
    );
    return result.rows[0];
  }
}

CQRS enables independent scaling of reads and writes, optimized query models, and eventual consistency where appropriate.

Implementation with Kafka and EventBridge

Apache Kafka excels at high-throughput event streaming with strong ordering guarantees within partitions. Use Kafka when you need:

  • Event replay capabilities
  • Complex stream processing
  • High message volumes (millions per second)
  • Long-term event retention
import { Kafka } from 'kafkajs';

const kafka = new Kafka({
  clientId: 'order-service',
  brokers: ['kafka-1:9092', 'kafka-2:9092', 'kafka-3:9092']
});

const producer = kafka.producer();

await producer.send({
  topic: 'orders',
  messages: [{
    key: order.customerId, // Ensures ordering per customer
    value: JSON.stringify(event),
    headers: {
      'event-type': 'order.created',
      'correlation-id': correlationId
    }
  }]
});

AWS EventBridge provides serverless event routing with schema registry and cross-account delivery. Choose EventBridge for:

  • AWS-native architectures
  • Schema validation requirements
  • SaaS integration needs
  • Lower operational overhead
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';

const client = new EventBridgeClient({ region: 'us-east-1' });

await client.send(new PutEventsCommand({
  Entries: [{
    Source: 'order.service',
    DetailType: 'OrderCreated',
    Detail: JSON.stringify(event),
    EventBusName: 'application-events'
  }]
}));

Common Pitfalls and Solutions

Dual-write problem: Writing to a database and publishing an event in separate transactions creates inconsistency if one fails. Solution: Use the transactional outbox pattern—write events to an outbox table in the same transaction, then publish via a separate process.

async createOrder(orderData: CreateOrderDTO): Promise<void> {
  await this.db.transaction(async (trx) => {
    const order = await trx('orders').insert(orderData);
    await trx('outbox').insert({
      aggregate_id: order.id,
      event_type: 'order.created',
      event_data: JSON.stringify(event),
      created_at: new Date()
    });
  });
}

Event ordering: Distributed systems don't guarantee global ordering. Solution: Partition events by aggregate ID (Kafka) or use sequence numbers and handle out-of-order delivery in consumers.

Schema evolution: Event schemas change over time. Solution: Use schema versioning, maintain backward compatibility, and implement upcasting in consumers.

Duplicate events: At-least-once delivery guarantees mean duplicates happen. Solution: Make consumers idempotent using deduplication keys or natural idempotency in business logic.

Best Practices Checklist

  • Use correlation IDs to trace events across service boundaries
  • Implement dead-letter queues for failed event processing
  • Version your event schemas explicitly in event metadata
  • Monitor event lag to detect processing bottlenecks
  • Set retention policies based on replay and compliance requirements
  • Implement circuit breakers for downstream dependencies in event handlers
  • Use structured logging with event IDs for debugging
  • Test with chaos engineering to validate resilience
  • Document event contracts in a schema registry
  • Implement health checks that verify event processing

Frequently Asked Questions

When should I use event-driven architecture instead of REST APIs?

Use events for asynchronous workflows, cross-service notifications, and audit requirements. Use REST for synchronous queries, external APIs, and simple CRUD operations. Many systems benefit from both—commands via REST, state changes via events.

How do I handle transactions across multiple services?

Implement the Saga pattern—coordinate distributed transactions through choreography (services react to events) or orchestration (a coordinator manages the workflow). Each step is a local transaction with compensating actions for rollback.

What's the difference between event sourcing and event-driven architecture?

Event-driven architecture uses events for communication between services. Event sourcing stores events as the source of truth for application state. You can use event-driven architecture without event sourcing, but event sourcing naturally produces events for distribution.

How do I query data in an event-sourced system?

Build read models (projections) that subscribe to events and maintain query-optimized data structures. Use CQRS to separate write operations (event sourcing) from read operations (projections).

What happens if event processing fails?

Implement retry logic with exponential backoff, dead-letter queues for poison messages, and alerting for processing failures. Design idempotent handlers so retries don't cause duplicate side effects.

How do I test event-driven systems?

Use contract testing for event schemas, integration tests with test containers running Kafka/EventBridge, and consumer-driven contracts to prevent breaking changes. Test failure scenarios explicitly.

Should I use Kafka or EventBridge?

Choose Kafka for high-throughput streaming, complex event processing, and on-premises deployments. Choose EventBridge for AWS-native architectures, simpler operational requirements, and SaaS integrations. Both can coexist—use EventBridge for application events and Kafka for data streaming.