Every developer eventually encounters a system where Service A needs to tell Service B that something happened, but you don't want A to know about B. That is the core motivation behind event-driven architecture. Instead of making direct calls, services emit events, and other services react to them.
But before you start firing events everywhere, you need to understand what an event actually is -- and what it is not.
Events vs commands vs queries
These three concepts sound similar but behave very differently. Mixing them up leads to tangled systems.
Event: A record of something that already happened. It is immutable. You cannot reject an event because it already occurred.
// Event: states a fact about the past
const orderPlaced = {
type: 'OrderPlaced',
timestamp: '2026-03-15T10:30:00Z',
data: {
orderId: 'ord-123',
customerId: 'cust-456',
items: [{ productId: 'prod-789', quantity: 2, price: 29.99 }],
total: 59.98
}
};Command: A request for the system to do something. It can be accepted or rejected. Commands target a specific handler.
// Command: asks the system to perform an action
const placeOrder = {
type: 'PlaceOrder',
data: {
customerId: 'cust-456',
items: [{ productId: 'prod-789', quantity: 2 }]
}
};
// This can fail: out of stock, invalid customer, payment declinedQuery: A request to read data. It should not change state.
// Query: reads data without modifying anything
const getOrderStatus = {
type: 'GetOrderStatus',
data: { orderId: 'ord-123' }
};
// Returns current state, no side effects| Aspect | Event | Command | Query |
|---|---|---|---|
| Direction | Broadcast (1 to many) | Targeted (1 to 1) | Targeted (1 to 1) |
| Tense | Past ("OrderPlaced") | Imperative ("PlaceOrder") | Question ("GetOrder") |
| Can fail? | No (already happened) | Yes (validation, business rules) | No (just reads) |
| Modifies state? | Consumers decide | Yes (that's the point) | No |
| Coupling | Loose (publisher doesn't know consumers) | Tight (sender knows the handler) | Tight (sender knows the source) |
| Naming convention | Past participle (Created, Updated, Deleted) | Imperative verb (Create, Update, Delete) | Get, Find, List |
Event notification vs event-carried state transfer
There are two fundamentally different ways to use events, and the choice has major implications for your architecture.
Event notification carries minimal data -- just enough to tell consumers that something happened.
// Event notification: "go look it up yourself"
const orderShipped = {
type: 'OrderShipped',
data: {
orderId: 'ord-123'
}
};
// Consumer must call back to get details
async function handleOrderShipped(event) {
const order = await orderService.getOrder(event.data.orderId);
await emailService.sendShippingConfirmation(order.customerEmail, order);
}The upside is small event payloads and a single source of truth. The downside is that consumers need network access to the source service, which adds coupling and latencyWhat is latency?The time delay between sending a request and receiving the first byte of the response, usually measured in milliseconds..
Event-carried state transfer includes all the data a consumer might need.
// Event-carried state transfer: "here's everything you need"
const orderShipped = {
type: 'OrderShipped',
data: {
orderId: 'ord-123',
customerEmail: 'alice@example.com',
customerName: 'Alice',
shippingAddress: '123 Main St',
trackingNumber: 'TRK-789',
items: [{ name: 'Widget', quantity: 2 }]
}
};
// Consumer has everything it needs -- no callback required
async function handleOrderShipped(event) {
await emailService.sendShippingConfirmation(
event.data.customerEmail,
event.data
);
}The upside is full decoupling -- consumers work even if the source service is down. The downside is larger payloads and potentially stale data if the source changes between event emission and consumption.
| Approach | Payload size | Coupling | Availability | Data freshness |
|---|---|---|---|---|
| Event notification | Small | Higher (callback needed) | Lower (depends on source) | Always current |
| Event-carried state transfer | Large | Lower (self-contained) | Higher (no callback) | Potentially stale |
Domain events
Domain events represent meaningful business moments. They use the language of the business, not the language of the database.
// Good: domain events that mean something to the business
'OrderPlaced'
'PaymentReceived'
'InventoryReserved'
'ShipmentDispatched'
'RefundIssued'
// Bad: technical events that leak implementation details
'RowInserted'
'CacheInvalidated'
'DatabaseUpdated'
'QueueFlushed'A well-designed domain eventWhat is domain event?An event named in business language that records a meaningful fact (e.g., OrderPlaced), enabling decoupled communication between services. tells you what happened in business terms. If you showed the event name to a product manager and they said "what does that mean?", it is too technical.
Structuring an event
Every event should include enough metadata for consumers to process it reliably.
interface DomainEvent<T = unknown> {
id: string; // Unique event ID (for idempotency)
type: string; // Event type (e.g., 'OrderPlaced')
source: string; // Which service emitted it
timestamp: string; // ISO 8601 when it occurred
version: number; // Schema version (for evolution)
correlationId: string; // Trace across services
data: T; // The actual payload
}
const event: DomainEvent<OrderData> = {
id: 'evt-abc-123',
type: 'OrderPlaced',
source: 'order-service',
timestamp: '2026-03-15T10:30:00Z',
version: 2,
correlationId: 'req-xyz-789',
data: {
orderId: 'ord-123',
customerId: 'cust-456',
total: 59.98
}
};The id field is critical for idempotency. The version field lets you evolve the schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required. without breaking consumers. The correlationId lets you trace a request across multiple services.
When events are overkill
Not everything needs to be event-driven. Here is a quick checklist.
Use events when:
- Multiple services care about the same business moment
- You need to decouple the producer from consumers
- Processing can be asynchronous (the user doesn't need an immediate response)
- You want an audit trail of what happened
Use direct calls when:
- Only one consumer exists and always will
- The caller needs a synchronous response to continue
- The interaction is simple request-response (fetch a user, validate a tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use.)
- Adding a message broker would be over-engineering for the scale
A common mistake is going event-driven on day one when you have two services and three developers. Events add operational complexity: you need a broker, monitoring, dead letter queues, idempotency handling, and ordering guarantees. Start simple, and introduce events when the coupling pain becomes real.