Skip to content
SparkTime, home
System Design From Zero to Interview-Ready
Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
6 · Distributed Systems
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
11 · Large-Scale Case Studies
12 · Interview Readiness
Lesson 27 of 45

Lesson 27 · Architecture and Security

Event-Driven Architecture, CQRS and Event Sourcing

When services communicate by publishing facts instead of calling each other: commands versus events, event notification versus event-carried state transfer, designing and versioning events, ordering, replay, and the two patterns often paired with events: CQRS read models and event sourcing, with honest guidance on when each is worth its complexity.

On this page

Key takeaways

  • A command asks one service to do something and may be rejected; an event states a fact that already happened and may interest many consumers.
  • Event-driven designs decouple producers from consumers and make new consumers cheap to add, at the cost of eventual consistency, harder debugging and schema evolution.
  • Design events deliberately: past-tense names, unique ids, versions, an ordering key, and either just identifiers or the state consumers need.
  • CQRS separates the write model from read models built for queries, usually kept up to date by events; it pays off for complex domains and very different read and write needs.
  • Event sourcing stores the events themselves as the source of truth; it gives audit and replay but changes how you query, version and delete data, so use it selectively.

When an order is placed in an e-commerce system, a surprising number of things should happen: reserve stock, take payment, send a confirmation, update the customer's order history, notify the warehouse, update sales dashboards, feed the recommendation model, and award loyalty points. If the order service calls each of those services directly, it must know about all of them, wait for them, and handle each of their failures. Adding "award loyalty points" means changing and redeploying the order service.

The alternative is for the order service to announce a fact, OrderPlaced, and let every interested service react on its own. That is the core idea of event-driven architecture. This article covers how to design it well, and two patterns that often travel with it: CQRS and event sourcing.

What you will learn

#
  • The difference between commands, events and queries.
  • What event-driven architecture is, and its benefits and costs.
  • Event notification versus event-carried state transfer.
  • How to design, version and order events, and how replay works.
  • CQRS: separate write and read models, and when it is worth it.
  • Event sourcing: events as the source of truth, with a runnable example.
  • Failure modes, and how to choose.

Commands, events and queries

#

Command

Event

Query

Meaning

"Please do this"

"This happened"

"Tell me this"

Tense

Imperative: ReserveStock, ChargeCard

Past: OrderPlaced, PaymentCompleted, ShipmentCreated

Question: GetOrder

Recipients

Exactly one handler

Zero, one or many subscribers

One handler

Can it be rejected?

Yes (insufficient stock)

No; it already happened

n/a

Coupling

Sender knows the receiver

Producer does not know who listens

Caller knows the target

Changes state?

Yes, if accepted

Records a change that already happened

No

This distinction is worth saying out loud in interviews. A message called SendEmail on a topic that five services consume is a design smell: either it is a command and should go to one handler, or it is really an event (UserRegistered) and the email service should decide for itself to send a welcome message.

Event-driven architecture

#

In an event-driven architecture, services publish events when their state changes, and other services subscribe to the events they care about, through a broker (article 11). Microsoft's guide to the style describes producers that generate events, consumers that react to them, and channels that transport them, with producers decoupled from consumers (Event-driven architecture style).

Code
Order service --OrderPlaced--> broker --+--> Inventory (reserve stock)                                        +--> Notifications (confirmation email)                                        +--> Order history read model                                        +--> Analytics                                        +--> Loyalty (award points)   <- added later, no change to Order

Benefits

#
  • Decoupling: producers do not know or care who consumes.
  • Extensibility: a new consumer subscribes without changing the producer.
  • Resilience: if analytics is down, orders still succeed; its events wait in the broker.
  • Scalability: consumers scale independently.
  • A record of what happened, useful for auditing and debugging, and (with a retained stream) for replay.

Costs

#
  • Eventual consistency: after OrderPlaced, the order history page may lag by a second.
  • Hidden flows: the overall business process is spread across consumers; nobody can read it in one place. Too many events triggering further events becomes hard to reason about.
  • Duplicates and ordering: consumers must be idempotent and tolerate reordering (articles 11 and 20).
  • Schema evolution: events are a public contract with every current and future consumer.
  • Debugging: you need tracing through the broker (article 25).

