By separating the databases we have knowingly lost the piece that made the monolith comfortable: the single transaction of createOrder, that BEGIN ... COMMIT that reserved stock, created the order, recorded the payment and, if anything failed, undid it all with a ROLLBACK. Now the reservation lives in Inventory's database, the payment in Payments' and the order in Orders', and there is no ROLLBACK spanning all three. The schema in 02-04 left the clues: STOCK_RESERVED and PAID statuses in orders, expires_at in reservations, a cancellation_reason. This lesson explains what replaces that transaction.
We will see why there are no ACID transactions across services (and why two-phase commit is ruled out), what the CAP theorem and eventual consistency mean in practice, the saga pattern in its two variants (choreography and orchestration), the full design of TechCorp's "create order" saga with the course's events, its compensating transactions and the order's state machine, the reasoned decision to start with choreography, the transactional outbox pattern that guarantees an event is published if and only if the change was saved, consumer idempotency, and the two ideas that usually accompany sagas: CQRS (separate write and read models) and event sourcing (storing events instead of state), with TechCorp's decision on each. All at the design level: how RabbitMQ is configured and how an event is published from Node.js is covered in 03-02 and 04-04.
Contents
- Why there are no ACID transactions across services
- The CAP theorem and eventual consistency, in practical terms
- The saga pattern: choreography versus orchestration
- TechCorp's "create order" saga
- The order's state machine
- TechCorp's decision: choreography to start with
- The transactional outbox pattern
- Consumer idempotency and idempotency keys
- CQRS: separating the write model from the read model
- Event sourcing: storing the facts instead of the state
- Why there are no ACID transactions across services
An ACID transaction (atomic, consistent, isolated, durable) is a promise that one database engine makes about its data. As soon as the data lives in two engines (Orders' PostgreSQL and Inventory's PostgreSQL, or PostgreSQL and MongoDB), neither can promise anything about the other.
There is a protocol for coordinating several databases in a single transaction: two-phase commit (2PC). A coordinator asks all participants "can you commit?" (phase 1: prepare); if they all say yes, it orders "commit" (phase 2); if any says no, it orders "abort". It sounds perfect, and in microservices it is almost always ruled out for these reasons:
| Problem with 2PC | Why it is serious in microservices |
|---|---|
| Long locks. Between phase 1 and phase 2, each participant keeps its rows locked waiting for the coordinator. | It is exactly the problem of the payment provider inside the createOrder transaction, but multiplied by the network: stock rows would stay locked while Payments talks to the payment provider. |
| The coordinator is a single point of failure. If it goes down between phases, participants are left "in doubt", locked until it comes back. | Violates design for failure: a coordinator outage paralyzes inventory, orders and payments at once. |
| Total temporal coupling. Everyone must be available at the same moment. | The availability of the whole is the product of the availabilities. |
| Uneven support. Not all stores support it (MongoDB does not participate in 2PC with PostgreSQL), nor do external systems (the payment provider is not going to "prepare" a charge). | TechCorp's flow includes an external payment provider: 2PC is impossible at the most delicate step. |
| Scales badly. Throughput drops with the number of participants and the latency between them. | Contradicts the scaling motive for which microservices are adopted. |
The industry's conclusion, and TechCorp's: across services there is no atomicity; there are sequences of local transactions, each atomic within its service, coordinated by messages, and with compensating actions to undo what was done when a later step fails. That is a saga.
- The CAP theorem and eventual consistency, in practical terms
The CAP theorem (Brewer) says that a distributed system, in the face of a network partition (P: two parts cannot communicate), must choose between consistency (C: everyone sees the same data at the same instant) and availability (A: everyone gets a response). Since partitions do happen (fallacy 1 from 01-02: the network is not reliable), the real choice is "when the network fails, would I rather respond with possibly stale data or not respond at all?".
For TechCorp's order flow, the answer is nuanced:
- Within each service, strong consistency:
inventory-servicewill never reserve more than there is (reserved <= quantityis a constraint in its database). - Across services, eventual consistency: for a few seconds, the order is PENDING in Orders while Inventory has already reserved; the website can show "processing" and the customer will receive the email when everything converges. It is acceptable because the business tolerates it: nobody needs to know in the same millisecond that stock and order agree.
"Eventual" means "guaranteed, but not instantaneous": if changes stop arriving, all copies end up matching. It does not mean "sometimes": a system that loses events is not eventually consistent, it is incorrect. Sections 7 and 8 (outbox and idempotency) are precisely what turns "eventual" into "guaranteed".
What changes for the team, put without theory: between the order being created and being confirmed, seconds may pass, and in that interval the system is in a legitimate intermediate state that has to be modeled, displayed and known how to undo. In the monolith that interval did not exist for anyone outside; now it is part of the design.
- The saga pattern: choreography versus orchestration
A saga is a sequence of local transactions T1, T2, ..., Tn, each in a different service, where each Ti publishes a message that triggers Ti+1. If Ti fails, the compensating transactions Ci-1, ..., C1 are executed to semantically undo what came before. "Semantically" matters: it is not a ROLLBACK (the charge was already made), it is an inverse business action (a refund).
There are two ways of coordinating the sequence:
| Aspect | Choreography | Orchestration |
|---|---|---|
| Who decides the next step | Nobody central: each service reacts to the events it cares about and publishes its own. | An orchestrator (a component inside a service, or a dedicated service) sends commands to each participant and waits for their replies. |
| Message style | Events ("X has happened"): order.created, stock.reserved. |
Commands ("do X"): reserveStock, charge, plus replies. |
| Where the flow logic lives | Spread out: Inventory knows that on order.created it reserves; Payments knows that on stock.reserved it charges. |
Concentrated in the orchestrator, which knows all steps and compensations. |
| Coupling | Low: services do not know each other, they only know events. | The orchestrator knows everyone; participants do not know each other. |
| Visibility of global state | Hard: it has to be rebuilt from the events (traces, 06-02). | Easy: the orchestrator has each saga's state in its table. |
| Risk | With many steps, nobody understands the whole flow ("who reacts to what?"); hidden cyclic dependencies. | The orchestrator becomes a central "brain" that accumulates logic from other contexts (createOrder is back under another name) if not disciplined. |
| Adding a step | Add a subscriber; nobody else is touched. | Modify the orchestrator. |
| Compensations | Each service subscribes to the failure/cancellation event and compensates its own part. | The orchestrator invokes them in reverse order. |
| When it fits | Short flows (3-5 steps), linear, with autonomous teams. | Long flows, with branches, deadlines, human intervention, or when you need to see "which step is order 88213 at" from a single place. |
Neither is "the right one". The practical rule: choreography by default for simple flows; orchestration when the flow grows or when visibility becomes a problem.
- TechCorp's "create order" saga
We design the saga with the course's events. The five main events are already known from 01-05; the event storming in 02-02 uncovered the failure and compensation events, which we now name with the same <context>.<fact> convention:
| Event | Published by | Meaning | Role in the saga |
|---|---|---|---|
order.created |
Orders | Order recorded as PENDING with lines, total and contact/shipping data. | Start (T1). |
stock.reserved |
Inventory | Reservation created for the order. | T2 succeeded. |
stock.rejected |
Inventory | Not enough stock for some line. | T2 failed. |
payment.confirmed |
Payments | The payment provider accepted the charge. | T3 succeeded. |
payment.rejected |
Payments | The payment provider rejected the charge. | T3 failed. |
order.confirmed |
Orders | Order complete (stock and payment OK). | Happy ending (T4). |
order.cancelled |
Orders | The order will not be completed; carries a reason. |
Triggers compensations. |
stock.released |
Inventory | Reservation released. | Compensation C2. |
payment.refunded |
Payments | Charge returned. | Compensation C3 (only if a charge was made). |
The steps, on the happy path and on the two failure paths:
sequenceDiagram
autonumber
actor C as Customer
participant GW as API Gateway :8080
participant ORD as orders-service
participant INV as inventory-service
participant PAY as payments-service
participant NOT as notifications-service
C->>GW: POST /orders {customerId, lines} + Idempotency-Key
GW->>ORD: POST /orders
Note over ORD: T1: validates customer and prices (Customers, Catalog)<br/>INSERT order PENDING + outbox(order.created)
ORD-->>C: 202 Accepted {orderId: ord-88213, status: PENDING}
ORD--)INV: order.created
alt stock available
Note over INV: T2: INSERT reservation ACTIVE, UPDATE stock.reserved
INV--)PAY: stock.reserved
INV--)ORD: stock.reserved
Note over ORD: status = STOCK_RESERVED
alt charge accepted
Note over PAY: T3: charge at payment provider, INSERT payment CAPTURED
PAY--)ORD: payment.confirmed
Note over ORD: T4: status = PAID -> CONFIRMED
ORD--)INV: order.confirmed
ORD--)NOT: order.confirmed
Note over INV: reservation CONSUMED, stock.quantity -= reserved
NOT->>C: email "order confirmed"
else charge rejected
PAY--)ORD: payment.rejected
Note over ORD: status = CANCELLED (reason PAYMENT_REJECTED)
ORD--)INV: order.cancelled
ORD--)NOT: order.cancelled
Note over INV: C2: reservation RELEASED, stock.reserved -= quantity
INV--)ORD: stock.released
NOT->>C: email "we could not charge you"
end
else out of stock
INV--)ORD: stock.rejected
Note over ORD: status = CANCELLED (reason OUT_OF_STOCK)
ORD--)NOT: order.cancelled
NOT->>C: email "product sold out"
end
Design observations, one by one:
- The response to the customer is
202 Acceptedwith status PENDING, not201with CONFIRMED as the monolith did. The order exists, but it is not complete; the customer queriesGET /orders/ord-88213(or receives a notification) to see the confirmation. It is the legitimate intermediate state from section 2, made visible in the contract. (Compared with the response in 01-01, wherePOST /ordersreturned201with PENDING: both are valid; from here on the course adopts202to underline that processing continues. The concrete API design is finalized in 03-01.) - T1 keeps the synchronous validations with Customers and Catalog (customer existence, current prices): without them there is no order to create. What leaves the HTTP request is everything else.
- Payments reacts to
stock.reserved, not toorder.created. Charging before knowing whether there is stock would force frequent refunds; reserving first and charging later minimizes expensive compensations. The saga's order is chosen by putting first the steps most likely to fail and cheapest to compensate. order.confirmedhas two consumers: Notifications (email) and Inventory (turning the reservation into a definitive stock deduction). In the monolith that deduction was inside the transaction; now it is Inventory's last local transaction.- Compensations are business events, not "undo" commands: Inventory releases stock because the order was cancelled, and publishes
stock.released; Payments would refund (payment.refunded) only if the order is cancelled afterpayment.confirmed, something that in this basic flow happens if Orders, already PAID, could not confirm (for example, because of a later anti-fraud rule) or if the customer cancels within the allowed window. It is a branch the team designs even though it is rare today. - The event carries what the consumer needs.
order.createdincludes lines (withproductId,quantity,unitPrice,name), total, the customer'semailandname, andshippingAddress; that way Inventory reserves without asking anyone, Payments charges without asking anyone and Notifications writes the email without asking anyone (the conformist pattern from 02-03). Example:
{
"eventId": "evt-01J5X8Q7ZK3M",
"type": "order.created",
"version": 1,
"occurredAt": "2026-08-15T10:42:00Z",
"orderId": "ord-88213",
"customerId": "c-1024",
"customer": { "email": "[email protected]", "name": "Ana" },
"shippingAddress": { "street": "Gran Vía 12", "postalCode": "28013", "city": "Madrid" },
"lines": [
{ "productId": "p-501", "name": "BT X200 Headphones", "unitPrice": 59.90, "quantity": 1 },
{ "productId": "p-777", "name": "USB-C Cable 2 m", "unitPrice": 9.90, "quantity": 2 }
],
"total": 79.70
}The unique eventId and the version are not decoration: the first is the basis of idempotency (section 8) and the second, of contract versioning (03-06). Every event in the course carries this envelope (eventId, type, version, occurredAt) plus its payload.
- The order's state machine
From orders-service's point of view, the saga looks like a state machine of the Order aggregate. Defining it explicitly is what prevents a late or duplicate event from leaving an order in an absurd state (for example, a payment.confirmed arriving for an order that is already CANCELLED).
stateDiagram-v2
[*] --> PENDING: POST /orders (T1)
PENDING --> STOCK_RESERVED: stock.reserved
PENDING --> CANCELLED: stock.rejected (OUT_OF_STOCK)
STOCK_RESERVED --> PAID: payment.confirmed
STOCK_RESERVED --> CANCELLED: payment.rejected (PAYMENT_REJECTED)
STOCK_RESERVED --> CANCELLED: deadline expired (PAYMENT_TIMEOUT)
PAID --> CONFIRMED: confirm (T4) → order.confirmed
PAID --> CANCELLED: not confirmable → refund
CONFIRMED --> [*]
CANCELLED --> [*]
| Status | Meaning | Events it accepts | Events it ignores (and logs) |
|---|---|---|---|
PENDING |
Created; awaiting reservation. | stock.reserved, stock.rejected |
payment.* (should not exist yet) |
STOCK_RESERVED |
Stock set aside; awaiting charge. | payment.confirmed, payment.rejected, deadline expired |
duplicate stock.* |
PAID |
Charge made; transient before confirming. | internal confirmation | everything else |
CONFIRMED |
Happy ending. Publishes order.confirmed. |
(customer cancellation within the window, future extension) | payment.*, stock.* |
CANCELLED |
Final. Publishes order.cancelled with reason. |
none | everything (a late payment.confirmed here forces a refund: an exceptional case that is logged and alerted) |
PAID looks redundant (why not go from STOCK_RESERVED straight to CONFIRMED?). It is kept separate for two reasons: it makes it possible to distinguish "I have recorded the payment but not yet published the confirmation" in case of a crash between the two writes, and it leaves room for future confirmation rules (anti-fraud, address validation) without changing the saga.
The expired deadline deserves attention: if Payments is down for half an hour, orders sit in STOCK_RESERVED with stock set aside that nobody buys. orders-service includes a watchdog (a periodic task) that cancels with PAYMENT_TIMEOUT the orders that have been in that status for more than N minutes; and, as an independent safety net, reservations.expires_at lets Inventory release orphaned reservations even if Orders does not ask. Two mechanisms, each in its own context, for the same risk.
Skeleton of the state machine and of a compensation consumer (illustrative: no real database access or RabbitMQ; the implementation comes in module 4):
// domain/orderStateMachine.js (orders-service) - allowed transitions
const TRANSITIONS = {
PENDING: { 'stock.reserved': 'STOCK_RESERVED', 'stock.rejected': 'CANCELLED' },
STOCK_RESERVED: { 'payment.confirmed': 'PAID', 'payment.rejected': 'CANCELLED', 'payment.timeout': 'CANCELLED' },
PAID: { 'confirm': 'CONFIRMED', 'not.confirmable': 'CANCELLED' },
CONFIRMED: {},
CANCELLED: {}
};
const REASONS = { 'stock.rejected': 'OUT_OF_STOCK', 'payment.rejected': 'PAYMENT_REJECTED', 'payment.timeout': 'PAYMENT_TIMEOUT' };
// Returns the new status, or null if the transition is not allowed (late/duplicate event).
function transition(currentStatus, eventType) {
return TRANSITIONS[currentStatus]?.[eventType] ?? null;
}
// Generic handler for saga events inside orders-service (skeleton).
async function onSagaEvent(event, repository, outbox) {
const order = await repository.get(event.orderId);
const newStatus = transition(order.status, event.type);
if (!newStatus) { // e.g. payment.confirmed on CANCELLED
logger.warn('transition_ignored', { orderId: order.orderId, from: order.status, event: event.type });
return; // idempotent: does not break, does not repeat
}
order.status = newStatus;
if (newStatus === 'CANCELLED') order.cancellationReason = REASONS[event.type];
// Same local transaction: save the order and enqueue the outgoing event (outbox, section 7)
await repository.saveWithEvents(order, [
newStatus === 'PAID' && { type: 'confirm', orderId: order.orderId }, // internal step T4
newStatus === 'CONFIRMED' && { type: 'order.confirmed', orderId: order.orderId, ...dataForConsumers(order) },
newStatus === 'CANCELLED' && { type: 'order.cancelled', orderId: order.orderId, reason: order.cancellationReason, ...dataForConsumers(order) }
].filter(Boolean));
}// consumers/orderCancelled.js (inventory-service) - compensation C2, skeleton
async function onOrderCancelled(event, reservations, outbox) {
const reservation = await reservations.findByOrder(event.orderId);
if (!reservation || reservation.status !== 'ACTIVE') return; // no reservation, or already released/consumed: nothing to do (idempotent)
// Inventory's local transaction: release and announce
await reservations.transaction(async (tx) => {
await tx.markReleased(reservation.reservationId); // UPDATE reservations SET status='RELEASED'
for (const line of reservation.lines) {
await tx.subtractReserved(line.productId, line.quantity); // UPDATE stock SET reserved = reserved - quantity
}
await outbox.enqueue(tx, { type: 'stock.released', orderId: event.orderId, reservationId: reservation.reservationId });
});
}Notice that both skeletons start by checking the status and bail out without doing anything if the event does not apply: that is half of idempotency; the other half is in section 8.
- TechCorp's decision: choreography to start with
Luis and his team choose choreography for the order saga, for these reasons:
- The flow is short and linear: four transactions, two failure points, two compensations.
- It fits the context map from 02-03: Orders–Inventory are a partnership over events, Payments and Notifications consume the published language. Nobody has to "command" anybody.
- It reinforces autonomy: adding a consumer (for example, a future
promotions-servicelistening toorder.confirmedto redeem a coupon) touches nobody. - The team learns with the simplest option before adding a piece (the orchestrator) that has to be operated and monitored.
And they put in writing when they will move to orchestration, so as not to discover it during an incident:
| Signal | What it indicates |
|---|---|
| The saga exceeds 5-6 steps or gains branches (partial shipment, split payment, returns). | The distributed logic no longer fits in anyone's head. |
| Nobody can quickly answer "which step is order X at, and why?" without reading traces from four services. | Visibility is missing; an orchestrator with its saga table provides it. |
| Cyclic event dependencies appear between services. | The choreography has become tangled. |
| Human intervention or complex deadlines are needed in the middle of the flow. | Orchestrators (or workflow engines) model that better. |
If the moment comes, the orchestrator will live inside orders-service (it owns the order's lifecycle) and will send commands to Inventory and Payments; the public events (order.confirmed, order.cancelled) will be kept for Notifications and for whoever comes later. The state machine from section 5 is the same in both cases: that is why it is defined now.
- The transactional outbox pattern
There is a subtle failure that would break the whole saga if not handled. orders-service must do two things when creating an order: save the row in its PostgreSQL and publish order.created to RabbitMQ. They are two different systems, so there is no transaction spanning both, and both orderings fail:
- Save, then publish: if the process crashes between the two, the order exists but nobody knows: it stays PENDING forever.
- Publish, then save: if the save fails, Inventory reserves stock for an order that does not exist.
It is the same dual write problem from 02-04, in its messaging version. The solution is the transactional outbox:
- In the same local transaction in which the order is saved, the event is inserted into an
outboxtable in the same database. Either both are saved, or neither: PostgreSQL does guarantee that. - A separate component (the relay) reads the
outboxtable, publishes the pending events to the broker and marks them as published. If the relay crashes, when it comes back it continues where it left off: no event is lost. - Since the relay may publish an event twice (for example, it publishes and crashes before marking), delivery is at least once, and consumers must be idempotent (section 8).
flowchart LR
API[orders-service API] -- "1. BEGIN<br/>INSERT orders<br/>INSERT outbox<br/>COMMIT" --> BD[(PostgreSQL orders)]
RELAY[Outbox relay<br/>inside the service itself] -- "2. SELECT ... WHERE published_at IS NULL" --> BD
RELAY -- "3. publish" --> MQ[(RabbitMQ)]
RELAY -- "4. UPDATE outbox SET published_at = NOW()" --> BD
MQ -. order.created .-> INV[inventory-service]
-- Outbox table in the database of EVERY service that publishes events (orders, inventory, payments, customers, catalog)
CREATE TABLE outbox (
event_id TEXT PRIMARY KEY, -- 'evt-01J5X8Q7ZK3M', travels in the event envelope
aggregate_type TEXT NOT NULL, -- 'Order'
aggregate_id TEXT NOT NULL, -- 'ord-88213' (allows publishing in order per aggregate)
type TEXT NOT NULL, -- 'order.created'
version INT NOT NULL DEFAULT 1,
payload JSONB NOT NULL, -- the event body
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ -- NULL = pending publication
);
CREATE INDEX idx_outbox_pending ON outbox (created_at) WHERE published_at IS NULL;And its use, in pseudocode, from the Orders repository (it is the saveWithEvents that appeared in section 5):
// repository/OrderRepository.js (orders-service) - skeleton of saving with outbox
async function saveWithEvents(order, events) {
await db.transaction(async (tx) => { // ONE local transaction
await tx.upsertOrder(order); // orders + order_lines
for (const event of events) {
await tx.insert('outbox', {
event_id: generateId('evt'), aggregate_type: 'Order', aggregate_id: order.orderId,
type: event.type, payload: event
});
}
}); // COMMIT: order and events, or nothing
// The relay, in another thread/process of the same service, takes care of publishing. The broker is not touched here.
}Two clarifications. First: the relay can be a periodic task of the service itself (polling the table every few hundred milliseconds) or a change data capture (CDC) tool; TechCorp starts with polling, which is enough for 3,000 orders/day, and the implementation details are covered in 04-04. Second: the outbox table is also a free audit log of everything the service has communicated to the outside, something that will prove valuable in 06-03.
- Consumer idempotency and idempotency keys
With "at least once" delivery, every consumer will receive some repeated event sooner or later (relay retry, broker redelivery after an acknowledgment failure, redeployment in the middle of processing). Idempotent means that processing the same event twice produces the same result as processing it once: Inventory does not reserve twice, Payments does not charge twice, Notifications does not send two emails.
There are two complementary mechanisms, and TechCorp's design uses both:
a) Natural idempotency through the model. Designing writes so that repetition changes nothing:
reservations.order_id UNIQUE: a secondorder.createdfor the same order collides with the constraint and the consumer treats it as "already done".payments.order_id UNIQUE: a secondstock.reserveddoes not produce a second charge.- The state machine: a second
payment.confirmedon an order that is already PAID/CONFIRMED has no transition and is ignored.
b) Processed-events log. For consumers whose effect is not a single row (sending an email, calling the payment provider), the eventId is stored in the same transaction as the effect:
-- In each consumer's database
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
consumer TEXT NOT NULL, -- 'notifications.confirmationEmail'
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);// Generic idempotency wrapper for a consumer (skeleton)
async function processOnce(event, consumer, db, handler) {
return db.transaction(async (tx) => {
const alreadySeen = await tx.exists('processed_events', { event_id: event.eventId, consumer });
if (alreadySeen) return 'DUPLICATE'; // the effect is not repeated
await handler(event, tx); // the real effect, inside the transaction
await tx.insert('processed_events', { event_id: event.eventId, consumer });
return 'PROCESSED';
});
}When the effect is external (calling the payment provider) it cannot be put inside the transaction; in that case an attempt with status "in progress" is recorded beforehand and the payment provider's own idempotency key is used (every serious payment provider accepts one, precisely for this reason): paymentProvider.charge({ amount, idempotencyKey: orderId }). If the call is repeated, the payment provider returns the same result without charging again.
c) The idempotency key in the API. The sixth problem of createOrder in 01-05 was the "double click": two identical POST /orders, two orders, two charges. The design solution is for the client to send an Idempotency-Key header (a UUID generated by the browser for that purchase attempt) and for orders-service to store, in its T1 transaction, the pair key → orderId and response; on a repeat with the same key, it returns the same response without creating anything. The exact form of the header and the response is finalized in 03-01; the data design is one more table in orders:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY, -- value of the Idempotency-Key header
order_id TEXT NOT NULL,
response JSONB NOT NULL, -- what was returned the first time
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);With outbox (events are guaranteed to go out) and idempotency (repeated events do no harm), eventual consistency goes from "let's hope it arrives" to "guaranteed".
- CQRS: separating the write model from the read model
CQRS (Command Query Responsibility Segregation) is the idea of using different models for writing and for reading. In 02-04 it appeared as "materialized view" and as the answer to the admin panel with cross filters; now it has a name and a mechanism.
- The write model is the Order aggregate with its invariants and its state machine, in the
ordersandorder_linestables. It is optimized for deciding (can I confirm? can I cancel?). - The read model is one or more denormalized tables, built by a projector that consumes events, and optimized for displaying: no
JOIN, with exactly the columns each screen needs.
For the admin panel's "orders with customer and product names":
-- Read model, maintained ONLY by the event projector. The command API never writes it.
CREATE TABLE admin_orders_view (
order_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
customer_name TEXT NOT NULL, -- from order.created (and updated by customer.updated)
shipping_city TEXT NOT NULL, -- from order.created
products_text TEXT NOT NULL, -- 'BT X200 Headphones x1, USB-C Cable 2 m x2'
total NUMERIC(10,2) NOT NULL,
status TEXT NOT NULL, -- updated by order.confirmed / order.cancelled
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_admin_orders_view_day_city ON admin_orders_view (created_at, shipping_city);// projectors/adminOrdersView.js - projector skeleton (idempotent event consumer)
const handlers = {
'order.created': (e, tx) => tx.upsert('admin_orders_view', {
order_id: e.orderId, customer_id: e.customerId, customer_name: e.customer.name,
shipping_city: e.shippingAddress.city, total: e.total, status: 'PENDING',
products_text: e.lines.map(l => `${l.name} x${l.quantity}`).join(', '),
created_at: e.occurredAt }),
'order.confirmed': (e, tx) => tx.update('admin_orders_view', { order_id: e.orderId }, { status: 'CONFIRMED' }),
'order.cancelled': (e, tx) => tx.update('admin_orders_view', { order_id: e.orderId }, { status: 'CANCELLED' }),
'customer.updated': (e, tx) => tx.update('admin_orders_view', { customer_id: e.customerId }, { customer_name: e.name })
};
// Runs wrapped in processOnce(...) from section 8.Where it lives: TechCorp starts with the view inside orders-service (same database, different table, fed by its own events and by customer.updated). It is "lightweight" CQRS: no separate query service is needed until volume or consumers justify it. If one day the panel needs to cross data from five contexts, the view moves to a query service or to the analytical store from 02-04.
When CQRS pays off and when it does not:
| Pays off | Does not pay off |
|---|---|
| Queries that cross contexts, with filters and pagination (dashboards, listings). | An order's detail: the write model already returns it fine. |
| Massive reads that must not load the transactional model (order search, reports). | Small systems with a single database, where a JOIN does the job. |
| When the write model is rich (aggregate with invariants) and the read model is flat. | When adding a projector only introduces delay without removing a real problem. |
CQRS brings eventual consistency between writes and reads: a freshly created order takes milliseconds or seconds to appear in the view. For the admin panel that is irrelevant; for "I just created the order and want to see it" you have to read from the write model or return the representation in the command's response.
- Event sourcing: storing the facts instead of the state
Event sourcing takes the idea one step further: the aggregate's current state is not stored; the sequence of events that produced it is, and the state is rebuilt by replaying them. The orders table with its status column would disappear; in its place there would be an event store:
| sequence | aggregate_id | type | payload |
|---|---|---|---|
| 1 | ord-88213 | OrderCreated | lines, total, customer... |
| 2 | ord-88213 | StockReserved | reservationId |
| 3 | ord-88213 | PaymentRecorded | paymentId, amount |
| 4 | ord-88213 | OrderConfirmed | — |
// Rebuilding the order from its events (illustrative skeleton)
function rebuildOrder(events) {
return events.reduce((order, e) => {
switch (e.type) {
case 'OrderCreated': return { ...e.payload, status: 'PENDING' };
case 'StockReserved': return { ...order, status: 'STOCK_RESERVED', reservationId: e.payload.reservationId };
case 'PaymentRecorded': return { ...order, status: 'PAID', paymentId: e.payload.paymentId };
case 'OrderConfirmed': return { ...order, status: 'CONFIRMED' };
case 'OrderCancelled': return { ...order, status: 'CANCELLED', reason: e.payload.reason };
default: return order;
}
}, null);
}| For | Against |
|---|---|
| Complete audit trail by construction: you know what happened, when and in what order. | Complexity: rebuilding state, snapshots when there are many events, versioning of old events that no longer have the same shape. |
| Temporal queries: "what did this order look like on Tuesday at 10?". | CQRS is mandatory: you cannot do WHERE status = 'PENDING' on an event store; projections are needed for any query. |
| Events are already the source of truth: outbox and event store converge. | Steep learning curve and less mature tooling in the usual ecosystem. |
| Fits domains where the history is the business (accounting, banking, insurance). | Correcting a wrong piece of data is not an UPDATE: it is emitting a correction event. |
TechCorp's decision: no, for now. Rationale:
- The order's state is simple (five statuses, two branches) and the relational model from 02-04 represents it effortlessly.
- The audit needs (knowing why an order was cancelled, when it was charged) are covered by
cancellation_reason, by theoutboxtable (which already keeps everything communicated) and by anorder_status_historytable if needed, at a fraction of the cost. - The team is learning sagas, outbox, idempotency, Docker, Kubernetes and observability all at once. Adding event sourcing would multiply the migration risk without solving any of the five problems from 01-05.
- It can be adopted later in a single context (Orders or Payments, if a regulatory requirement demands it) without touching the others, precisely because each service owns its storage.
It is a typical architecture decision: the technique is not dismissed, it is dismissed for now, and the signal that would reopen it is written down.
Common Mistakes and Tips
- Trying to get 2PC back "with a bit of code": locking stock while calling Payments over HTTP and waiting for the reply. It is the
createOrdertransaction with more latency and more failures. If a step can fail, design its compensation. - Incomplete compensations. Every step that modifies something needs its inverse designed before deploying: reserve ↔ release, charge ↔ refund, confirm ↔ (there is none: that is why it is the last).
- Publishing the event outside the transaction. Without an outbox, events get lost, and losing an
order.createdis a zombie order. The outbox is not optional. - Assuming an event arrives exactly once and in order. It will arrive repeated and, sometimes, out of order (a
payment.confirmedcan reach Orders before thestock.reservedif the consumer was slow). The state machine and the processed-events log protect against both. - An orchestrator that knows too much. If one day you move to orchestration, the orchestrator sends commands and waits for replies; it does not compute stock or decide charges. That belongs to each context.
- CQRS and event sourcing "because they are modern". CQRS only where a query calls for it; event sourcing only where the history is the business.
- Tip: always draw the saga as a sequence diagram and as a state machine. The first shows the happy path; the second is what forces you to think about late, duplicate and out-of-order events.
Exercises
Exercise 1: An out-of-order event
Because of a consumer restart, orders-service receives payment.confirmed for ord-88213 before stock.reserved (both exist and are valid). With the state machine from section 5: what happens to each event? Does the order end up in a correct state? What would have to change in the design if that situation were frequent?
Exercise 2: Designing a new compensation
TechCorp will let the customer cancel a CONFIRMED order within the following 30 minutes. Design the saga extension: which transition is added to the state machine, which event Orders publishes, what each consumer does (Inventory, Payments, Notifications), and what guarantees that a double click on "cancel" does not refund twice.
Exercise 3: Outbox or not?
catalog-service publishes product.updated every time an operator saves a listing in MongoDB. A colleague proposes publishing directly to RabbitMQ after saving, "because MongoDB is not PostgreSQL and we don't have an outbox". Explain what can go wrong, how you would apply the outbox pattern with MongoDB (hint: an outbox collection and MongoDB transactions, or the document itself) and which TechCorp consumer would suffer if a product.updated were lost.
Solutions
Exercise 1
The order is in PENDING. payment.confirmed arrives: in PENDING there is no transition for that event → it is ignored and a warning is logged (transition_ignored). Then stock.reserved arrives: PENDING → STOCK_RESERVED, correct. But the payment.confirmed was already discarded, so the order would sit in STOCK_RESERVED until the watchdog cancels it with PAYMENT_TIMEOUT, and Payments would have charged: it would end up CANCELLED with an "orphaned" payment.confirmed that requires a refund. It is correct in the sense that there is no impossible state, but it is a bad business outcome. If it were frequent, there are two improvements: (a) instead of discarding the non-applicable event, park it (store it as "pending application" and retry it when the status changes, or ask the broker to redeliver it later); (b) have Payments include the reservationId in payment.confirmed, so that Orders can accept PENDING → PAID knowing the reservation exists. In practice the disorder is rare because payment.confirmed is causally later than stock.reserved, and (a) is enough.
Exercise 2
- New transition:
CONFIRMED → CANCELLEDwith input eventcancellation.requested(customer command viaPOST /orders/{id}/cancellation), allowed only ifNOW() - confirmed_at <= 30 minand the order has not left the warehouse (a rule of the Orders context; in a more complete design, "shipped" would be another status that would block cancellation). - Orders publishes
order.cancelledwithreason: 'CANCELLED_BY_CUSTOMER'and, in the payload,paymentIdororderIdso that Payments can locate the charge. - Inventory: the reservation is already
CONSUMED(stock was deducted on confirmation); its compensation is to restock quantity (quantity += n) and publishstock.released(or astock.restocked, if you want to distinguish). - Payments: looks up the order's
CAPTUREDpayment; if it exists, refunds at the payment provider with idempotency keyrefund-<orderId>and publishespayment.refunded; if there is no charge, it does nothing. - Notifications: email "order cancelled, refund in progress".
- Double click: the second
POST .../cancellationfinds the order alreadyCANCELLED(no transition → 409 or an idempotent response); even if a secondorder.cancelledarrived, Payments detects it viaprocessed_eventsand via theREFUNDEDstatus of its row; and the payment provider stops it through the idempotency key. Three layers.
Exercise 3
Exactly the same can go wrong as in PostgreSQL: the process crashes between saving the document and publishing (lost event: whoever consumes it ends up with a stale price replica) or publishes and then the save fails (phantom event). The pattern is the same with MongoDB: (a) use a MongoDB multi-document transaction (available on replica sets) to write the products document and a document in the outbox collection atomically, with a relay that reads outbox and publishes; or (b) write the pending events inside the product document itself (pendingEvents: [...]) in the same write operation, and have the relay extract and clear them (the "embedded outbox" variant, useful without transactions); or (c) use MongoDB change streams as a CDC mechanism. Who would suffer: any consumer that keeps a replica of catalog data (for example, the admin panel's materialized view if it showed updated names, or the analytical store from 02-04 for categories). orders-service does not suffer when creating orders because it queries the price by synchronous composition; that was precisely the reason for that decision in 02-04.
Conclusion
We have replaced the single transaction of createOrder with a complete distributed design. We know why there is no ACID across services and why 2PC is ruled out (locks, single coordinator, external systems), what eventual consistency means in practice (legitimate intermediate states, guaranteed but not instantaneous), and we have designed TechCorp's "create order" saga by choreography: order.created → stock.reserved → payment.confirmed → order.confirmed, with the failure events stock.rejected and payment.rejected, the order.cancelled event (with reason OUT_OF_STOCK, PAYMENT_REJECTED or PAYMENT_TIMEOUT) and the compensations stock.released and payment.refunded. The state machine PENDING → STOCK_RESERVED → PAID → CONFIRMED / CANCELLED governs which event is accepted and which is ignored; the transactional outbox guarantees an event goes out if and only if the change was saved; idempotency (UNIQUE constraints, the processed_events table, the Idempotency-Key header, payment provider keys) makes repetitions harmless. With CQRS we have added the admin_orders_view view fed by a projector, and we have decided that event sourcing does not pay off for TechCorp today, leaving in writing the signals that would reopen the decision (just like those that would lead from choreography to orchestration).
This concludes the design module: we have principles, a decomposition plan with an extraction order, six bounded contexts with their relationship map, one database per service with its schema, and a saga with outbox and idempotency. What we have not yet decided is exactly how the services talk to each other: the shape of each one's REST APIs (POST /orders with its 202, GET /products?ids=, POST /reservations), how events are really published and consumed in RabbitMQ, when gRPC or GraphQL make sense, what the API Gateway on port 8080 does, how services find each other and how contracts are versioned without breaking anyone. That is module 3, and it starts with RESTful APIs.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
