"Let's make it event-driven" came up in three design reviews on our team in one quarter, and each time it meant something different. One engineer wanted services to stop calling each other synchronously. Another wanted a full audit history of every change. A third wanted fast read models for a dashboard. Those are three different patterns with very different costs, and choosing the wrong one buys complexity without the benefit you were after.

This guide separates the four patterns that usually hide behind the label: event notification, event-carried state transfer, event sourcing and CQRS. We apply each to the same example, a subscription billing system where the subscriptions service changes plans and other services (invoicing, entitlements, analytics) need to react. For each pattern we cover what it is, what it costs, and when we pick it.

Pattern 1: event notification

A service publishes a small event saying something happened, with just enough identifiers for others to react. Consumers that need details call back to the source.

{
  "type": "SubscriptionPlanChanged",
  "event_id": "evt_01J8Z4",
  "subscription_id": "sub_7731",
  "occurred_at": "2026-07-01T09:12:44Z"
}

Invoicing receives this, then calls GET /subscriptions/sub_7731 to read the new plan and price.

What it buys. The publisher does not know who listens; adding analytics later needs no change to the subscriptions service. Events are small and the payload contract is tiny.

What it costs. Every consumer calls back, so the source still carries read load and must be available when consumers process the event. The callback reads current state, not the state at the time of the event; if the plan changed twice in a second, both consumers may read the second plan twice. The overall flow becomes hard to see, because it lives in subscriptions spread across services.

When we use it. When consumers need only a trigger ("something changed, recompute"), when the payload would be sensitive or large, or as the first step away from synchronous calls.

Pattern 2: event-carried state transfer

The event carries the data consumers need, so they can keep a local copy and never call back.

{
  "type": "SubscriptionPlanChanged",
  "event_id": "evt_01J8Z4",
  "subscription_id": "sub_7731",
  "customer_id": "cus_204",
  "version": 14,
  "previous_plan": "team",
  "plan": "business",
  "seats": 25,
  "price_cents": 49900,
  "currency": "USD",
  "effective_at": "2026-07-01T00:00:00Z",
  "occurred_at": "2026-07-01T09:12:44Z"
}

Entitlements stores plan and seats per subscription in its own table and answers "can this user use feature X" without a network call.

What it buys. Consumers keep working when the source is down. Read load on the source disappears. Each consumer can store the data in whatever shape suits it.

What it costs. Data is duplicated and eventually consistent; for a short window entitlements may see the old plan. Event schemas become a real API with versioning and compatibility rules. Consumers must handle ordering and duplicates, which is why the example carries a version and consumers ignore anything not newer than what they have. Rebuilding a consumer's copy needs either a compacted topic with the latest state per key or a backfill path.

When we use it. For reference data many services read often (plans, products, account status), and anywhere availability of the consumer matters more than instant consistency.

Pattern 3: event sourcing

The service does not store current state as the source of truth at all. It stores the sequence of events, and current state is derived by replaying them.

sub_7731 stream:
  1  SubscriptionStarted     plan=team seats=10
  2  SeatsAdded              seats=+5
  ...
  13 SeatsAdded              seats=+10
  14 PlanChanged             plan=business

To change a subscription, the service loads its events, rebuilds the aggregate in memory, validates the command against it, and appends new events with an expected version. A concurrent writer that appended first causes a version conflict, so the second writer retries.

-- append-only event store with optimistic concurrency
CREATE TABLE subscription_events (
  subscription_id text   NOT NULL,
  version         int    NOT NULL,
  event_type      text   NOT NULL,
  payload         jsonb  NOT NULL,
  recorded_at     timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (subscription_id, version)
);

What it buys. A complete, trustworthy history: you can answer "what plan was this customer on when we invoiced them on 3 May" without a separate audit log. You can build new read models from history at any time, and debug by replaying exactly what happened.

What it costs. It is the most expensive pattern here. Events are permanent, so schema changes need upcasting of old events. Aggregates with long histories need snapshots for load time. Deleting personal data requires crypto-shredding or careful stream rewriting. Querying current state across many aggregates is impossible without a separate read model, which is why event sourcing almost always comes with CQRS. Most developers are unfamiliar with it, so onboarding is slower.

When we use it. Rarely, and only in a bounded context where history is the product: ledgers, billing adjustments, compliance-heavy workflows. We use it for our credit-notes ledger, not for the whole subscriptions service.

An important distinction: publishing events to Kafka is not event sourcing. A service that writes rows to PostgreSQL and also emits events is using notification or state transfer. Event sourcing means the event log is the source of truth.

Pattern 4: CQRS

Command Query Responsibility Segregation splits the write model from one or more read models. Commands go through a model optimised for validation and invariants; queries hit separate models optimised for reading, updated from events.

For billing, the write side enforces rules like "seat count cannot drop below active users". The read side has a denormalised subscription_overview table feeding the admin dashboard, with plan names, MRR and seat utilisation precomputed:

CREATE TABLE subscription_overview (
  subscription_id text PRIMARY KEY,
  customer_name   text,
  plan            text,
  seats           int,
  active_users    int,
  mrr_cents       bigint,
  last_event_version int
);

A projector consumes subscription events and upserts this table, skipping events whose version is not newer than last_event_version.

What it buys. Reads scale independently, and each screen or API can have exactly the shape it needs. Complex invariants live in one place on the write side.

What it costs. Read models are eventually consistent, so the UI must handle "I just changed it and the list still shows the old value", usually by reading from the write side right after a command or showing a pending state. More moving parts: projectors, their lag, and rebuild procedures.

When we use it. When read and write workloads differ sharply in shape or volume, such as dashboards and search over data with complex write rules. CQRS does not require event sourcing; our overview table is fed by state-transfer events from an ordinary CRUD service.

Comparing the four

Notification State transfer Event sourcing CQRS
Source of truth Publisher's DB Publisher's DB Event log Write model
Payload size Tiny Medium Domain events Any
Consumer independence Low (calls back) High High High for reads
Consistency Read-time current Eventual Strong on write side Eventual on read side
History available No Only if retained Full Depends
Complexity Low Medium High Medium to high

Signals that point to each

  • Consumers only need to know that something happened: notification.
  • Consumers keep needing the same fields and the source is a bottleneck or availability risk: state transfer.
  • Auditors, finance or support keep asking "what was the state at time T and why": event sourcing for that context.
  • Reads are slow or awkward because the write schema does not match how data is viewed: CQRS.

Mixing is normal. Our subscriptions service is CRUD with an outbox, publishes state-transfer events, and feeds a CQRS read model; only the credit-notes ledger is event sourced.

Mistakes we have made

Mistake Consequence What we do instead
Notification events with callbacks under heavy fan-out The source got hammered on every event Carry the fields consumers actually need
State transfer events without versions Older events overwrote newer state after redelivery Per-entity version in every event
Event sourcing the whole service Months spent on upcasters and snapshots for little benefit Event source only the contexts where history matters
Treating Kafka retention as the event store History vanished after retention expired Event store in a database, Kafka for distribution
CQRS read models with no rebuild path A projector bug corrupted the dashboard permanently Every projector can rebuild from scratch

Key takeaways

  • "Event-driven" covers four patterns with different goals; name the one you mean in design reviews.
  • Event notification decouples triggers but keeps a runtime dependency on the source.
  • Event-carried state transfer removes that dependency at the cost of duplicated, eventually consistent data.
  • Event sourcing makes the event log the source of truth; reserve it for contexts where history is the product.
  • CQRS separates read and write models and does not require event sourcing.
  • Whatever the pattern, events need versions, consumers need idempotency, and read models need a rebuild path.

References