Martin Fowler's essay on the many meanings of "event-driven" distinguishes four patterns that are often confused: event notification, event-carried state transfer, event sourcing and CQRS (What do you mean by "Event-Driven"?). The rest of this article covers each.

Thin events or fat events?

#

Event notification (thin events)

#

The event says something changed and carries little more than identifiers:

JSON
{"event_id": "evt_19c", "type": "CustomerAddressChanged", "version": 1, "customer_id": 42, "occurred_at": "2026-10-04T18:22:07Z"}

Consumers that need details call the customer service. Simple and small; but every consumer calls back (load on the producer), the producer must be up when consumers process, and the data fetched may already have changed again.

Event-carried state transfer (fat events)

#

The event carries the state consumers need:

JSON
{"event_id": "evt_19c", "type": "CustomerAddressChanged", "version": 1, "customer_id": 42, "address": {"line1": "12 Lake Road", "city": "Chennai", "pin": "600001"}, "occurred_at": "2026-10-04T18:22:07Z"}

Consumers keep their own local copy of the data they need (for example, the shipping service keeps customer addresses) and never call back. Faster and more resilient, at the cost of larger events, data duplicated across services, and more care about what is exposed.

Thin

Fat

Event size

Small

Larger

Load on producer

Callbacks from consumers

None after publishing

Consumer independence

Needs producer available

Fully independent

Data exposure

Minimal

Consumers see more fields

Typical use

Triggers ("something changed, check it")

Replicating reference data between services

Designing events

#

A good event:

  • has a past-tense name describing a business fact (PaymentCompleted, not PaymentServiceUpdatedRow);
  • has a unique id for deduplication;
  • has a type and version for schema evolution;
  • has an ordering key (the aggregate id, such as order_id) so events for one entity stay in order in a partitioned broker;
  • has when it happened and, ideally, correlation and causation ids linking it to the request or event that caused it (helpful in tracing);
  • contains only what consumers need, and no secrets.

Versioning

#

Consumers you do not control will read your events for years. Evolve them like public APIs (article 04):

  • Add optional fields; never remove or repurpose fields within a version.
  • Consumers must ignore unknown fields.
  • For breaking changes, publish a new version (OrderPlaced v2), possibly alongside v1 for a transition period.
  • Many teams use a schema registry to store schemas and check compatibility automatically when producers change.

Ordering and replay

#
  • Order is guaranteed per partition or key, not globally (article 11). Choose the key that must stay ordered, typically the aggregate id.
  • With a retained stream, a new consumer can start from the beginning and replay history to build its state, and an existing consumer can rebuild after a bug fix. Plan retention (or compaction, which keeps the latest event per key) with that in mind.
  • Consumers should still tolerate duplicates and out-of-order events, using event ids and per-entity versions (article 20).

Publishing reliably

#

Use the transactional outbox (article 21) so an event is published if and only if the state change committed. Without it, events are occasionally lost or invented, and event-driven systems drift silently.

CQRS: separate models for writing and reading

#

Command Query Responsibility Segregation (CQRS) separates the model that handles writes (commands, with business rules and validation) from one or more models that serve reads (queries, shaped for screens and reports). Martin Fowler describes it as using a different model to update information than the model you use to read it, and warns that it adds risky complexity for most systems (CQRS).

Code
Commands --> Write model (normalised, transactional, enforces rules) --> events                                                                          |Queries  <-- Read model A: "my orders" list (denormalised) <--------------+         <-- Read model B: search index <---------------------------------+         <-- Read model C: sales dashboard aggregates <-------------------+

Microsoft's CQRS pattern guidance describes the read models as materialised views optimised for queries, kept in sync by events from the write side, which makes them eventually consistent; it recommends the outbox pattern and idempotent read-model consumers for that synchronisation (CQRS pattern).

When it helps:

  • reads and writes have very different shapes or scale (a product catalogue written by a few staff and read by millions);
  • several very different query views over the same data (lists, search, analytics);
  • complex domains where the write model's rules should not be bent to suit query needs;
  • the data is already flowing as events between services.

