This is the lesson in which the design from modules 2 and 3 becomes a service that works end to end. We build the core of orders-service (port 3002, Luis's team) on the same template as catalog-service, but with everything Catalog did not have: PostgreSQL with transactions, outbound HTTP calls to Catalog and Customers with their timeout and their ACL, the createOrder use case that saves the order and its order.created event in the same transaction (outbox), the relay that publishes to RabbitMQ with confirms, and the consumers that receive the saga events and move the order through its state machine to CONFIRMED or CANCELLED. We finish with a complete manual check: create Ana's order with curl, see it PENDING, inject a stock.reserved and see it STOCK_RESERVED. We show the essential parts in full and summarize the repetitive ones; the omitted files are direct variants of what you already saw in 04-02.
Contents
- Project structure and dependencies
- PostgreSQL: pool, transactions and migrations
- Outbound HTTP clients: Catalog (with ACL) and Customers
- Domain: the
Orderaggregate and the state machine - Repository with outbox:
saveWithEvents()andprocessOnce() - The
createOrderuse case and thePOST /v1/ordersroute - The outbox relay
- Consumers: the saga and the customers replica
GET /v1/orders/{id}with ETag and the full startup- End-to-end check
- Project structure and dependencies
npm install express pg amqplib pino pino-http zod @techcorp/common-http && npm install -D nodemon dotenvorders-service/ ├── src/ │ ├── server.js, app.js, config.js (04-03), health.js │ ├── routes/orders.js # POST /v1/orders, GET /v1/orders/:id │ ├── use-cases/createOrder.js │ ├── domain/order.js # aggregate: create, compute total, apply event │ ├── domain/orderStateMachine.js # TRANSITIONS, REASONS, transition (02-05, as is) │ ├── repositories/orderRepository.js # saveWithEvents, get, idempotency keys, processOnce │ ├── clients/catalogClient.js, clients/customersClient.js │ ├── translators/productTranslator.js # ACL (02-03, 03-06) │ ├── infra/postgres.js │ └── messaging/outboxRelay.js, sagaConsumer.js, customersConsumer.js ├── migrations/001-initial-schema.sql … 004-idempotency-keys.sql ├── scripts/migrate.js, scripts/publishEvent.js └── contracts/openapi.yaml, contracts/asyncapi.yaml
Compared with Catalog, the driver changes (pg instead of mongodb), amqplib appears, and there are three new folders: use-cases/ (the application logic is richer than in Catalog), domain/ and messaging/. messaging/topology.js and publisher.js from 03-02 come from @techcorp/common-http.
- PostgreSQL: pool, transactions and migrations
// src/infra/postgres.js
const { Pool } = require('pg');
function createPostgresPool({ url, logger }) {
const pool = new Pool({ connectionString: url, max: 10, connectionTimeoutMillis: 5000, idleTimeoutMillis: 30000 });
pool.on('error', (err) => logger.error({ err }, 'error on idle pool connection')); // without this, an error takes the process down
return {
query: (sql, params) => pool.query(sql, params), // outside a transaction
// Runs fn(tx) inside BEGIN/COMMIT; ROLLBACK if fn throws. tx.query ALWAYS uses the same connection.
async transaction(fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn({ query: (sql, params) => client.query(sql, params) });
await client.query('COMMIT');
return result;
} catch (err) { await client.query('ROLLBACK'); throw err; }
finally { client.release(); }
},
ping: () => pool.query('SELECT 1'),
close: () => pool.end()
};
}
module.exports = { createPostgresPool };transaction(fn) is the piece that makes the outbox possible: everything executed with the received tx goes in the same transaction; either all of it commits or none of it does.
The migrations are the schemas already designed, with no substantive changes: 001-initial-schema.sql (orders, order_lines, customers_ref from 02-04 §7.1), 002-outbox.sql (outbox with its partial index on pending rows, 02-05 §7), 003-processed-events.sql (processed_events with composite primary key (event_id, consumer)), 004-idempotency-keys.sql (idempotency_keys from 02-05 §8, to which we add a fingerprint TEXT NOT NULL column with the body hash to detect key reuse with a different body, 03-01). scripts/migrate.js (~30 lines) applies them in order and records each one in applied_migrations so as not to repeat it; it runs with npm run migrate before starting (in Kubernetes it will be a Job or an init container, 05-02).
- Outbound HTTP clients: Catalog (with ACL) and Customers
The Catalog client evolves the one from 03-01: it receives the URL and the timeout as parameters (04-03), calls /v1/products?ids= and applies the productTranslator ACL (03-06) before returning, so the rest of Orders never sees Catalog's JSON.
// src/translators/productTranslator.js — the tolerant reader from 03-06, unchanged
function toOrdersProduct(dto) {
return { productId: dto.id, name: dto.name, unitPrice: Number(dto.price), available: dto.available !== false };
}
module.exports = { toOrdersProduct };// src/clients/catalogClient.js
const { BusinessError } = require('@techcorp/common-http');
const { toOrdersProduct } = require('../translators/productTranslator');
function createCatalogClient({ baseUrl, timeoutMs }) {
return {
// Returns a Map productId → { productId, name, unitPrice, available }
async getProducts(ids, { requestId }) {
const url = `${baseUrl}/v1/products?ids=${encodeURIComponent(ids.join(','))}`;
let response;
try {
response = await fetch(url, { headers: { Accept: 'application/json', 'X-Request-Id': requestId }, signal: AbortSignal.timeout(timeoutMs) });
} catch (err) { // timeout or network: 503 for our caller (03-01)
throw new BusinessError('DEPENDENCY_UNAVAILABLE', `Catalog unavailable: ${err.name}`, 503);
}
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
if (response.status >= 500) throw new BusinessError('DEPENDENCY_UNAVAILABLE', `Catalog responded ${response.status}`, 503);
throw new BusinessError('INVALID_REQUEST', `Catalog rejected the request (${problem.code ?? response.status})`, 400);
}
const { data, notFound } = await response.json();
const products = data.map(toOrdersProduct);
const notSellable = [...notFound, ...products.filter((p) => !p.available).map((p) => p.productId)];
if (notSellable.length > 0) throw new BusinessError('PRODUCT_UNAVAILABLE', `Unavailable products: ${notSellable.join(', ')}`, 422);
return new Map(products.map((p) => [p.productId, p]));
}
};
}
module.exports = { createCatalogClient };The Customers client is symmetrical and shorter: GET {CUSTOMERS_URL}/v1/customers/{id} with the same timeout; 404 → BusinessError('CUSTOMER_NOT_FOUND', ..., 404); 5xx/network → DEPENDENCY_UNAVAILABLE; it returns { customerId, name, email, addresses }. No retries or circuit breaker (06-03): just a timeout.
- Domain: the
Order aggregate and the state machine
Order aggregate and the state machinedomain/orderStateMachine.js is literally the one from 02-05 (TRANSITIONS, REASONS, transition). The aggregate adds construction and total computation in integer cents to avoid 59.90 + 9.90 * 2 = 79.69999...:
// src/domain/order.js
const { randomUUID } = require('node:crypto');
const { transition, REASONS } = require('./orderStateMachine');
const toCents = (n) => Math.round(n * 100);
// Builds a PENDING Order from the validated request and from what Customers and Catalog said
function createOrder({ customerId, lines, shippingAddress }, { customer, products }) {
const frozenLines = lines.map((l, i) => {
const p = products.get(l.productId); // catalogClient already guaranteed it exists and is sellable
return { line: i + 1, productId: p.productId, productName: p.name, unitPrice: p.unitPrice, quantity: l.quantity };
});
const totalCents = frozenLines.reduce((acc, l) => acc + toCents(l.unitPrice) * l.quantity, 0);
return {
orderId: `ord-${randomUUID().slice(0, 8)}`, // opaque id generated by the owner (02-04)
customerId, customer: { name: customer.name, email: customer.email },
status: 'PENDING', lines: frozenLines, shippingAddress,
total: totalCents / 100, cancellationReason: null, createdAt: new Date().toISOString()
};
}
// Applies a saga event; returns the new status or null if there is no transition (late/duplicate event)
function applySagaEvent(order, eventType) {
const newStatus = transition(order.status, eventType);
if (!newStatus) return null;
order.status = newStatus;
if (newStatus === 'CANCELLED') order.cancellationReason = REASONS[eventType];
return newStatus;
}
// Payload that travels in order.created / order.confirmed / order.cancelled (02-05 contract and 03-06 AsyncAPI)
function dataForConsumers(o) {
return { orderId: o.orderId, customerId: o.customerId, customer: o.customer, shippingAddress: o.shippingAddress, total: o.total,
lines: o.lines.map((l) => ({ productId: l.productId, name: l.productName, quantity: l.quantity, unitPrice: l.unitPrice })) };
}
module.exports = { createOrder, applySagaEvent, dataForConsumers };
- Repository with outbox:
saveWithEvents() and processOnce()
saveWithEvents() and processOnce()// src/repositories/orderRepository.js
const { randomUUID } = require('node:crypto');
function createOrderRepository(db) {
// Saves order + lines + customer replica + events in the outbox, in ONE transaction (02-05 §7).
// `tx` optional: if the caller is already in a transaction (processOnce), we reuse theirs.
async function saveWithEvents(order, events, tx) {
const work = async (t) => {
await t.query(`INSERT INTO orders (order_id, customer_id, status, total, shipping_address, cancellation_reason, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())
ON CONFLICT (order_id) DO UPDATE SET status = EXCLUDED.status, cancellation_reason = EXCLUDED.cancellation_reason, updated_at = NOW()`,
[order.orderId, order.customerId, order.status, order.total, order.shippingAddress, order.cancellationReason, order.createdAt]);
for (const l of order.lines) { // lines never change after creation: idempotent insert
await t.query(`INSERT INTO order_lines (order_id, line, product_id, product_name, unit_price, quantity)
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, [order.orderId, l.line, l.productId, l.productName, l.unitPrice, l.quantity]);
}
if (order.customer) { // customers_ref replica (02-04): we warm it up with what we already know
await t.query(`INSERT INTO customers_ref (customer_id, name, email, updated_at) VALUES ($1,$2,$3,NOW())
ON CONFLICT (customer_id) DO NOTHING`, [order.customerId, order.customer.name, order.customer.email]);
}
for (const ev of events) { // the outbox: same INSERTs, same transaction
await t.query(`INSERT INTO outbox (event_id, aggregate_type, aggregate_id, type, version, payload) VALUES ($1,'Order',$2,$3,$4,$5)`,
[`evt-${randomUUID()}`, order.orderId, ev.type, ev.version ?? 1, ev.payload]);
}
};
return tx ? work(tx) : db.transaction(work);
}
async function get(orderId, tx = db) {
const { rows } = await tx.query(`SELECT o.*, c.name AS customer_name, c.email AS customer_email
FROM orders o LEFT JOIN customers_ref c ON c.customer_id = o.customer_id WHERE o.order_id = $1`, [orderId]);
if (rows.length === 0) return null;
const lines = (await tx.query('SELECT * FROM order_lines WHERE order_id = $1 ORDER BY line', [orderId])).rows;
return toOrder(rows[0], lines); // row → aggregate (camelCase names; ~10 lines, omitted)
}
// API idempotency (02-05 §8c, 03-01): key → stored response
const findKey = async (key) => (await db.query('SELECT fingerprint, response FROM idempotency_keys WHERE key = $1', [key])).rows[0] ?? null;
const saveKey = (t, key, orderId, fingerprint, response) =>
t.query('INSERT INTO idempotency_keys (key, order_id, fingerprint, response) VALUES ($1,$2,$3,$4)', [key, orderId, fingerprint, response]);
// Consumer idempotency (02-05 §8b, 03-02): effect + record in the SAME transaction
async function processOnce(eventId, consumer, fn) {
return db.transaction(async (tx) => {
const { rowCount } = await tx.query('INSERT INTO processed_events (event_id, consumer) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, consumer]);
if (rowCount === 0) return 'DUPLICATE'; // already processed: the transaction does nothing else
await fn(tx); // the real effect, with the same tx
return 'PROCESSED';
});
}
return { saveWithEvents, get, findKey, saveKey, processOnce, transaction: db.transaction };
}
module.exports = { createOrderRepository };Two subtleties: processOnce inserts into processed_events first (if two replicas receive the same event at the same time, the second one blocks on the row and, when the first one commits, sees rowCount = 0), and the effect runs with the same tx, so saveWithEvents(order, events, tx) goes in that transaction: the status change, the outgoing event and the processed mark commit together or not at all.
- The
createOrder use case and the POST /v1/orders route
createOrder use case and the POST /v1/orders route// src/use-cases/createOrder.js
const { createHash } = require('node:crypto');
const { BusinessError } = require('@techcorp/common-http');
const Order = require('../domain/order');
const fingerprintOf = (body) => createHash('sha256').update(JSON.stringify(body)).digest('hex');
function createCreateOrderUseCase({ repository, catalogClient, customersClient, logger }) {
return async function createOrder(request, { idempotencyKey, requestId }) {
// 1. Idempotency: same key + same body → same response; same key + different body → 422 (03-01)
const fingerprint = fingerprintOf(request);
const previous = await repository.findKey(idempotencyKey);
if (previous) {
if (previous.fingerprint !== fingerprint) throw new BusinessError('IDEMPOTENCY_KEY_REUSED', 'The Idempotency-Key was already used with a different body', 422);
return { order: previous.response, repeated: true };
}
// 2. Customer and products IN PARALLEL: they are independent, so latency is that of the slowest, not the sum
const [customer, products] = await Promise.all([
customersClient.getCustomer(request.customerId, { requestId }), // CUSTOMER_NOT_FOUND → 404
catalogClient.getProducts([...new Set(request.lines.map((l) => l.productId))], { requestId }) // PRODUCT_UNAVAILABLE → 422
]);
// 3. The aggregate, PENDING, with frozen names and prices and the computed total
const order = Order.createOrder(request, { customer, products });
const response = toRepresentation(order); // the JSON from 03-01 (id, status, lines, total, _links); ~8 lines, omitted
// 4. Order + order.created + idempotency key: ONE transaction. No RabbitMQ or HTTP in here.
await repository.transaction(async (tx) => {
await repository.saveWithEvents(order, [{ type: 'order.created', version: 1, payload: Order.dataForConsumers(order) }], tx);
await repository.saveKey(tx, idempotencyKey, order.orderId, fingerprint, response);
});
logger.info({ orderId: order.orderId, customerId: order.customerId, total: order.total, requestId }, 'order created');
return { order: response, repeated: false };
};
}
module.exports = { createCreateOrderUseCase };The route is the one from 03-01 with /v1/ and without the error-mapping try/catch (now errorMiddleware from 04-02 does it, because the clients throw BusinessError with a status):
// src/routes/orders.js (POST excerpt)
router.post('/v1/orders', async (req, res, next) => {
try {
const idempotencyKey = req.get('Idempotency-Key');
if (!idempotencyKey) throw new BusinessError('INVALID_REQUEST', 'Missing Idempotency-Key header', 400);
const request = newOrderSchema.parse(req.body); // zod: customerId, lines[{productId, quantity ≥ 1}], shippingAddress (400/422 via middleware)
const { order } = await createOrder(request, { idempotencyKey, requestId: req.id });
res.status(202).location(`/v1/orders/${order.id}`).json(order);
} catch (err) { next(err); }
});When we respond 202, the order.created event is in the outbox table, not in RabbitMQ. That is deliberate and it is what makes the design robust: if RabbitMQ is down, the order is accepted anyway and the event will go out when it comes back.
- The outbox relay
// src/messaging/outboxRelay.js
const { buildEnvelope, publishEvent } = require('@techcorp/common-http/messaging/publisher'); // 03-02
function createOutboxRelay({ db, confirmChannel, intervalMs, batchSize = 50, logger }) {
let timer = null, stopped = false;
async function publishPending() {
// One transaction per batch. FOR UPDATE SKIP LOCKED: if there are two Orders replicas, each takes different rows.
const published = await db.transaction(async (tx) => {
const { rows } = await tx.query(
`SELECT event_id, type, version, payload FROM outbox WHERE published_at IS NULL ORDER BY created_at LIMIT $1 FOR UPDATE SKIP LOCKED`, [batchSize]);
if (rows.length === 0) return 0;
for (const row of rows) {
// The envelope carries the outbox eventId (not a new one): if the relay retries, the consumer recognizes it as a duplicate
const envelope = { ...buildEnvelope(row.type, row.payload, { version: row.version }), eventId: row.event_id };
publishEvent(confirmChannel, envelope);
}
await confirmChannel.waitForConfirms(); // the broker confirms it has received (and persisted) the batch
await tx.query(`UPDATE outbox SET published_at = NOW() WHERE event_id = ANY($1)`, [rows.map((r) => r.event_id)]);
return rows.length; // COMMIT: only now are they marked
});
if (published > 0) logger.debug({ published }, 'outbox published');
return published;
}
async function cycle() {
if (stopped) return;
try {
const n = await publishPending();
timer = setTimeout(cycle, n === batchSize ? 0 : intervalMs); // if the batch was full, keep going without waiting
} catch (err) {
logger.error({ err }, 'outbox relay: failure, retrying next cycle'); // RabbitMQ down: the rows stay pending
timer = setTimeout(cycle, intervalMs);
}
}
return { start: () => cycle(), stop: () => { stopped = true; clearTimeout(timer); } };
}
module.exports = { createOutboxRelay };If the process dies between waitForConfirms and the COMMIT, the rows get published again in the next cycle: that is the at-least-once we accepted in 03-02, absorbed by processOnce in the consumers.
- Consumers: the saga and the customers replica
The saga consumer follows the 03-02 pattern (prefetch, ack after processing, nack to DLQ) on the orders.saga queue; what changes is the handler, which now uses the real domain and repository:
// src/messaging/sagaConsumer.js
const { declareConsumerQueue } = require('@techcorp/common-http/messaging/topology'); // 03-02
const Order = require('../domain/order');
const QUEUE = 'orders.saga';
const EVENTS = ['stock.reserved', 'stock.rejected', 'payment.confirmed', 'payment.rejected'];
function createSagaConsumer({ channel, repository, logger }) {
// Effect of a saga event. Runs INSIDE processOnce, with its tx.
async function handle(envelope, tx) {
const order = await repository.get(envelope.payload.orderId, tx);
if (!order) { logger.warn({ envelope }, 'event for unknown order'); return; } // do not retry: ack (it stays recorded as processed)
let newStatus = Order.applySagaEvent(order, envelope.type);
if (!newStatus) { logger.info({ orderId: order.orderId, from: order.status, event: envelope.type }, 'transition_ignored'); return; }
if (newStatus === 'PAID') newStatus = Order.applySagaEvent(order, 'confirm'); // T4 from 02-05: today it is immediate
const events = [];
if (newStatus === 'CONFIRMED') events.push({ type: 'order.confirmed', payload: Order.dataForConsumers(order) });
if (newStatus === 'CANCELLED') events.push({ type: 'order.cancelled', payload: { ...Order.dataForConsumers(order), reason: order.cancellationReason } });
await repository.saveWithEvents(order, events, tx); // new status + outgoing events, same transaction as processed_events
logger.info({ orderId: order.orderId, status: newStatus, event: envelope.type }, 'order updated');
}
async function start() {
await declareConsumerQueue(channel, QUEUE, EVENTS);
await channel.prefetch(10);
await channel.consume(QUEUE, async (msg) => {
if (!msg) return;
let envelope;
try { envelope = JSON.parse(msg.content.toString()); } catch { return channel.nack(msg, false, false); } // unreadable → DLQ
try {
await repository.processOnce(envelope.eventId, 'orders.saga', (tx) => handle(envelope, tx));
channel.ack(msg);
} catch (err) {
logger.error({ err, eventId: envelope.eventId }, 'error processing saga event');
channel.nack(msg, false, !msg.fields.redelivered); // 1st time: requeue; 2nd: DLQ (03-02)
}
});
}
return { start };
}
module.exports = { createSagaConsumer };The orders.customers consumer (customersConsumer.js) is the same structure with a single event, customer.updated, and a one-statement handler: INSERT INTO customers_ref ... ON CONFLICT (customer_id) DO UPDATE SET name, email, updated_at = EXCLUDED.updated_at WHERE customers_ref.updated_at < EXCLUDED.updated_at (the condition discards events that arrive out of order). Both consumers use their own channel on the same AMQP connection; the relay uses a third, confirm channel (createConfirmChannel).
GET /v1/orders/{id} with ETag and the full startup
GET /v1/orders/{id} with ETag and the full startup// src/routes/orders.js (GET excerpt)
router.get('/v1/orders/:id', async (req, res, next) => {
try {
const order = await repository.get(req.params.id);
if (!order) throw new BusinessError('ORDER_NOT_FOUND', `${req.params.id} does not exist`, 404);
const etag = `"${order.orderId}:${new Date(order.updatedAt).getTime()}"`; // changes with every transition
if (req.get('If-None-Match') === etag) return res.status(304).end(); // cheap polling (03-01)
res.set('ETag', etag).set('Cache-Control', 'no-cache').json(toRepresentation(order));
} catch (err) { next(err); }
});server.js extends the one from 04-02: it loads config (04-03), creates the pool and the repository, connects to RabbitMQ with connect(config.RABBITMQ_URL) (03-02) and opens confirmChannel = await connection.createConfirmChannel(), builds the HTTP clients, composes createApp({ repository, catalogClient, customersClient, logger, healthChecks: { postgres: db.ping, rabbitmq: () => channel.closed ? Promise.reject(new Error('channel closed')) : Promise.resolve() } }), and starts relay and consumers after listen (createApp internally builds the use case with createCreateOrderUseCase({ repository, catalogClient, customersClient, logger }), so the tests in 04-05 can replace any of the three pieces). On shutdown, the order is reversed: relay.stop(), close AMQP channels and connection (un-acked messages get redelivered), server.close(), db.close(). As decided in exercise 1 of 03-05, /health/ready checks PostgreSQL and RabbitMQ, not Catalog or Customers.
sequenceDiagram
participant W as Web
participant R as routes/orders.js
participant CU as createOrder
participant CL as customers-service:3004
participant CA as catalog-service:3001
participant PG as PostgreSQL (orders)
participant RL as outboxRelay
participant MQ as RabbitMQ techcorp.events
participant CS as sagaConsumer
W->>R: POST /v1/orders (Idempotency-Key)
R->>CU: createOrder(request)
par in parallel
CU->>CL: GET /v1/customers/c-1024
CU->>CA: GET /v1/products?ids=p-501,p-777
end
CU->>PG: BEGIN; orders+order_lines+customers_ref+outbox(order.created)+idempotency_keys; COMMIT
R-->>W: 202 Location: /v1/orders/ord-…
RL->>PG: SELECT … FOR UPDATE SKIP LOCKED
RL->>MQ: publish order.created (confirm)
RL->>PG: UPDATE outbox SET published_at
Note over MQ: Inventory reserves and publishes stock.reserved
MQ->>CS: stock.reserved (orders.saga queue)
CS->>PG: processOnce: processed_events + status STOCK_RESERVED
CS->>MQ: ack
- End-to-end check
Local dependencies (04-01) and startup; Catalog (04-02) must be running on 3001. Since customers-service does not exist yet, in development CUSTOMERS_URL points at a 20-line stub (scripts/stubCustomers.js, an Express app that answers GET /v1/customers/c-1024 with Ana Ruiz and 404 for everything else):
docker run -d --name pg-orders -p 5432:5432 -e POSTGRES_USER=svc_orders -e POSTGRES_PASSWORD=dev-orders -e POSTGRES_DB=orders postgres:16
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
cp .env.example .env && npm run migrate && node scripts/stubCustomers.js & # stub on 3004
npm run dev# 1. Create Ana's order → 202
curl -s -i -X POST http://localhost:3002/v1/orders -H 'Content-Type: application/json' -H 'Idempotency-Key: 7f3c9a2e-1b4d-4e8f-9c21-5a6b7c8d9e0f' \
-d '{"customerId":"c-1024","lines":[{"productId":"p-501","quantity":1},{"productId":"p-777","quantity":2}],
"shippingAddress":{"street":"Gran Vía 12","postalCode":"28013","city":"Madrid","country":"ES"}}'
# HTTP/1.1 202 Accepted Location: /v1/orders/ord-3f9a1c2b body: {"id":"ord-3f9a1c2b","status":"PENDING","total":79.7,...}
# 2. Repeat the same request → 202 with the SAME id (idempotency); change a quantity with the same key → 422 IDEMPOTENCY_KEY_REUSED
# 3. Query → PENDING (and in RabbitMQ Management, queue inventory.orders: 1 order.created message if Inventory is not running)
curl -s http://localhost:3002/v1/orders/ord-3f9a1c2b | jq .status # "PENDING"
# 4. Simulate Inventory: publish stock.reserved with the script
node scripts/publishEvent.js stock.reserved '{"orderId":"ord-3f9a1c2b","reservationId":"res-4471"}'
curl -s http://localhost:3002/v1/orders/ord-3f9a1c2b | jq .status # "STOCK_RESERVED"
# 5. Simulate Payments → CONFIRMED, and order.confirmed shows up in the notifications.orders queue
node scripts/publishEvent.js payment.confirmed '{"orderId":"ord-3f9a1c2b","paymentId":"pay-9001","amount":79.70}'
curl -s http://localhost:3002/v1/orders/ord-3f9a1c2b | jq .status # "CONFIRMED"
# 6. Repeat step 4 → the status does NOT change (transition_ignored in the log): idempotency + state machine// scripts/publishEvent.js — publishes a standard envelope to techcorp.events: node scripts/publishEvent.js <type> '<JSON payload>'
const { connect } = require('@techcorp/common-http/messaging/topology');
const { buildEnvelope, publishEvent } = require('@techcorp/common-http/messaging/publisher');
(async () => {
const [type, payloadJson] = process.argv.slice(2);
const { connection, channel } = await connect(process.env.RABBITMQ_URL ?? 'amqp://localhost:5672');
const envelope = buildEnvelope(type, JSON.parse(payloadJson));
publishEvent(channel, envelope);
console.log('published', envelope.eventId, type);
await channel.close(); await connection.close();
})();If all six steps behave like this, the "a customer places an order" flow works end to end on the Orders side, with the guarantees we designed: no dual write, no double orders and no impossible transitions.
Common Mistakes and Tips
- Publishing to RabbitMQ inside
createOrder. That is the dual write from 02-05: order saved and event lost (or the other way around). Onlyoutbox; the relay publishes. - A new
eventIdon every relay attempt. Consumers would not recognize the duplicate. The envelope reusesoutbox.event_id. ackbefore processing or effect outside theprocessOncetransaction. Events get lost or applied twice. Effect and record with the sametx;ackat the end.- Querying Customers and Catalog serially.
Promise.all: they are independent. (And if one fails,Promise.allrejects as soon as the first one fails: correct here, because without both there is no order.) - Computing the total with floating-point decimals.
59.90 + 19.80is not always79.70. Integer cents and division at the end; in the DB,NUMERIC(10,2). - Forgetting
pool.on('error'). An error on an idle connection is an'error'with no handler: the process dies. FOR UPDATEwithoutSKIP LOCKED. With two replicas, the second waits for the first on every cycle; withSKIP LOCKEDthey work in parallel without duplicating.- Blocking the HTTP response until the saga finishes. The contract is
202+ polling with ETag. Waiting inside the request recreates temporal coupling.
Exercises
Exercise 1. Two Orders replicas receive the same POST /v1/orders with the same Idempotency-Key at the same time (the client retried after a network timeout). Walk through the createOrder code and explain what happens in each replica; identify the point at which the primary key of idempotency_keys decides the outcome and what the "losing" replica should return (hint: PostgreSQL error code 23505).
Exercise 2. Write the complete handler for the orders.customers consumer (handle(envelope, tx)) for customer.updated with payload { customerId, name, email, updatedAt }, with the out-of-order protection from section 8, and explain why the state machine is not needed here.
Exercise 3. The relay has one replica and publishes 50 events per cycle every 500 ms. At the campaign peak (×20 → 60,000 orders/day ≈ 0.7 orders/s on average, with bursts of 10/s), is that enough? Do the math and propose two configuration tweaks (04-03) without changing code.
Solutions
Solution 1. Both replicas run findKey almost simultaneously and neither finds the key; both call Customers and Catalog and build an order with different ids (ord-a…, ord-b…); both open their transaction. The first to COMMIT inserts its row into idempotency_keys; the second, when running saveKey, collides with the primary key (23505 unique_violation) and its entire transaction does a ROLLBACK: its order and its order.created disappear, which is exactly what we want. What is missing is handling the error: in createOrder, catch err.code === '23505' around the transaction, re-read with findKey and return the response stored by the winner (with repeated: true). Without that catch, the losing replica would respond 500 to a client that, if it retries, will already get the correct order; with it, it responds 202 with the same order.
Solution 2.
async function handle(envelope, tx) {
const { customerId, name, email, updatedAt } = envelope.payload;
await tx.query(
`INSERT INTO customers_ref (customer_id, name, email, updated_at) VALUES ($1,$2,$3,$4)
ON CONFLICT (customer_id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email, updated_at = EXCLUDED.updated_at
WHERE customers_ref.updated_at < EXCLUDED.updated_at`,
[customerId, name, email, updatedAt]);
}There is no state machine because customers_ref is not an aggregate with invariants: it is a read-only replica (02-04) whose only requirement is to converge on the latest known value. The WHERE updated_at < EXCLUDED.updated_at condition guarantees that an old event arriving late does not overwrite a newer one; processOnce covers the exact duplicate.
Solution 3. Relay capacity: 50 events every 500 ms = 100 events/s (and more, because with a full batch it chains cycles without waiting). Each order generates 2 Orders events (order.created and order.confirmed/cancelled): the burst of 10 orders/s is 20 events/s, five times below. It is enough. What does matter is latency: with a 500 ms interval, an event waits 250 ms on average before going out. Configuration tweaks: lower OUTBOX_INTERVAL_MS to 250 in production (already in the 04-03 table) and, for extra headroom, expose the batch size as OUTBOX_BATCH (100). Adding a second Orders replica also doubles the relay with no changes thanks to SKIP LOCKED.
Conclusion
orders-service is built in its essential part and follows, piece by piece, what was designed earlier: the PostgreSQL pool with transaction(fn); the migrations with the schemas from 02-04 and 02-05 (plus the fingerprint in idempotency_keys); the HTTP clients to GET /v1/products?ids= and GET /v1/customers/{id} with timeout, mapping to DEPENDENCY_UNAVAILABLE/CUSTOMER_NOT_FOUND/PRODUCT_UNAVAILABLE and the productTranslator ACL; the Order aggregate with frozen prices and total in cents; createOrder with Idempotency-Key, Promise.all and a single transaction for order, order.created and key; the outbox relay with FOR UPDATE SKIP LOCKED and waitForConfirms; the orders.saga and orders.customers consumers with processOnce and the state machine; GET /v1/orders/{id} with ETag; and a manual check that takes Ana's order from PENDING to CONFIRMED.
We verified all of that by hand, with curl and a script. That is no safety net: tomorrow someone will touch productTranslator or the saga consumer and nobody will repeat the six steps. The next lesson turns these checks into automated tests at different levels: unit tests for the domain (Order, transition), component tests against createApp with doubles, integration tests with real PostgreSQL and RabbitMQ through Testcontainers, and contract tests with Pact between Orders and Catalog, so that the GET /v1/products?ids= that works today does not break silently.
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
