Skip to content
Bitloops - Git captures what changed. Bitloops captures why.
HomeAbout usDocsBlog
ResourcesSoftware ArchitectureEvent-Driven Architecture: Decoupling with Events

Event-Driven Architecture: Decoupling with Events

Instead of Order Service calling Payment Service, it publishes OrderCreated and Payment Service listens. This decouples services, enables async processing, allows parallel reactions. But adds complexity: eventual consistency, event ordering, distributed failure modes.

9 min readUpdated March 4, 2026Software Architecture

Definition

Event-driven architecture treats events—"something happened"—as first-class architectural concepts. When an important business event occurs (order created, payment processed, user registered), the system publishes it. Other components listen and react.

This inverts typical request-response coupling. Instead of Order Service calling Payment Service ("charge this card"), Order Service publishes an OrderCreated event. Payment Service (and others) listen and react independently. In AI-native development, these service boundaries and event patterns must be encoded as architectural constraints that agents respect.

Core Concepts

Events: The Building Blocks

An event represents something that happened in the past. It's immutable, timestamped, and carries the minimum data needed for listeners to react.

// Domain Event: something important happened
class OrderCreatedEvent {
  readonly eventId: string;
  readonly aggregateId: string; // orderId
  readonly aggregateType: string = 'Order';
  readonly timestamp: Date;
  readonly version: number;

  readonly orderId: string;
  readonly customerId: string;
  readonly items: OrderItem[];
  readonly total: number;
  readonly currency: string;

  constructor(
    orderId: string,
    customerId: string,
    items: OrderItem[],
    total: number,
    currency: string
  ) {
    this.eventId = generateId();
    this.aggregateId = orderId;
    this.orderId = orderId;
    this.customerId = customerId;
    this.items = items;
    this.total = total;
    this.currency = currency;
    this.timestamp = new Date();
    this.version = 1;
  }

  // Events are serializable
  toJSON() {
    return {
      eventId: this.eventId,
      aggregateId: this.aggregateId,
      aggregateType: this.aggregateType,
      timestamp: this.timestamp.toISOString(),
      version: this.version,
      data: {
        orderId: this.orderId,
        customerId: this.customerId,
        items: this.items,
        total: this.total,
        currency: this.currency
      }
    };
  }
}
typescript

Events are typically grouped by domain concern: OrderCreated, OrderShipped, OrderCancelled. Each domain publishes its events.

Event Bus: The Message Channel

An event bus is the mechanism for publishing and subscribing to events. It can be in-memory, a message queue, or both.

// Event Bus: subscribers react to events
class EventBus {
  private subscribers: Map<string, ((event: any) => Promise<void>)[]> = new Map();

  subscribe(eventType: string, handler: (event: any) => Promise<void>): void {
    if (!this.subscribers.has(eventType)) {
      this.subscribers.set(eventType, []);
    }
    this.subscribers.get(eventType)!.push(handler);
  }

  async publish(event: any): Promise<void> {
    const eventType = event.constructor.name;
    const handlers = this.subscribers.get(eventType) || [];

    // Publish to all subscribers
    await Promise.all(handlers.map(handler => handler(event)));
  }
}

// Event Source: publishes events
class OrderService {
  constructor(
    private orderRepository: OrderRepository,
    private eventBus: EventBus
  ) {}

  async createOrder(request: CreateOrderRequest): Promise<Order> {
    const order = new Order(generateId(), request.customerId);
    order.addItems(request.items);
    await this.orderRepository.save(order);

    // Publish the event
    const event = new OrderCreatedEvent(
      order.id,
      order.customerId,
      order.items,
      order.total,
      'USD'
    );
    await this.eventBus.publish(event);

    return order;
  }
}

// Event Listener: reacts to events
class PaymentEventListener {
  constructor(
    private paymentService: PaymentService,
    private eventBus: EventBus
  ) {
    // Subscribe to OrderCreated events
    this.eventBus.subscribe('OrderCreatedEvent', (event: OrderCreatedEvent) =>
      this.onOrderCreated(event)
    );
  }

  private async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
    console.log(`Processing payment for order ${event.orderId}`);
    try {
      const result = await this.paymentService.charge(
        event.customerId,
        event.total,
        event.currency
      );
      if (result.success) {
        // Publish success event
        await this.eventBus.publish(
          new PaymentProcessedEvent(event.orderId, result.transactionId)
        );
      } else {
        // Publish failure event
        await this.eventBus.publish(
          new PaymentFailedEvent(event.orderId, result.error)
        );
      }
    } catch (error) {
      console.error('Payment processing failed', error);
      // Publish failure event
      await this.eventBus.publish(
        new PaymentFailedEvent(event.orderId, error.message)
      );
    }
  }
}

// Multiple listeners can react to the same event
class NotificationEventListener {
  constructor(
    private emailService: EmailService,
    private eventBus: EventBus
  ) {
    this.eventBus.subscribe('OrderCreatedEvent', (event: OrderCreatedEvent) =>
      this.onOrderCreated(event)
    );
  }

