Event sourcing, CQRS and the transactional outbox
Eliminating the dual-write hazard using Change Data Capture (CDC) and Command Query Responsibility Segregation.
What you will learn
- Explain why writing to a database and publishing to a broker separately goes wrong.
- Fix it with a transactional outbox and a relay.
- Say what event sourcing and CQRS are, and when CQRS is worth its complexity.
The dual-write hazard
In microservice architectures, an application frequently needs to update its local database and publish an event to a message broker:
// ANTI-PATTERN: Non-atomic dual write
async function placeOrder(order: Order) {
await db.orders.insert(order); // Step 1: Database Commit
await kafka.publish('orders.v1', order); // Step 2: Message Publish
}
Why dual-writes inevitably fail:
- If Step 1 succeeds and the network fails or app crashes before Step 2, the order exists in DB but downstream inventory, shipping, and notification services never receive the event (Ghost Order).
- If Step 2 executes first and DB commit fails on constraint violation, external services charge credit cards for an order that does not exist.
The Transactional Outbox Pattern
The Transactional Outbox Pattern solves the dual-write hazard by leveraging the database's local ACID transaction guarantee:
- Atomic Insertion: In a single ACID transaction, write both the business entity (
orderstable) and an outbox record (outboxtable):BEGIN TRANSACTION; INSERT INTO orders (id, user_id, total, status) VALUES ('ord_99', 'usr_1', 149.00, 'CREATED'); INSERT INTO outbox (id, aggregate_type, payload, created_at) VALUES ('evt_99', 'Order', '{"id":"ord_99","total":149.00}', NOW()); COMMIT; - Message relay: A separate process publishes the outbox rows to the broker. It can poll the outbox table, or use change data capture: Debezium, for example, reads the database's change log (MySQL's binlog, PostgreSQL's logical replication stream) and sends the changes to Kafka.
- Delivery: An event is published only if its transaction committed, and is not lost if the service crashes afterward, all without a two-phase commit. But the relay may publish a message more than once, so consumers must be idempotent (lesson 7.2).
Event sourcing
Event sourcing goes further: instead of storing only the current state, capture every change to the application's state as a sequence of events, and treat that sequence as the source of truth. You can then discard the current state and rebuild it by replaying the events, or work out what the state was at any point in the past. The events also make a natural stream for other services to consume.
CQRS: command query responsibility segregation
CQRS uses a different model to update information than the model you use to read it:
- Command side (writes): A model built to enforce the business rules, often a normalized, transactional schema.
- Query side (reads): One or more read models shaped for the queries, often denormalized and possibly in different stores (a search index, a cache), kept up to date asynchronously from the write side's events, so reads may briefly lag writes.
Martin Fowler, who popularized the pattern's description, warns that for most systems CQRS adds risky complexity: use it only on specific parts of a system where the read and write needs really differ.
- Web & API Clients: 1 × Clients & Users
- Command API (Write Path): 4 × App Compute Server
- PostgreSQL + Outbox Table: 2 × Primary Relational DB
- Debezium / Kafka Connect: 2 × App Compute Server
- Kafka Event Broker: 3 × Message Queue / Kafka
- CQRS Read View (Elastic/Redis): 2 × Distributed NoSQL Store
- Web & API Clients sends requests to Command API (Write Path)
- Command API (Write Path) sends requests to PostgreSQL + Outbox Table
- PostgreSQL + Outbox Table sends requests to Debezium / Kafka Connect
- Debezium / Kafka Connect sends requests to Kafka Event Broker
- Kafka Event Broker sends requests to CQRS Read View (Elastic/Redis)
Key takeaways
- A crash after the commit, or a failed commit after the publish, leaves the database and the broker disagreeing.
- Writing the event to an outbox table in the same transaction publishes it only if the change committed; the relay may publish twice, so consumers must be idempotent.
- Event sourcing keeps every change as an event and can rebuild state by replaying them.
- CQRS separates the write model from read models updated asynchronously, so reads may lag; use it only where read and write needs really differ.
Sources
- System Design Primer: Asynchronism
- Chris Richardson, microservices.io: Pattern: Transactional outbox
- Debezium documentation: Architecture
- Martin Fowler, "Event Sourcing"
- Martin Fowler, "CQRS"
Adapted from The System Design Primer by Donne Martin, licensed under CC BY 4.0. Changes were made.