When it is overkill: simple CRUD screens over one table, small domains, and teams without experience operating eventually consistent views, which the same guidance lists as reasons not to use it.

Handling the lag in the UI: after a command succeeds, return the new state from the write side, or show it optimistically on the client, so the user sees their change even if the read model lags (read-your-writes, article 19).

You have already met CQRS informally: Pixly's precomputed feeds (a read model built from photo events) and the search index fed from the product database are both read models.

Event sourcing: events as the source of truth

#

Most systems store current state (the account balance is 700) and possibly a log of changes on the side. Event sourcing inverts that: the events are the source of truth (opened, deposited 1,000, withdrew 300), and current state is derived by replaying them. Microsoft's Event Sourcing pattern describes storing the full series of actions on an object in an append-only store and materialising state from it, and opens with a warning that it is a complex pattern with significant trade-offs, costly to adopt or abandon (Event Sourcing pattern). Martin Fowler's description covers the same idea and its uses, such as rebuilding state and temporal queries (Event Sourcing).

Event sourcing in code

#

This Java program keeps one account's events in an append-only list, derives the balance by replay, answers a "what was the balance back then?" question, and uses an expected-version check so two concurrent withdrawals cannot both succeed against the same state (the optimistic concurrency that event stores provide).

Java
// File: EventSourcingDemo.javaimport java.util.ArrayList;import java.util.List;public class EventSourcingDemo {    sealed interface Event permits Opened, Deposited, Withdrawn {}    record Opened(String accountId) implements Event {}    record Deposited(long amount) implements Event {}    record Withdrawn(long amount) implements Event {}    // The event store for one account: append-only, with an optimistic version check.    static final List<Event> stream = new ArrayList<>();    static void append(int expectedVersion, Event event) {        if (stream.size() != expectedVersion) {            throw new IllegalStateException("version conflict: expected " + expectedVersion                    + ", stream is at " + stream.size());        }        stream.add(event);    }    // Current state is not stored; it is derived by replaying the events.    static long balance(List<Event> events) {        long total = 0;        for (Event e : events) {            if (e instanceof Deposited d) {                total += d.amount();            } else if (e instanceof Withdrawn w) {                total -= w.amount();            }        }        return total;    }    // A command: check the rule against the state at the version we read, then append.    static void withdraw(int versionRead, long amount) {        if (balance(stream.subList(0, versionRead)) < amount) {            throw new IllegalStateException("insufficient funds");        }        append(versionRead, new Withdrawn(amount));    }    public static void main(String[] args) {        append(0, new Opened("acc-1"));        append(1, new Deposited(1000));        append(2, new Withdrawn(300));        System.out.println("events in the stream:");        for (int i = 0; i < stream.size(); i++) {            System.out.println("  " + (i + 1) + ". " + stream.get(i));        }        System.out.println("balance now: " + balance(stream));        System.out.println("balance as of event 2: " + balance(stream.subList(0, 2)));        // Two clients both read the stream at version 3 and each try to withdraw 600.        withdraw(3, 600);        System.out.println("client A: withdrew 600");        try {            withdraw(3, 600);        } catch (IllegalStateException e) {            System.out.println("client B: rejected, " + e.getMessage());        }        System.out.println("final balance: " + balance(stream) + " after " + stream.size() + " events");    }}
Code
events in the stream:  1. Opened[accountId=acc-1]  2. Deposited[amount=1000]  3. Withdrawn[amount=300]balance now: 700balance as of event 2: 1000client A: withdrew 600client B: rejected, version conflict: expected 3, stream is at 4final balance: 100 after 4 events

Without the version check, both clients would have seen a balance of 700, both withdrawals would pass the rule, and the account would end at -500. The check makes the second client reload and re-evaluate, exactly like optimistic concurrency on a row (article 14).

Benefits and costs

#

Benefits

Costs

A complete, immutable audit trail of what happened

Querying current state needs projections (read models), usually eventually consistent

Rebuild or create new read models by replaying

Replaying long streams is slow without snapshots

Answer "what was true at time T?"

Event schemas live forever; versioning needs upcasting or tolerant readers

Captures intent ("seats reserved"), not only resulting values

