Integration & APIs/
Lesson

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.

AI pitfall
Ask AI to "design a notification system" and it will often propose a full event-driven architecture with Kafka, CQRS, and saga orchestration, for a system that could be a simple REST call. AI over-engineers by default because event-driven patterns appear frequently in its training data. Always start with the simplest approach and add events when coupling pain is real.

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 declined

Query: 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
AspectEventCommandQuery
DirectionBroadcast (1 to many)Targeted (1 to 1)Targeted (1 to 1)
TensePast ("OrderPlaced")Imperative ("PlaceOrder")Question ("GetOrder")
Can fail?No (already happened)Yes (validation, business rules)No (just reads)
Modifies state?Consumers decideYes (that's the point)No
CouplingLoose (publisher doesn't know consumers)Tight (sender knows the handler)Tight (sender knows the source)
Naming conventionPast participle (Created, Updated, Deleted)Imperative verb (Create, Update, Delete)Get, Find, List
02

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.

ApproachPayload sizeCouplingAvailabilityData freshness
Event notificationSmallHigher (callback needed)Lower (depends on source)Always current
Event-carried state transferLargeLower (self-contained)Higher (no callback)Potentially stale
Good to know
The naming convention matters more than it seems. Events are past tense ("OrderPlaced"), commands are imperative ("PlaceOrder"), queries are questions ("GetOrder"). If you see an event named "CreateOrder," it is probably a command disguised as an event, and it will cause confusion when someone tries to reject it.
03

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.

Edge case
Technical events like "DatabaseUpdated" or "CacheInvalidated" almost always indicate a leaky abstraction. If your consumers need to react to a database change, something in your domain model is missing. The correct event is the business reason the data changed, not the fact that it changed.
04

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.

05

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.