EDA Fundamentals: Domain Events, Commands and Message Bus
Imagine an e-commerce system: when a customer completes an order, they must happen many things in parallel — inventory needs to be decremented, email notification needs to be sent, the fulfillment team must be notified, the loyalty system must be updated. Yes do they coordinate these services? A classic approach is for the Orders service to call all other services directly: tight coupling, fragility, impossibility to climb independently.
L'Event-Driven Architecture (EDA) overturns this paradigm: service
Orders publishes an event OrderPlaced on a message bus, and all services
interested parties consume it independently. The Orders service does not know who listens to them, not
awaits answers, it does not depend on their availability. This decoupling and the
fundamental principle on which scalable and resilient distributed systems are built.
What You Will Learn
- The difference between Domain Events, Commands and Queries in EDA
- Publish-Subscribe Pattern: decoupled producers and consumers
- Message Bus, Event Bus and Message Queue: when to use what
- Event schema: structure, versioning and standard CloudEvents
- Benefits and trade-offs of EDA versus synchronous REST
- Implementing a simple EDA system in TypeScript
- How to decide when to use EDA and when not to use it
The Three Types of Messages in EDA
Not all messages in an event-driven system are the same. Understand the distinction between events, commands and queries and the first step to design correct EDA systems:
| Type | Description | Direction | Answer | Example |
|---|---|---|---|---|
| Domain Event | Something that happened in the domain | 1 → N (broadcast) | No | OrderPlaced, PaymentReceived |
| Command | A request to perform an action | 1 → 1 (point-to-point) | Optional (async ACK) | PlaceOrder, SendEmail |
| Queries | One request for data (asynchronous EDA) | 1 → 1 with reply | Yes (reply queue) | GetOrderStatus via reply queue |
Domain Events: The Heart of EDA
Un Domain Event describes something that has already happened in the business domain. The key properties:
- Immutable: describes the past, it does not change after publication
- Named in the past:
OrderPlaced, NotPlaceOrder - Self-contained: contains all the data necessary for consumers
- Typed: each type of event has a defined pattern
// TypeScript: struttura di un Domain Event
interface DomainEvent {
eventId: string; // ID unico dell'evento (UUID)
eventType: string; // nome del tipo evento
occurredAt: string; // timestamp ISO 8601 (immutabile)
aggregateId: string; // ID dell'aggregato che ha generato l'evento
aggregateType: string; // tipo dell'aggregato (es. "Order")
version: number; // versione dello schema evento (per evoluzione)
payload: unknown; // dati specifici dell'evento
metadata?: {
correlationId?: string; // ID per tracciare la catena di eventi
causationId?: string; // ID del messaggio che ha causato questo evento
userId?: string; // utente che ha innescato l'azione
};
}
// Evento concreto: OrderPlaced
interface OrderPlacedEvent extends DomainEvent {
eventType: 'OrderPlaced';
aggregateType: 'Order';
payload: {
orderId: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
unitPrice: number;
}>;
totalAmount: number;
currency: string;
shippingAddress: {
street: string;
city: string;
country: string;
};
};
}
// Creare un OrderPlaced event
function createOrderPlacedEvent(order: Order): OrderPlacedEvent {
return {
eventId: crypto.randomUUID(),
eventType: 'OrderPlaced',
occurredAt: new Date().toISOString(),
aggregateId: order.id,
aggregateType: 'Order',
version: 1,
payload: {
orderId: order.id,
customerId: order.customerId,
items: order.items,
totalAmount: order.totalAmount,
currency: order.currency,
shippingAddress: order.shippingAddress,
},
metadata: {
correlationId: crypto.randomUUID(),
},
};
}
Publish-Subscribe Pattern
The pattern Publish-Subscribe and the foundation of the EDA: the publisher (producer) sends events to the message bus without knowing who receives them; the subscribers (consumers) register to receive certain types of events without knowing who publishes them.
// Implementazione semplice di un Event Bus in memoria (per test/sviluppo)
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;
class InMemoryEventBus {
private handlers = new Map<string, EventHandler<DomainEvent>[]>();
subscribe<T extends DomainEvent>(eventType: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(eventType) ?? [];
this.handlers.set(eventType, [...existing, handler as EventHandler<DomainEvent>]);
}
async publish(event: DomainEvent): Promise<void> {
const eventHandlers = this.handlers.get(event.eventType) ?? [];
// Pubblica in parallelo a tutti i subscriber
await Promise.allSettled(
eventHandlers.map((handler) => handler(event))
);
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
}
// Utilizzo:
const eventBus = new InMemoryEventBus();
// Inventory Service si registra per OrderPlaced
eventBus.subscribe<OrderPlacedEvent>('OrderPlaced', async (event) => {
console.log(`Decrementing inventory for order ${event.payload.orderId}`);
for (const item of event.payload.items) {
await inventoryService.decrement(item.productId, item.quantity);
}
});
// Email Service si registra per OrderPlaced
eventBus.subscribe<OrderPlacedEvent>('OrderPlaced', async (event) => {
await emailService.sendOrderConfirmation(
event.payload.customerId,
event.payload.orderId
);
});
// Order Service pubblica l'evento (non conosce i subscriber)
await eventBus.publish(createOrderPlacedEvent(placedOrder));
Message Bus, Event Bus and Message Queue: The Differences
The terms are often used interchangeably but have specific meanings:
- Message Queue: Point-to-point queue. A message is delivered to only one consumer. Example: SQS Standard Queue
- Event Bus: Broadcast to everyone the subscribers. Each subscriber receives a copy of the event. Example: AWS EventBridge, SNS Topic
- Message Bus: General term that includes both queue and topic. In practice: a broker that manages message routing (RabbitMQ, Kafka)
// Esempio: stessa logica su AWS SQS + SNS (architettura fan-out comune)
// Pattern fan-out: SNS Topic + SQS Queue per ogni consumer
// 1. Pubblica su SNS Topic
// 2. SNS consegna a tutte le SQS Queue sottoscritte
// 3. Ogni servizio legge dalla propria SQS Queue indipendentemente
// Terraform per il fan-out pattern:
resource "aws_sns_topic" "order_events" {
name = "order-events"
}
resource "aws_sqs_queue" "inventory_queue" {
name = "inventory-order-events"
}
resource "aws_sqs_queue" "email_queue" {
name = "email-order-events"
}
resource "aws_sns_topic_subscription" "inventory" {
topic_arn = aws_sns_topic.order_events.arn
protocol = "sqs"
endpoint = aws_sqs_queue.inventory_queue.arn
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.order_events.arn
protocol = "sqs"
endpoint = aws_sqs_queue.email_queue.arn
}
CloudEvents: The Standard for Event Schemas
CloudEvents and a CNCF specification that standardizes the structure of the events between different systems. Adopting it facilitates interoperability and simplifies tools monitoring and debugging:
// CloudEvents v1.0 - struttura standard
{
"specversion": "1.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "com.company.order.placed", // Reverse DNS + evento
"source": "/orders-service/v1", // URI del servizio sorgente
"subject": "order-789", // identificativo della risorsa
"time": "2026-03-20T10:30:00Z", // timestamp ISO 8601
"datacontenttype": "application/json",
"dataschema": "https://schemas.company.com/order/placed/v1.json",
"data": {
"orderId": "order-789",
"customerId": "cust-123",
"totalAmount": 150.00,
"currency": "EUR"
}
}
// TypeScript: creare un CloudEvent con la SDK ufficiale
import { CloudEvent } from "cloudevents";
const event = new CloudEvent({
specversion: "1.0",
type: "com.company.order.placed",
source: "/orders-service/v1",
subject: `order-${orderId}`,
datacontenttype: "application/json",
dataschema: "https://schemas.company.com/order/placed/v1.json",
data: {
orderId: order.id,
customerId: order.customerId,
totalAmount: order.totalAmount,
currency: order.currency,
},
});
// Valida il CloudEvent prima di pubblicarlo
if (!event.source || !event.type) {
throw new Error("CloudEvent validation failed: missing required fields");
}
Versioning of Events
Events are consumed by multiple services independently. Change the pattern of an event without a versioning strategy breaks consumers. The main patterns:
// Pattern 1: Versioning nel tipo evento
// Vecchi consumer continuano a ricevere v1, nuovi consumer si registrano per v2
eventBus.subscribe('OrderPlaced.v1', handleOrderPlacedV1);
eventBus.subscribe('OrderPlaced.v2', handleOrderPlacedV2);
// Pattern 2: Backward-compatible changes (aggiunta di campi opzionali)
// SAFE: aggiungere nuovi campi opzionali (consumer ignorano i campi sconosciuti)
interface OrderPlacedEventV1 {
orderId: string;
customerId: string;
totalAmount: number;
}
interface OrderPlacedEventV2 extends OrderPlacedEventV1 {
// Aggiunto in V2: opzionale, backward-compatible
estimatedDeliveryDate?: string;
loyaltyPointsEarned?: number;
}
// Pattern 3: Parallel publishing (per breaking changes)
// Pubblica sia v1 che v2 per un periodo di transizione
async function publishOrderPlaced(order: Order): Promise<void> {
const v1Event = createOrderPlacedV1(order);
const v2Event = createOrderPlacedV2(order);
await Promise.all([
eventBus.publish(v1Event), // per consumer legacy
eventBus.publish(v2Event), // per consumer aggiornati
]);
}
// NEVER: rimuovere campi, cambiare tipi, rinominare campi obbligatori
// -> breaking change: migra prima tutti i consumer poi rimuovi v1
Benefits and Trade-Offs of EDA
When to Use EDA
- Necessary decoupling: When you want to add new consumers without changing publishers
- Independent scalability: Different consumers with different loads scaling separately
- Audit trails: Immutable events are a natural log of everything that happened in the system
- Resilience to failure: If a consumer is down, the message bus holds messages until it comes back up
- Cross-system integration: Heterogeneous systems that communicate via standard events
When NOT to use EDA
- Immediate response required: If the user has to wait for the synchronous result, EDA adds unnecessary latency and complexity
- Simple systems: A monolith with few features does not benefit from the overhead of a message broker
- Simple distributed transactions: For operations that must be atomic across multiple services, EDA requires the Saga pattern (high complexity)
- Small team without EDA experience: The learning curve is significant. Start with REST and add EDA where needed
The Complete Flow: e-Commerce Example
// Flusso completo EDA per un ordine e-commerce
// 1. Order Service: riceve HTTP POST /orders
// 2. Valida, persiste, pubblica evento
class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly eventBus: EventBus
) {}
async placeOrder(dto: PlaceOrderDto): Promise<Order> {
// Logica business: crea l'ordine
const order = Order.create(dto);
// Persisti nel database
await this.orderRepo.save(order);
// Pubblica gli eventi generati dall'aggregato
const events = order.getUncommittedEvents();
await this.eventBus.publishAll(events);
order.clearEvents();
return order;
}
}
// 3. Inventory Service: ascolta OrderPlaced
// - Scala indipendentemente con 5 consumer paralleli
// - Se giu, i messaggi si accumulano nella queue
// 4. Email Service: ascolta OrderPlaced
// - Invia email di conferma
// - Se fallisce, il messaggio va in DLQ per retry
// 5. Loyalty Service: ascolta OrderPlaced
// - Calcola e aggiunge punti fedeltà
// - Pubblica LoyaltyPointsEarned
// 6. Analytics Service: ascolta OrderPlaced + LoyaltyPointsEarned
// - Aggiorna le metriche in tempo reale
// Il servizio Order non sa niente di tutto questo!
// Aggiungere un nuovo consumer = zero modifiche al publisher
Conclusions and Next Steps
EDA is a paradigm shift: from a system where services "call" each other to a system where services "communicate via events". The decoupling gain, scalability and resilience is real, but requires facing new challenges: management of errors becomes asynchronous, debugging requires correlation ID and distributed tracing, consistency must become "eventual".
The next articles in this series address the advanced patterns that make EDA viable in production: Event Sourcing for immutable state, CQRS for separation read/write, Saga for distributed transactions, and AWS tools (EventBridge, SQS, SNS) to implement them in cloud environments.
Upcoming Articles in the Event-Driven Architecture Series
- Event Sourcing: State as an Immutable Sequence of Events
- CQRS: Separate Read and Write for Independent Scaling
- Saga Pattern: Distributed Transactions with Choreography and Orchestration
- AWS EventBridge: Serverless Event Bus and Content-Based Routing
- Dead Letter Queue and Resilience in Asynchronous Systems
Related Series
- Apache Kafka and Stream Processing — Kafka as a backbone for high-volume EDA systems
- Kubernetes at Scale — orchestrate EDA microservices on Kubernetes
- Practical Software Architecture — when EDA vs REST, monolith vs microservices







