"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
- Azure Architecture Center, Event Sourcing pattern: https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing
- Azure Architecture Center, CQRS pattern: https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs
- Azure Architecture Center, Event-driven architecture style: https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/event-driven
- Apache Kafka documentation, Log compaction: https://kafka.apache.org/documentation/#compaction
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.