CQRS and Event Sourcing
Finance asks a question the order saga cannot answer. A learner's wallet and the platform's tuition account moved money three weeks ago, the mutable 'balance' column says zero toda
CQRS and Event Sourcing
Advanced System Design & Software Architecture · Lesson 42 · Microservices and Enterprise Architecture
Lesson Overview
Finance asks a question the order saga cannot answer. A learner's wallet and the platform's tuition account moved money three weeks ago, the mutable balance column says zero today, and the ticket asks what the balance was on Tuesday and which posting changed it. An audit table written in the same transaction as the balance would have answered, if someone had treated that table as mandatory. A log of "we updated a row" in an application logger will not. The history is the product.
CQRS, command query responsibility segregation, splits the model that enforces invariants from the model that serves screens and reports. Event sourcing stores the command model's state as an append-only sequence of events and rebuilds the current state by folding them. The two ideas are related and they are not the same. You can expose a query table that is updated in the same database as a normal order row, which is CQRS without event sourcing. You can fold events on every read and skip a query model, which is event sourcing without a useful CQRS split, and it will not survive a statement download. CQRS in Microservices, Event Sourcing in Services, CQRS, and Event Sourcing introduce the split. This lesson applies it to TechLearningPro's ledger, and it says where the order saga must not copy it.
Lesson 32's saga already has a state column, compensations, and an outbox row committed in the same local transaction as the transition. That state column is the right model for a process. The ledger is a different model because finance will add a report you have not imagined, and because a correction is a new posting, not an UPDATE of last month. The event table is the system of record. A projection serves the statement. Kafka, from Message Queues and Event Streaming, carries copies to other services. It does not replace the table. Retention that deletes segments would delete the ledger.
The transfer of money between two accounts inside this one database stays one local transaction. Event sourcing is the storage format. It is not a reason to saga a double-entry you can commit together, and it is not a reason to open the circuit breaker from lesson 41 against your own projector.
Learning Objectives
By the end of this lesson you will be able to:
- Separate CQRS from event sourcing, and name a TechLearningPro use for each combination.
- Append ledger events under a per-account sequence so two concurrent debits cannot both succeed on a stale balance.
- Fold events into an aggregate that enforces "no unapproved overdraft," and treat a snapshot as a disposable cache of that fold.
- Build an idempotent statement projection, including a rebuild that does not truncate the table the UI is reading.
- Commit a double-entry transfer in one database transaction, and use the saga only where the other side is the payment provider.
- Publish ledger facts through the same outbox discipline as lesson 32, with the event row as the source of truth.
- Version event payloads with upcasters, keep personal data out of the stream, and refuse Kafka retention as the ledger's durability.
Prerequisites
- Lesson 32: local transactions, the order saga's state column,
payment_uncertain, and the outbox insert beside the business write. This lesson reuses that discipline and does not reopen 2PC. - Lesson 8's aggregates. The account is an aggregate. Its invariant lives in the fold, not in a check constraint you hope every writer remembers. Aggregates is the pattern language. The sequence number below is the implementation.
- Lesson 28's replication lag. Database Replication and Consistency is why a statement served from a replica can trail a command that just committed. The command response returns the new balance when the UI must not go backwards.
- Lesson 33's relay. The projector and the broker are consumers of a durable row, not extra writes beside it.
- Lesson 36's warning against a distributed lock where a unique constraint is the lock. Distributed Locking and Coordination stays off the hot path of a single-account debit.
- Enough PostgreSQL to use a primary key on
(account_id, sequence)and to recognize a unique violation as optimistic concurrency, not as a bug in the driver.
Detailed Core Concepts
CQRS is two models
A command changes state and is rejected when an invariant fails. A query returns a shape the screen needs and is allowed to be stale by a defined amount. Using one row for both forces every new report to add columns, indexes, and locks to the table the debit path updates. CQRS lets the write model stay small and the read model match the query.
For the ledger, the write model is the account aggregate: identity, currency, whether an overdraft is allowed, and the balance implied by the events. The read model is statement_line and account_balance, denormalized, with the columns the UI filters on. They can live in two schemas of one PostgreSQL cluster. A second database engine is a later move, when a report load actually threatens the write path. Starting with a new store because the pattern's diagram showed one is how the ledger gains a dual-write problem it did not have.
CQRS without event sourcing is already in the platform. Checkout writes the saga row. A search projection consumes OrderCompleted and stores a document. The order's source of truth remains the saga row, which you update in place. That is the right CQRS for orders. Rebuilding search from a scan of orders is acceptable because the saga row still has the current state. You do not need every intermediate seats_reserved transition in an event store to place the next order.
Event sourcing is the fold
Event sourcing makes the sequence of events the source of truth. The balance is not updated. It is derived. A debit command loads the account's events in order, folds them into memory, checks the invariant, and appends a new event if the debit is allowed. A crash after the append leaves the debit in history. A crash before the append leaves it absent. There is no third state where the balance moved and the history did not, because there is no balance column on the write side.
sqlCREATE TABLE ledger.account_event (account_id uuid NOT NULL,sequence bigint NOT NULL,event_type text NOT NULL,payload jsonb NOT NULL,occurred_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY (account_id, sequence));
sequence starts at 1 for each account and increases by one. The primary key is the optimistic lock. Two commands read sequence 4, both try to insert sequence 5, one commits, the other gets a unique violation and must reload and decide again. You do not take a distributed lock around the account. The constraint is the lock, and it covers exactly the aggregate.
Events you need for the wallet:
| Event | Payload | Invariant it respects |
|---|---|---|
AccountOpened | currency, overdraft allowed or not | First event, sequence 1 |
AmountCredited | amount minor, currency, correlation id | Currency matches the account |
AmountDebited | amount minor, currency, correlation id | Resulting balance ≥ 0 unless overdraft is allowed |
HoldPlaced | amount minor, hold id | Available balance covers the hold |
HoldReleased | hold id | The hold exists and is not already released |
Amounts are long minor units. Floats are not an event field. occurred_at is when the business says the posting happened. The order of events is sequence, not the timestamp. Clocks move backwards. Sequences do not.
A correction is a new event, AdjustmentPosted, that references the sequence it corrects. Deleting or updating a prior row destroys the audit. The fold interprets the adjustment. Support does not open a SQL console and edit payload.
Snapshots are disposable
Folding ten events is cheap. Folding a hundred thousand on every debit is not. A snapshot stores the folded state at a sequence:
sqlCREATE TABLE ledger.account_snapshot (account_id uuid PRIMARY KEY,sequence bigint NOT NULL,balance_minor bigint NOT NULL,currency char(3) NOT NULL,overdraft_ok boolean NOT NULL,taken_at timestamptz NOT NULL);
Load the snapshot, then SELECT … WHERE account_id = ? AND sequence > ? ORDER BY sequence. The snapshot is a cache. A test that deletes the snapshot row and still debits correctly proves the events are the record. A snapshot written without its sequence, or written past an event you failed to fold, is worse than no snapshot. If the snapshot's sequence is not an event you actually have, discard it and fold from 1. Do not "repair" the balance in the snapshot table and leave the events alone. The next rebuild will overwrite the repair, or it will not, and then you have two truths.
Take a snapshot every N events inside the same transaction as the append that crosses the boundary, or asynchronously after commit. Same-transaction snapshots add write latency and cannot be lost relative to that event. Asynchronous snapshots are an optimization that may lag. Either way they are not read by finance as evidence. Evidence is the event stream.
Projections are CQRS's read side
sqlCREATE TABLE ledger_read.statement_line (account_id uuid NOT NULL,sequence bigint NOT NULL,event_type text NOT NULL,amount_minor bigint NOT NULL,balance_after_minor bigint NOT NULL,correlation_id uuid,occurred_at timestamptz NOT NULL,PRIMARY KEY (account_id, sequence));
The projector applies events in order and inserts a line. The primary key makes the apply idempotent: a replayed event hits the conflict and is skipped, or the statement uses ON CONFLICT DO NOTHING. Store the last applied sequence per consumer if you project asynchronously, and do not apply sequence 12 before sequence 11. A gap means you wait, or you stop and alert. Skipping a gap silently understates the balance.
Two delivery styles:
Same transaction as the append. The command inserts the event and the statement line together. The account holder who just moved money reads a balance that matches. A bug in the projector rolls the event back, which is what you want on the synchronous path. The cost is that every extra projection lengthens the debit transaction. Keep this path to the one view the wallet UI needs.
Asynchronous projection. A relay reads events the way lesson 33 reads the outbox and applies them to the finance warehouse and the PDF statement. The command commits before those views move. They will be stale. Lesson 28's lag applies if you also serve them from a replica. The UI that must not go backwards uses the command response, which returns the new balance from the in-memory fold, or reads the synchronous projection from the primary. Do not point the "you just paid" screen at the warehouse.
Rebuild is a requirement, not an emergency procedure. You will ship a projector bug. The events are still right. Truncating statement_line while the UI reads it shows an empty wallet. Build statement_line_v2, fold from sequence 1, and swap the name in a short transaction, or swap a view. Then drop the old table. If you cannot rebuild, you do not have event sourcing. You have a log you are afraid to read.
The outbox is the event row
Lesson 32 inserted into commerce.outbox in the same transaction as the saga update because the saga row was the truth and the message was a copy. Here the event row is the truth. Publishing to Kafka in the command method, beside the insert, is the same dual write: the process can die after the insert and before the send, or send and then fail the insert. A relay reads account_event in sequence order, publishes, and records the published sequence. A crash after publish and before that record publishes again. Consumers deduplicate on (account_id, sequence). That is at-least-once, which lesson 37 already refused to call exactly-once.
Do not invent a second outbox that copies the event payload unless the relay cannot read the event table. A second copy that can diverge is a second ledger. If another service needs the fact, it consumes the topic the relay fills. It does not connect to ledger.account_event. Payment still does not let the ledger open payment's tables. The saga emits a command, "post tuition," through its own outbox. The ledger performs the local append and replies with the sequences it wrote. The correlation id is the order id. The ledger does not import Order.
What stays a saga
A transfer between the learner wallet and the platform tuition account is one ledger service and one database. Append both legs in one transaction. If the debit aggregate and the credit aggregate are separate streams, lock the debit account's latest sequence (INSERT the next sequence) and insert the credit in the same transaction, rolling back both if either invariant fails. A unique violation on either stream aborts the transaction. You do not emit AmountDebited, commit, and then hope the credit succeeds in a second service. That would be a saga inside a single database, with a window where money has left one account and not reached the other, for no operational gain.
The payment provider remains outside that transaction. The saga in lesson 32 owns uncertainty about the card. Only when the provider result is known does checkout send "post tuition" or "post the refund." The ledger does not append AmountCredited while the capture is payment_uncertain. You may append nothing, and let the saga retry the command later, idempotently, with the order id as the correlation id so a redelivery does not credit twice. An event type SettlementPending is a product decision if the UI must show "money incoming." It is not a way to guess that the card succeeded.
Kafka as the only store fails the retention test. A topic with a week of retention forgets a Tuesday in March. Compacted topics keep the latest value per key, which is a balance, not a history. Infinite retention plus a disciplined compaction policy can be engineered into a store, and it is a different system from the bus in lesson 33. TechLearningPro's record is the PostgreSQL table, backed up with the ledger's RPO. The topic is a feed.
Upcasting and personal data
Event payloads change. Version them. An upcaster reads AmountDebited version 1, where amount was a decimal string, and returns the in-memory version 2 with amountMinor and currency. The stored bytes stay. Rewriting history in place to "migrate" JSON removes the ability to prove what was recorded. A snapshot has a schema version too. If the upcaster does not understand the snapshot, drop the snapshot and fold events.
The learner's name, email, and card number do not belong in payload. Store the learner id, which is already how lesson 38 separated catalogs of facts. A deletion request then does not require rewriting the ledger. If a payload already contains an email, you have an incident: the fix is a new event stream policy, a redaction process you would rather not run, and a review of the relay so the topic does not spread the same payload. Crypto-shredding is the heavy tool when the data is already in the bytes. The design goal is to not need it.
Architecture and Design Diagrams
mermaidflowchart TDcmd[Debit or transfer command]agg[Fold aggregate from snapshot plus events]ev[ledger.account_event append]snap[ledger.account_snapshot]sync[ledger_read.statement_line same transaction]relay[Relay in sequence order]topic[Kafka topic copy]warehouse[Finance projection]saga[Order saga]cmd --> agg --> evev --> snapev --> syncev --> relay --> topic --> warehousesaga -->|post tuition command, correlation id| cmd
The saga never writes account_event. The warehouse never writes it either. The relay does not decide whether a debit was legal. That decision already happened in the fold, before the append.
mermaidsequenceDiagramparticipant S as Order sagaparticipant L as Ledger commandparticipant DB as PostgreSQLparticipant R as RelayS->>L: PostTuition orderId, amountL->>DB: BEGINL->>DB: INSERT debit and credit eventsL->>DB: INSERT statement linesL->>DB: COMMITL-->>S: sequencesR->>DB: read unpublished sequencesR->>R: publish, then mark published
A unique violation inside the transaction rolls back both legs. A relay crash after publish redelivers. The warehouse insert conflicts on (account_id, sequence) and keeps one line.
Technical Explanation
The fold and the command
javapublic final class Account {private final UUID id;private final String currency;private final boolean overdraftOk;private long balanceMinor;private long sequence;public static Account fold(List<AccountEvent> events) {Account account = new Account(events.get(0));for (int i = 1; i < events.size(); i++) {account.apply(events.get(i));}return account;}public AmountDebited debit(long amountMinor, UUID correlationId) {if (amountMinor <= 0) {throw new IllegalArgumentException("amount");}if (!overdraftOk && balanceMinor - amountMinor < 0) {throw new InsufficientFunds(id);}return new AmountDebited(id, sequence + 1, amountMinor, currency, correlationId);}}
debit does not touch JDBC. It returns the event that should exist next, or it throws. The application service loads events, folds, calls debit, and inserts. A unique violation means someone else appended. The service reloads a bounded number of times and then fails the command. An infinite reload loop is a livelock under contention. The wallet's hot account is the platform tuition account if every purchase credits it. That hotspot is real. Shard the platform side by posting to per-day settlement accounts, or accept a single-row sequence as the throughput ceiling and measure it. Event sourcing does not remove contention on one aggregate. It makes the contention visible as a key.
InsufficientFunds is a business rejection. It does not open the payment circuit breaker. It does not retry. The saga compensates the seat if the tuition posting was required and the wallet cannot pay, which is a product path you write down. For card checkout, the wallet posting happens after capture, so insufficient funds on the platform credit side is a bug, not a shopper decline. Alert on it.
Idempotent post from the saga
sqlINSERT INTO ledger.account_event (account_id, sequence, event_type, payload)SELECT :accountId, COALESCE(MAX(sequence), 0) + 1, 'AmountCredited', CAST(:payload AS jsonb)FROM ledger.account_eventWHERE account_id = :accountId;
That statement is not safe under concurrency: two transactions can compute the same next sequence. Use a per-account head row updated with UPDATE ledger.account_head SET sequence = sequence + 1 WHERE account_id = :id RETURNING sequence, which locks the head row, or insert with a sequence you computed and treat the unique violation as the retry signal. The head row is a serialization point you own inside one database. It is not a distributed lock.
The saga's redelivery uses correlation_id inside the payload and a unique index on (account_id, correlation_id, event_type) so the same PostTuition cannot credit twice even if the sequence logic is retried at the command level. The sequence primary key stops lost updates. The correlation unique index stops duplicate business commands. You want both. Lesson 37's idempotency key is this index, scoped to the ledger.
Same-transaction statement line
Inside the transaction that inserted the event, insert the statement line with the balance the fold just computed. If that insert throws, the event rolls back and the caller sees a failure, not a missing line. The asynchronous warehouse is allowed to lag. The wallet screen is not, so it reads ledger_read.statement_line on the primary or trusts the command response. A replica for statements is fine for "download last year's PDF," where lesson 28's lag is invisible, and wrong for "refresh after I clicked transfer."
Rebuild and upcast
A projector bug understated balance_after_minor for a week. The repair is a new projection built by folding from sequence 1 through the upcaster, not an UPDATE of the bad lines. The upcaster runs on read during the rebuild. Old payloads remain. When the new table is caught up, swap. Commands may continue during the rebuild because they append events the rebuild has not reached; the rebuild must include events committed after it started, which means it runs until it is within a small gap and then locks briefly to catch the tail, or it follows the same relay cursor the live projector uses. Plan that tail. A rebuild that copies "all events as of noon" and swaps at 1 p.m. drops an hour of postings.
What the order model still does not do
The order saga remains a row with a state check constraint in spirit, the conditional UPDATE … WHERE state = 'seats_reserved' from lesson 32. Turning every saga transition into an event-sourced aggregate adds a fold to a process that already had an explicit state machine, and it tempts people to "replay the saga" instead of writing compensation_failed. Do not event-source the order because the ledger worked. Do not store the ledger as a mutable balance plus a log line you sometimes forget to write. The test is whether losing the derived table loses the truth. For the saga, losing the row loses the truth, so the row is the truth. For the ledger, losing the statement loses a view, so the events are the truth.
Practical Implementation or Design Walkthrough
Problem
Learners hold a wallet. Captured course purchases credit a platform tuition account and, when the product says so, debit a promotional wallet. Finance needs a statement and the ability to add a new report without rewriting history. Card capture stays in the payment saga.
Requirements
- A debit that would go negative without an overdraft flag is rejected and appends nothing.
- Two concurrent debits of the last unit of balance: one event, one unique-violation retry that then rejects or appends a smaller amount, never two successful debits.
- A transfer between two ledger accounts is atomic in one database transaction.
- A repeated
PostTuitionwith the same order id does not double-credit. - The wallet UI sees the new balance without reading a replica.
- A projection can be rebuilt beside the live one.
- No learner email in
payload. Kafka is not the record.
Steps
- Create
ledger.account_eventand the head row or the insert-with-retry path. Add the correlation unique index. - Implement
Account.foldanddebitwith no JDBC. Unit-test the invariant on a list of events, including a replay after a snapshot delete. - In one
@Transactionalmethod, append both legs of an internal transfer and the synchronous statement lines. Confirm a failure in the second insert leaves zero new events. - Accept
PostTuitiononly from the saga, after capture is known. Idempotency is the correlation index. Do not call the card provider from the ledger. - Run a relay that publishes committed sequences and records progress. Consumers use
ON CONFLICT DO NOTHING. - Build
statement_line_v2from sequence 1, swap, and drop the old table in a staging drill before you need it in production. - Point the post-transfer read at the primary projection or the command response. Point historical PDFs at a replica if you must.
Failure handling
Unique violation on sequence: reload, refold, retry a bounded number of times. Unique violation on correlation id: return the original sequences, do not append. Projector exception on the synchronous path: the transaction rolls back, the command fails, the saga retries the post later with the same correlation id. Projector exception on the warehouse path: the event stays, the relay retries, the warehouse deduplicates. A wrong synchronous line that committed is a defect in the same transaction as the event; fix the bug and rebuild. Do not hot-patch the line.
payment_uncertain produces no ledger event. When the saga later learns the capture failed, it posts nothing. When it learns the capture succeeded, it posts once.
Validation
- Fold of a known script of events equals the expected balance. Delete snapshots and repeat.
- Concurrent debits, balance covers only one: one row in
account_event, oneInsufficientFunds. - Kill the process after commit and before relay publish: the row remains, the relay sends it later, the consumer has one line.
- Replay the same
PostTuition: one credit. - A payload assertion fails the build if
emailorpanappears in a sample event. - Rebuild test: corrupt a copy of the projection, rebuild from events, checksums match.
- A transfer test aborts the transaction on a forced second-leg failure and shows no debit event left behind.
Enterprise Real-World Scenarios
Scenario 1: The Tuesday balance
Context. A learner disputes a wallet deduction. Support can see today's balance and a vague log line. Finance wants the statement as of Tuesday 18:00 and the correlation id that ties the posting to an order.
Design. The statement projection, rebuilt if necessary, filters occurred_at and sequence. The correlation id joins to the saga's order id without a foreign key across services. The event payload has amounts and ids. The learner's email is looked up in the profile service, which is allowed to change later without rewriting the ledger. The dispute is resolved by showing the event, not by editing it. If the deduction was wrong, AdjustmentPosted credits the wallet with a new sequence and a reason code.
What you refuse. An update of balance_after_minor in production to "make the ticket go away." The next rebuild restores the old number, or the events and the view diverge forever. Either result is an audit finding.
Scenario 2: Catalog prices do not become an event-sourced aggregate
Context. A team that liked the ledger proposes event sourcing for course prices, progress percentages, and the saga itself, with Kafka as the store so "everything is events."
Design. Catalog prices stay a mutable row plus the snapshot copied onto the order at pay time, from lesson 38. The current price is the truth the editor expects to update. Progress is a high-volume current position; the product does not replay every heartbeat. The saga keeps its state column. Kafka remains the bus, with the retention lesson 33 chose for a bus. The ledger is the exception because the history is the business record and the writers are inside one service you operate. Event Sourcing describes the pattern. The pattern's cost is the projector, the upcaster, and the rebuild drill. Pay that cost where Tuesday's balance matters.
Best Practices
- Decide whether the derived view or the current row is the truth. Event source only the former.
- Keep commands free of JDBC. Fold, decide, then append in one transaction.
- Use
(account_id, sequence)as the conflict that serializes the aggregate. Add a correlation unique index for the business command. - Store minor units and a currency. Do not store floats, emails, or card numbers.
- Treat snapshots as caches with a sequence. If they disagree with events, events win.
- Make every projection rebuildable beside the live table. Practice the swap.
- Update the wallet view in the command transaction. Leave the warehouse asynchronous.
- Publish by relaying the event table. Do not dual-write to Kafka.
- Commit both legs of an internal transfer in one database. Saga the provider, not the second row in the same ledger.
- Do not append a credit while payment is
payment_uncertain. - Version payloads. Upcast on read. Do not rewrite stored events in place.
- Return the new balance from the command when the UI must read its own write.
- Bound concurrency retries. Alert on hot accounts instead of hiding them behind a distributed lock.
- Keep the order saga as a state machine. Do not fold it because the ledger folds.
Common Mistakes and Anti-Patterns
Event sourcing the entire platform. Every service grows a projector, an upcaster, and a rebuild job. Catalog edits become appends nobody replays. You pay operational cost where a row update was the product. Choose the ledger. Leave the rest on CRUD plus the saga.
Mutable balance plus a best-effort log. The log insert sits outside the transaction, or in a catch block. The balance moves, the history does not, and you discover it during the dispute in scenario 1. If you are not going to event source, write the audit row in the same transaction as the balance and accept that rebuilds of new shapes are migrations. Do not claim you have events.
Kafka as the ledger. Retention deletes the Tuesday you needed, or compaction keeps only the latest balance. The bus is a copy. The table is the record. A consumer that "rebuilds from the beginning of the topic" is bounded by the topic's retention, which is not an audit policy.
Editing history. An UPDATE of payload or a delete of a sequence breaks the fold and the correlation index. Corrections are new events. Snapshots that were hand-edited are discarded.
Sagas between two tables in the same ledger database. Debit commits, credit fails, money vanishes until a compensator runs. One transaction was available and you refused it. Save the saga for the card provider, where a transaction cannot enlist.
Projector bug fixed in the read table only. The view is right until the next rebuild, then it is wrong again, or you are now afraid to rebuild and the bug becomes permanent. Fix the fold, rebuild into a new table, swap.
Global queries against the event table. "All accounts with a balance over 100" folds the entire history on the request path. That query belongs to a projection. If the projection does not exist, the API does not offer the query.
Duplicate credits on saga retry. No unique correlation id. The relay and the saga both cause PostTuition to run twice, and both inserts succeed with new sequences. The index on (account_id, correlation_id, event_type) is the fix you can test by publishing the command twice.
Troubleshooting and Design Review
| Symptom | Evidence | Likely cause | Action |
|---|---|---|---|
| Two debits, balance negative, overdraft off | Two events, no unique failure | Sequence assigned outside a lock or constraint | Head row or primary key, retry on conflict |
| Statement empty, events present | Truncate in place | Rebuild on the live table | Rebuild beside, then swap |
| Wallet UI goes backwards after a transfer | Read from replica or warehouse | Stale projection | Return the command's balance or read the synchronous view on the primary |
| Duplicate tuition credit | Two events, one order id | Redelivery without a correlation constraint | Unique index, return the original on conflict |
| Tuesday balance unknowable | Only a mutable balance | No event record | You cannot reconstruct what you did not store; fix forward with adjustments and events |
| Relay lag, commands healthy | Unpublished sequence age | Relay down | Run the relay. Do not publish ad hoc and also leave the cursor |
| Fold throws on old rows | Payload shape changed | No upcaster | Upcast on read; leave stored bytes |
| Transfer shows a debit and no credit | Two transactions | Saga inside one database | One transaction for both legs |
| Email appears on the topic | Payload sample | PII in the event | Stop the relay, rotate the topic's consumers, change the payload |
Design review: What is the source of truth if the projection is dropped? What conflict fires when two commands append together? Can you rebuild beside the live read model? Which writes are in the command transaction, and which are allowed to lag? How does a repeated saga command avoid a second credit? Where is the provider uncertainty kept, and is it outside the ledger? What happens to an old payload version? Which fields would make a deletion request rewrite history?
Interview Questions and Answers
Q1. How do CQRS and event sourcing differ, and when would you use only one?
CQRS uses a write model shaped for invariants and a read model shaped for queries. Event sourcing stores state as an append-only event sequence and derives current state by folding. You can update a saga row in place and project search from OrderCompleted, which is CQRS without event sourcing, and it is the right order model. You can fold events on every command and also write a statement table, which is both, and it is the right ledger. Event sourcing without a projection forces every screen to fold history. CQRS and Event Sourcing are the pattern pages. The choice here is per capability, not per company.
Q2. How does a concurrent debit lose safely?
Each account has a monotonic sequence. The insert's primary key is (account_id, sequence). Two commands that both computed the same next sequence: one commits, the other gets a unique violation, reloads, and folds again. A head row updated with UPDATE … SET sequence = sequence + 1 … RETURNING serializes the same way inside one database. A distributed lock around the account adds coordination from lesson 36 without a stronger invariant. If the reloaded fold shows insufficient funds, the command throws and appends nothing.
Q3. What is a snapshot allowed to be?
A cache of the fold at a recorded sequence, so a debit loads the snapshot and the events after it. It is not evidence and not the balance you edit. If it is missing, corrupt, or ahead of the events, fold from the start or from the last snapshot whose sequence exists. A test deletes snapshots and still produces the same decision. Writing the snapshot in the command transaction keeps it aligned. Writing it later is allowed if you treat a miss as a full fold.
Q4. How do you rebuild a projection without emptying the wallet UI?
Project into a new table from sequence 1 through an upcaster, follow the tail of events committed while you were building, and swap a view or a name in a short transaction. Truncating the live statement shows a zero balance and breaks read-your-writes. A rebuild that stops at a timestamp and swaps later drops the gap. The ability to rebuild is what makes the events the source of truth. A team that cannot rebuild is depending on the projection, whether they admit it or not.
Q5. Why is an internal transfer not a saga?
Both accounts are in the ledger database. One local transaction can append the debit event, the credit event, and the synchronous statement lines, and roll all of them back on a unique violation or an invariant failure. A saga would commit the debit and compensate later if the credit failed, leaving a window where the funds exist in neither account, inside a system you operate. Lesson 32 uses a saga because the card provider cannot prepare with PostgreSQL. Do not import that pattern into two inserts. The provider boundary stays in the saga: no ledger credit while the capture is payment_uncertain.
Q6. How does this use the outbox from lesson 32?
The saga still writes its own outbox when it sends PostTuition. The ledger's source of truth is account_event, inserted once. A relay publishes those rows in order and records progress, accepting duplicates. Consumers deduplicate on (account_id, sequence). A second outbox table that copies the payload can diverge and becomes a second record. Publishing inside the command method, next to the insert, reopens the dual write the outbox exists to close. Message Queues and Event Streaming owns the relay's broker behavior. This lesson owns the decision that the event row is what the relay reads.
Q7. Why is Kafka with a week of retention not an event store?
The store must still contain Tuesday's posting when finance asks next quarter. Bus retention is chosen for consumers' lag, not for audit. Compaction keeps the latest value per key, which deletes the history you were sourcing. You can operate a log as a store only with a retention and backup policy that matches the ledger's RPO, and that system is not the topic checkout uses for OrderCompleted. PostgreSQL holds the events. The topic carries copies.
Q8. How do old events stay readable after a payload change?
Store a version. An upcaster turns version 1 into the current in-memory record on read and during rebuild. The bytes in account_event stay as they were written, so you can defend what was decided then. Snapshots carry a version and are discarded when they cannot be upcast. Rewriting JSON in place is an edit to history. It belongs in the same category as updating payload by hand.
Q9. A learner invokes a privacy deletion. What did you store?
Learner id, amounts, currency, correlation ids. Not the email, not the name, not the card number. The profile service can delete the email without touching the ledger. If the payload contains the email, the relay has probably copied it to a topic and a warehouse, and you are in a redaction incident. The aggregate design from lesson 38, one writer and no extra personal data, is what makes the ledger retainable. Crypto-shredding is the fallback when the design already failed, not the plan.
Q10. When should the UI read the command result instead of the projection?
When the person who sent the command must see the new balance immediately: the transfer button, the wallet refresh on that device. The command returns the folded balance, and the synchronous statement line is on the primary. The warehouse and any replica are for reports and historical PDFs, where lag is acceptable under lesson 28. Serving "you just paid" from an asynchronous projection produces a backwards balance and a second click.
Q11. Why is event sourcing a poor default for the order saga and for catalog prices?
The saga's truth is the current state and the next compensation, already a conditional update. Replaying transitions does not make payment_uncertain easier. Catalog's truth is the current price, and the order stores a snapshot from lesson 38 so later edits do not rewrite invoices. Neither needs an upcaster or a rebuild drill. The ledger does, because the dispute is about the sequence of postings. Using one storage fashion for all three guarantees the catalog team runs a projector they never rebuild and the saga team debugs a fold instead of a state column.
Hands-On Exercises
Exercise 1: Wallet debit and statement
Problem. A promotional wallet may be debited until it hits zero. The UI shows a statement. Two debits race.
Tasks.
- Define the events and the fold invariant.
- Show the constraint or head-row update that allows one winner.
- Specify the synchronous statement write and how a rebuild swaps in a new table.
- Say what the command returns to the UI, and which store a year-old PDF may use.
Deliverables. The schema, the fold rules, the transaction boundary, and the rebuild steps.
Exercise 2: Tuition posting from the saga
Problem. PostTuition arrives after capture, and it may arrive twice. The credit is the platform account and the debit is the learner wallet, or the posting is a single credit from outside the wallet if the card paid. Pick one and stick to it.
Tasks.
- Explain why the card timeout does not append an event.
- Show the unique correlation index and the response on a duplicate command.
- Explain why this is not a second saga between the two ledger rows when both are in the wallet product, and when it would become a saga if a partner bank held one of the accounts.
Rubric.
| Area | Weight |
|---|---|
| CQRS versus event sourcing, ledger versus saga versus catalog | 20% |
| Sequence conflict, correlation idempotency, invariants | 25% |
| One transaction for internal legs; uncertainty stays in the saga | 20% |
| Rebuildable projection and read-your-writes | 20% |
| Relay over the event row; no PII; no Kafka-as-record | 15% |
Lesson Summary
CQRS splits the model that decides from the model that displays. Event sourcing makes the ledger's decisions a sequence of immutable events, folded under a per-account sequence so concurrent debits conflict instead of overdrawing. Snapshots and statement tables are derived. The wallet's statement updates in the command's transaction. The warehouse follows on a relay. Rebuild beside the live table, upcast old payloads, and keep emails out of the stream.
An internal transfer commits both legs in one PostgreSQL transaction. The order saga remains the owner of card uncertainty and does not event-source itself. It sends PostTuition once the capture is known, and the correlation id makes that command idempotent. Kafka carries copies. The event table, not the topic, is what finance can still read when the dispute arrives.
You now have the boundary from lesson 38, the way a process is found and configured from lesson 39, the public contract from lesson 40, the failure policy from lesson 41, and the ledger's storage from this lesson. The saga and the outbox from lesson 32 sit in the middle of that set: process state for checkout, event history for money, and a relay rather than a dual write for both.
Further Reading
- Greg Young, CQRS and event sourcing talks and Versioning in an Event Sourced System, for upcasting and why you do not edit the log.
- Martin Fowler, CQRS and Event Sourcing.
- PostgreSQL primary keys and unique indexes as optimistic concurrency.
- Chris Richardson, Microservices Patterns, event sourcing and transactional outbox, read next to lesson 32 rather than instead of it.
- This course: Distributed Transactions and Saga Pattern, Message Queues and Event Streaming, Database Replication and Consistency, Idempotency and Exactly-Once Processing, Domain-Driven Design Fundamentals.
- Primers: CQRS in Microservices, Event Sourcing in Services, CQRS, Event Sourcing, Outbox Pattern.