Deleting personal data conflicts with immutability (techniques include keeping personal data outside events, or encrypting it per subject and deleting the key)

Natural fit with event-driven integration

Different mental model; harder testing and operations

Two practical notes from Microsoft's guidance: do not confuse an event store with a message broker (a broker such as Kafka is good at distributing events, but typically lacks per-entity streams with optimistic concurrency), and apply event sourcing selectively to the parts of a system that benefit, such as a ledger or order pipeline, keeping ordinary CRUD elsewhere (Event Sourcing pattern).

Worked example: the order flow

#

Commands and events:

Code
Client --PlaceOrder (command)--> Order service  Order service: validate, store order PENDING + outbox OrderPlaced (one transaction)OrderPlaced (event) --> Saga orchestrator --ReserveStock (command)--> Inventory                                      <--StockReserved (event)--                    --ChargePayment (command)--> Payment                                      <--PaymentCompleted (event)--                    --ConfirmOrder (command)--> Order service --> OrderConfirmed (event)OrderConfirmed --> Shipping --> ShipmentCreated (event)OrderConfirmed, ShipmentCreated --> Notifications, Order-history read model, Analytics, Loyalty

Commands go to one owner, which can refuse; events announce facts to whoever cares. The saga (article 21) uses commands for its steps; everything downstream reacts to events.

CQRS read model for "My orders": a consumer builds a denormalised table keyed by customer, containing order summary, status, item thumbnails and shipment tracking, from OrderPlaced, OrderConfirmed, ShipmentCreated and OrderCancelled events. The "My orders" page is one query on one table. After placing an order, the app shows the order from the command's response, so the user never notices the read model's brief lag.

Event sourcing: used only for the payment ledger (article 39), where an immutable history is a requirement. Orders are stored as ordinary state with an event log published via the outbox.

Adding a consumer later: a fraud-scoring service subscribes to OrderPlaced and PaymentCompleted, replays the last 30 days from the retained stream to warm up, and starts scoring. No other service changes.

Failure modes

#

Failure

Effect

Mitigation

Event lost between commit and publish

Consumers never react; data drifts

Transactional outbox

Duplicate events

Double effects

Idempotent consumers with event ids

Out-of-order events

Stale state overwrites new

Per-entity ordering keys; version checks

Breaking schema change

Consumers crash or misread

Additive changes, versions, schema registry, contract tests

Read model lagging badly

Users see stale views

Monitor lag; read-your-writes from the command response

Event chains trigger loops

Runaway event storms

Clear ownership; detect cycles; limit fan-out

Poison event blocks a partition

Consumer stuck

Dead-letter handling per consumer

Trade-offs

#

Choice

You gain

You pay

Events instead of direct calls

Decoupling, extensibility, resilience

Eventual consistency, hidden flows, schema contracts

Fat events

Consumer independence

Larger events, duplicated data

CQRS

Optimised reads and a clean write model

Synchronisation, lag, more stores

Event sourcing

Audit, replay, temporal queries

Complexity, versioning, deletion challenges

Long stream retention

Replay for new consumers

Storage, privacy obligations

Common mistakes

#
  • Commands disguised as events (SendEmailEvent) or events used to request work from a specific service.
  • Publishing events without an outbox.
  • Events that mirror database rows instead of business facts.
  • Breaking event schemas as if they were private.
  • CQRS and event sourcing everywhere, for simple CRUD.
  • No way to see a whole business flow (no tracing, no process view).

In an interview

#
  • Use precise words: "the order service publishes OrderPlaced; inventory reacts", and "the saga sends a ChargePayment command".
  • Mention the outbox, idempotent consumers and the ordering key in the same breath.
  • Introduce CQRS only where a read path clearly needs a different shape or scale; name the read model and its lag.
  • Introduce event sourcing only for parts that need an immutable history, and name its costs.

Exercises

#

Exercise 1

Command or event?

#

Classify each message, and rename it if the name is misleading:

  1. ReserveSeats sent by the booking orchestrator to the inventory service.
  2. UserRegistered published by the accounts service.
  3. SendWelcomeEmail published to a topic consumed by email, analytics and CRM services.
  4. UpdateSearchIndex published by the product service after every edit.

