Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
- 12Relational Databases and Data Modeling for System Design
- 13Database Indexes and Query Performance: How to Make Reads Fast
- 14Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions
- 15Database Replication: Primary-Replica, Read Replicas, Lag and Failover
- 16Database Sharding and Partitioning: How It Works and When You Need It
- 17SQL vs NoSQL: Data Models and How to Choose the Right Database
6 · Distributed Systems
- 18Why Distributed Systems Are Hard: Partial Failure, Timeouts and Clocks
- 19CAP Theorem and Consistency Models Explained Without the Myths
- 20Idempotency, Retries and Message Delivery Guarantees
- 21Distributed Transactions: Two-Phase Commit, the Outbox Pattern and Sagas
- 22Distributed Locks, Leases, Leader Election and Consensus
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
- 33Design a Notification System: Email, SMS and Push at Scale
- 34Design a Chat System Like a Messaging App
- 35Design a News Feed: Fan-Out, Ranking and the Celebrity Problem
- 36Design Search Autocomplete (Typeahead)
- 37Design a Distributed Job Scheduler
- 38Design a Ticket Booking System Without Overselling
- 39Design a Payment System: Idempotency, Ledgers and Reconciliation
11 · Large-Scale Case Studies
- 40Design a Video Streaming Platform: Upload, Transcoding and Adaptive Streaming
- 41Design a Ride-Hailing System: Location, Matching and Real-Time Updates
- 42Design a Food Delivery Platform: Orders, Restaurants and Delivery Partners
- 43Design an E-Commerce System: Catalog, Cart, Checkout, Inventory and Flash Sales
- 44Design a Web Search Engine: Crawling, Inverted Indexes and Ranking
12 · Interview Readiness
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.
For
SDE-2 candidates and backend developers designing systems where several services react to the same business changes, or considering CQRS or event sourcing.
Level
Before you start
Read Message Queues (article 11), Idempotency (article 20), Distributed Transactions (article 21), especially the outbox pattern, and Monolith vs Microservices (article 26).
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: | Past: | Question: |
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).
Order service --OrderPlaced--> broker --+--> Inventory (reserve stock) +--> Notifications (confirmation email) +--> Order history read model +--> Analytics +--> Loyalty (award points) <- added later, no change to OrderBenefits
#- 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:
{"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:
{"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, notPaymentServiceUpdatedRow); - 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 (
OrderPlacedv2), 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).
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).
// 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"); }}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 eventsWithout 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:
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, LoyaltyCommands 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 aChargePaymentcommand". - 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:
ReserveSeatssent by the booking orchestrator to the inventory service.UserRegisteredpublished by the accounts service.SendWelcomeEmailpublished to a topic consumed by email, analytics and CRM services.UpdateSearchIndexpublished 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:
- An internal tool where HR staff edit employee contact details.
- A digital wallet that must show every transaction ever made and support audits.
- 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
- Command, correctly named: one recipient, can be refused if seats are taken.
- Event, correctly named: a fact; any number of consumers.
- 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. PublishUserRegisteredand let each react. - 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
- Neither. Simple CRUD on a small dataset; a normal relational model with an audit log table is plenty.
- 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.
- 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 |
|---|---|---|---|
| Event | Trip service | Pricing |
| Command or reaction | Pricing | n/a |
| Event | Pricing | Payment, Earnings |
| Command (from a small saga or the payment service reacting) | Payment | n/a |
| Events | Payment | Earnings, Receipts, Trip |
| Event | Earnings | Receipts, Driver app |
| Event (optional) | Notifications | Analytics |
| Event or a reaction to | Ratings | Apps |
Idempotency:
- Pricing: one fare per trip: unique constraint on
trip_idin the fares table. - Payment: charge with an idempotency key
charge-{trip_id}at the provider and a uniquetrip_idon 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
#- Next: Security in System Design: Authentication, Authorization, Encryption and Abuse Prevention, the last building-block article before the case studies.
- Later: the notification (33), news feed (35), payment (39) and food delivery (42) case studies are event-driven designs.
Sources & further reading
- Technology
- Software Engineering
- #distributed systems
- #event-driven architecture
- #Kafka
- #microservices
- #roadmap
- #scalability
- #system design
- #system design interview
Connected lessons
Linked from 1 guide
Other pages in the library that point here.
Keep reading
More in Technology →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

