Event-Driven Architecture
01 / 02

Core Event Patterns

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