Exercise 2: Thin or fat?

#

The catalogue service publishes product changes. Consumers: search (needs title, description, attributes), pricing dashboard (needs price only), cart (needs title and price to display), and a recommendation model (needs category and tags). Thin or fat events, and why? What would you include?

Exercise 3: Is it worth it?

#

Decide whether CQRS, event sourcing, both or neither is appropriate:

  1. An internal tool where HR staff edit employee contact details.
  2. A digital wallet that must show every transaction ever made and support audits.
  3. A marketplace where product pages are read 10,000 times for every edit, and search, listings and seller dashboards each need different views.

Exercise 4: Design the events

#

When a ride ends in a ride-hailing app: the fare is calculated, the rider is charged, the driver's earnings are recorded, both receive receipts, and both are asked for ratings. Design the events and commands, say which service owns each, and give each consumer's idempotency strategy.

Show solutionHide solution
  1. Command, correctly named: one recipient, can be refused if seats are taken.
  2. Event, correctly named: a fact; any number of consumers.
  3. A command disguised as an event. If three services consume it, the real fact is UserRegistered; the email service should decide to send a welcome email, analytics to count a sign-up, CRM to create a contact. Publish UserRegistered and let each react.
  4. A command disguised as an event, coupling the product service to search. Publish ProductUpdated (a fact); the search indexer subscribes and updates the index, and other consumers can use the same event.

Exercise 2

Show solutionHide solution

Fat events (event-carried state transfer) fit here: several consumers each need product data to keep their own views, and calling back to the catalogue for every change would load it heavily and couple their availability to it.

Include in ProductUpdated: product id, version, title, description, attributes, category, tags, price and currency, status, and timestamp. Each consumer takes what it needs and ignores the rest. Keep internal fields (cost price, supplier details) out of the public event. Alternatively, publish two event types if price changes are very frequent: ProductDetailsUpdated and ProductPriceChanged, so consumers subscribe only to what matters to them. Use product_id as the ordering key, and let consumers apply an event only if its version is newer than what they hold.

Exercise 3

Show solutionHide solution
  1. Neither. Simple CRUD on a small dataset; a normal relational model with an audit log table is plenty.
  2. Event sourcing for the ledger (the transactions are the truth; balances are derived; audits need immutable history), usually with CQRS read models for balances and statements. Snapshots keep balance reads fast.
  3. CQRS without event sourcing. Keep a conventional write model for products, publish events via the outbox, and build read models: cached product pages, a search index, listing views and seller dashboards, each optimised for its query. Event sourcing adds little here.

Exercise 4

Show solutionHide solution

Message

Type

Owner

Consumers

TripCompleted

Event

Trip service

Pricing

CalculateFare (or pricing reacts to TripCompleted)

Command or reaction

Pricing

n/a

FareCalculated

Event

Pricing

Payment, Earnings

ChargeRider

Command (from a small saga or the payment service reacting)

Payment

n/a

RiderCharged / RiderChargeFailed

Events

Payment

Earnings, Receipts, Trip

DriverEarningsRecorded

Event

Earnings

Receipts, Driver app

ReceiptsSent

Event (optional)

Notifications

Analytics

RatingRequested

Event or a reaction to TripCompleted

Ratings

Apps

Idempotency:

  • Pricing: one fare per trip: unique constraint on trip_id in the fares table.
  • Payment: charge with an idempotency key charge-{trip_id} at the provider and a unique trip_id on charges.
  • Earnings: unique (trip_id, driver_id) in the earnings ledger; the event id recorded in an inbox.
  • Notifications: record (trip_id, receipt_type, recipient) as unique before sending.
  • Ratings: one rating request per (trip_id, party).

All events use trip_id as the ordering key, are published through the outbox, and carry correlation ids so the trace of a trip can be followed end to end.

Where to go next

#

Sources & further reading

  1. learn.microsoft.com
  2. martinfowler.com
  3. learn.microsoft.com
  4. learn.microsoft.com
  5. martinfowler.com
  6. martinfowler.com
  • Guide · Technology

    Start Here: Your Roadmap from Zero to Job-Ready DSA

  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages