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.
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
}
};
}
}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);
}
}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);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);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;
}
}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;
}
}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
};
}
}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
});
}
}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
}
}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
More in this hub
Event-Driven Architecture: Decoupling with Events
7 / 10Previous
Article 6
Microservices Architecture: Breaking the Monolith
Next
Article 8
Monolith vs. Microservices: The Real Tradeoff
Also in this hub
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