  private async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
    await this.emailService.sendConfirmation(event.customerId, event.orderId);
  }
}
typescript

One event, multiple listeners. Each listener is independent. If one fails, others still react.

Event Types: The Important Distinction

Domain Events

Events that represent something significant in the business domain. They're published by the domain model, listened to by application services.

// Domain Event: published by the domain
new OrderCreatedEvent(orderId, customerId, items, total);
new PaymentProcessedEvent(orderId, transactionId);
new InventoryReservedEvent(orderId, items);
typescript

Domain events are part of the ubiquitous language. They're named after business concepts, not technical operations.

Integration Events

Events published to external systems or services. These cross service boundaries and often require serialization/deserialization.

// Integration Event: sent to message queue, consumed by other services
interface IntegrationEvent {
  eventId: string;
  eventType: string;
  aggregateId: string;
  timestamp: string; // ISO 8601
  data: Record<string, any>;
}

// On publish:
const integrationEvent: IntegrationEvent = {
  eventId: event.eventId,
  eventType: 'order.created',
  aggregateId: event.orderId,
  timestamp: event.timestamp.toISOString(),
  data: {
    orderId: event.orderId,
    customerId: event.customerId,
    total: event.total,
    currency: event.currency
  }
};

await messageQueue.publish('orders', integrationEvent);
typescript

Integration events are often different from domain events. Domain events are in-memory objects, rich with behavior. Integration events are serialized messages, minimal data needed for other services to react.

Event Sourcing vs. Event-Driven Architecture

These are orthogonal concepts, often confused.

Event-driven architecture is about using events to decouple components. Services publish events, others listen. No events are stored, just reacted to.

Event sourcing is about storing the event log as the source of truth. Instead of storing the current state ("Order status is shipped"), you store the events that led to that state ("OrderCreated, PaymentProcessed, OrderShipped"). Reconstruct state by replaying events.

You can have event-driven without event sourcing (most systems). You can have event sourcing without event-driven (but it's less common). You can have both.

// Event-driven without event sourcing
class OrderService {
  async createOrder(request): Promise<Order> {
    const order = new Order(...);
    await this.orderRepository.save(order); // Save current state
    await this.eventBus.publish(new OrderCreatedEvent(...)); // Publish event
  }
}

// Event sourcing
class OrderService {
  async createOrder(request): Promise<Order> {
    const order = new Order(...);
    await this.eventStore.append(new OrderCreatedEvent(...)); // Save events
    // State is reconstructed from events
  }

  async getOrder(orderId): Promise<Order> {
    const events = await this.eventStore.getEvents(orderId);
    const order = new Order(orderId);
    events.forEach(event => order.apply(event));
    return order;
  }
}
typescript

Event sourcing is powerful for audit trails, temporal queries, and debugging ("how did we get to this state?"), but adds complexity. Event-driven architecture is simpler and more common.

CQRS: Commands and Queries

Command Query Responsibility Segregation (CQRS) often pairs with event-driven architecture.

Commands change state. They trigger events.

// Command: cause a change
class CreateOrderCommand {
  constructor(
    readonly customerId: string,
    readonly items: OrderItem[]
  ) {}
}

// Handler: processes command, publishes events
class CreateOrderCommandHandler {
  async handle(command: CreateOrderCommand): Promise<OrderId> {
    const order = new Order(generateId(), command.customerId);
    command.items.forEach(item => order.addItem(item));
    await this.orderRepository.save(order);
    await this.eventBus.publish(new OrderCreatedEvent(...));
    return order.id;
  }
}
typescript

Queries read state. They don't change anything.

// Query: read state
class GetOrderQuery {
  constructor(readonly orderId: string) {}
}

// Handler: reads state, returns data
class GetOrderQueryHandler {
  async handle(query: GetOrderQuery): Promise<OrderDTO> {
    const order = await this.orderRepository.findById(query.orderId);
    return {
      id: order.id,
      customerId: order.customerId,
      total: order.total,
      status: order.status
    };
  }
}
typescript

CQRS shines in event-driven systems where command handlers publish events and query handlers read eventually-consistent read models built from events. But it adds ceremony. Use it when you need temporal separation of reads and writes (scaling reads independently, building multiple denormalized views).

Handling Failure: The Hard Part

Events are simple conceptually but complex operationally. What happens when things go wrong?

Listener fails. Payment Service crashes while processing OrderCreated. The event is lost. Order exists, payment wasn't charged. Now you have an inconsistent state.

Solution: durable event bus. Instead of in-memory subscribers, use a message queue (RabbitMQ, Kafka). Events persist until consumed. Payment Service can restart and replay unconsumed events.

Duplicate processing. Payment Service processes OrderCreated, publishes PaymentProcessed. Then crashes before the event is marked consumed. It restarts, processes the same event again. Now you've charged twice.

Solution: idempotency. Design handlers to be idempotent—processing the same event twice has the same effect as processing it once. Use idempotency keys: a unique identifier for each operation to prevent duplicate processing.

// Idempotent handler
class PaymentEventListener {
  async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
    // Check if we've already processed this event by its unique ID
    const existing = await this.paymentRepository.findByEventId(event.eventId);
    if (existing) {
      return; // Already processed, nothing to do
    }

    // Process payment
    const result = await this.paymentService.charge(...);

    // Store the payment with idempotency key
    await this.paymentRepository.save({
      orderId: event.orderId,
      eventId: event.eventId,
      transactionId: result.transactionId
    });
  }
}
javascript

Eventual consistency confusion. Order is created and returned to the user. They check their email. No confirmation email yet because the notification listener is still processing. They think something broke.

Solution: clear expectations. UI should say "Order created. Confirmation email will arrive shortly." Async operations need user visibility.

Event versioning. OrderCreatedEvent originally had no items field. Now it does. Old handlers don't expect it, new handlers require it. Systems diverge.

Solution: version events. Support multiple versions in parallel during transition. Migrate listeners.

// V1 event (old)
class OrderCreatedEventV1 {
  constructor(readonly orderId: string, readonly customerId: string, readonly total: number) {}
}

// V2 event (new, with items)
class OrderCreatedEventV2 {
  constructor(
    readonly orderId: string,
    readonly customerId: string,
    readonly items: OrderItem[],
    readonly total: number
  ) {}
}

// Handler supports both
class PaymentEventListener {
  private eventBus: EventBus;

  constructor(eventBus: EventBus) {
    this.eventBus = eventBus;
    // Subscribe to both versions
    this.eventBus.subscribe('OrderCreatedEventV1', (event) => this.onOrderCreated(event));
    this.eventBus.subscribe('OrderCreatedEventV2', (event) => this.onOrderCreated(event));
  }

  private async onOrderCreated(event: OrderCreatedEventV1 | OrderCreatedEventV2): Promise<void> {
    // Handle both versions
    const total = 'total' in event ? event.total : event.items.reduce((sum, item) => sum + item.price, 0);
    // ... charge
  }
}
typescript

When Event-Driven Works

Async workflows. Triggering actions that don't need immediate response (send email, update cache, publish to analytics).

Decoupled services. Multiple services need to react to the same event. Publish once, multiple listeners.

Temporal dependencies. Action A triggers action B triggers action C. Events model this naturally.

High throughput. Async processing scales better than request-reply.

When Event-Driven Is Overkill

Simple CRUD. Create user, return response. Event-driven adds complexity for no benefit.

Immediate feedback required. User performs action, needs instant confirmation. Async event processing doesn't fit.

Single synchronous flow. Only one thing needs to happen. Request-reply is simpler.

AI Agents and Event-Driven Architecture

Event-driven systems need clear event contracts to stay intact. An agent might publish an event with missing or extra fields, breaking listeners.

Bitloops helps by making event schemas explicit. You define: "OrderCreatedEvent must include orderId, customerId, items, and total." An agent generating code gets immediate feedback: "Event missing required field: items."

Moreover, idempotency and event versioning are architectural concerns that benefit from explicit documentation. When you provide agents with patterns and constraints around events, they generate more resilient code.

FAQ

Should everything be an event?

No. Events are for genuinely significant, asynchronous operations. Don't event-drive a simple HTTP request that returns data immediately.

How do I debug event-driven systems?

Hard. Causality is invisible. Use structured logging with event IDs. Trace requests through the system. Good observability is essential. Consider event sourcing for audit trails.

What message queue should I use?

RabbitMQ is reliable and well-understood. Kafka is scalable for high volume. AWS SQS/SNS if you're in AWS. Start simple (in-memory), move to a queue when you need durability.

How do I ensure events aren't lost?

Use a durable message queue. Events persist until consumed. Use acknowledgments: listener processes event, then acknowledges. Until acknowledged, the message stays in the queue.

Can listeners depend on event ordering?

Be careful. Events for the same aggregate (Order A) should be ordered. But events across aggregates (Order A, Order B) might arrive out of order. Design for it.

What about distributed transactions?

Event-driven doesn't support traditional distributed transactions. Instead, use sagas: a sequence of compensating transactions. If payment fails, cancel the order. If shipping fails, refund payment.

Primary Sources

  • Fowler's guide to storing application state as immutable event streams. Event Sourcing
  • Fowler's explanation of separating read and write models for complex systems. CQRS Pattern
  • Evans' domain-driven design methodology for complex software systems. Domain-Driven Design
  • Newman's guide to designing and building systems composed of microservices. Building Microservices
  • Richardson's explanation of sagas for managing distributed transactions across services. Saga Pattern
  • Robert C. Martin's guide to clean architecture and dependency principles. Clean Architecture

Get Started with Bitloops.

Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.

curl -sSL https://bitloops.com/install.sh | bash