Core Event Patterns
Commands vs. Events
A command is a request for something to happen, addressed to one specific handler, which can accept or reject it. An event is a broadcast fact that something already happened — named in the past tense — that any number of listeners may react to or ignore. Getting this distinction right shapes the whole system: commands couple caller to callee, events decouple them.
// Command — imperative, targeted, can fail/reject
interface PlaceOrderCommand {
type: 'PlaceOrder';
customerId: string;
items: OrderItem[];
}
// Event — declarative fact, already happened, broadcast to anyone listening
interface OrderPlacedEvent {
type: 'OrderPlaced'; // past tense
orderId: string;
customerId: string;
placedAt: string;
}
// A command handler decides whether to accept it, then emits an event
async function handlePlaceOrder(cmd: PlaceOrderCommand) {
const order = await orderRepo.create(cmd);
await eventBus.publish<OrderPlacedEvent>({
type: 'OrderPlaced',
orderId: order.id,
customerId: cmd.customerId,
placedAt: new Date().toISOString(),
});
}Event Notification vs. Event-Carried State Transfer
A thin "notification" event carries just an ID, forcing consumers to call back to the source for details — simple, low schema coupling, but reintroduces a runtime dependency. A "state transfer" event carries the data consumers actually need, so they stay available even if the source service is down — at the cost of payload duplication and staleness risk if not versioned carefully.
// Event notification — thin, forces a callback
interface OrderPlacedThin {
type: 'OrderPlaced';
orderId: string; // consumer must call GET /orders/:id for anything else
}
// Event-carried state transfer — consumer is self-sufficient
interface OrderPlacedRich {
type: 'OrderPlaced';
orderId: string;
customerId: string;
items: { sku: string; qty: number; price: number }[];
total: number;
placedAt: string;
}Event Sourcing
Instead of storing only the latest state, event sourcing persists the full sequence of state-changing events as the system of record, and derives current state by replaying them. This gives a complete audit trail and the ability to rebuild any past state, at the cost of needing careful event-schema evolution over the system's lifetime.
type AccountEvent =
| { type: 'AccountOpened'; balance: number }
| { type: 'FundsDeposited'; amount: number }
| { type: 'FundsWithdrawn'; amount: number };
// Current state is derived by replaying the log — never mutated in place
function replay(events: AccountEvent[]): { balance: number } {
return events.reduce((state, event) => {
switch (event.type) {
case 'AccountOpened': return { balance: event.balance };
case 'FundsDeposited': return { balance: state.balance + event.amount };
case 'FundsWithdrawn': return { balance: state.balance - event.amount };
}
}, { balance: 0 });
}CQRS
Command Query Responsibility Segregation splits the write model (handles commands, emits events) from a separate read model, kept up to date by consuming those events and optimized independently for queries — e.g. a denormalized search index that would be awkward to maintain in the normalized write schema.
Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free