The design module ended with a list of open questions, and the first one was the most concrete: exactly how do the services talk to each other. We have decided that POST /orders responds 202 Accepted, that Orders looks up the catalog with GET /products?ids=, that Inventory exposes POST /reservations; but a contract is not a route name: it is the exact shape of the request, the response, the status codes, the error format and the headers that both sides commit to honoring. This lesson turns those design decisions into complete REST contracts.
REST is the synchronous communication style TechCorp will use in two places: in the public API (what the web and the mobile app consume through the gateway on port 8080) and in the few internal synchronous calls that 02-02 left authorized (Orders → Catalog and Orders → Customers). We will look at how resources and URIs are modeled, the semantics of the verbs and their idempotency, the status codes we will use throughout the course, TechCorp's five key contracts with request and response examples, a uniform error format based on RFC 7807, pagination, filtering, HATEOAS at a practical level, useful headers, documentation with OpenAPI 3 and, finally, a minimal Express endpoint and a fetch client that apply the contract. Versioning is left for 03-06, the gateway for 03-04 and resilience (retries, circuit breakers) for 06-03: here only the timeout appears, as the minimum good practice.
Contents
- REST in microservices: what it is and what it is not
- Resources and URIs
- HTTP verbs, semantics and idempotency
- The status codes we will use
- TechCorp's key REST contracts
- Uniform error format: RFC 7807
- Pagination, filtering and sorting
- HATEOAS at a practical level
- Useful headers
- Documentation with OpenAPI 3
- An Express endpoint and a
fetchclient that apply the contract
- REST in microservices: what it is and what it is not
REST (Representational State Transfer) is neither a protocol nor a library: it is a set of design constraints on top of HTTP. The ones that matter to us in microservices are four:
- Resources identified by URIs. An order is
/orders/ord-88213; a product,/products/p-501. The URI identifies the thing, not the action. - Manipulation through representations. The client never touches the PostgreSQL row: it sends and receives representations (JSON) of the resource.
- Uniform interface. The HTTP verbs (
GET,POST,PUT,PATCH,DELETE) mean the same thing in every service. Nobody inventsPOST /orders/get. - Stateless between requests. Every request carries everything it needs (authentication, identifiers). This is what lets us run eight replicas of Catalog behind a load balancer (03-05) without caring which one answers.
What REST is not: it is not "anything that returns JSON over HTTP". The difference between a REST API and an "RPC over HTTP" API lies in the use of the uniform interface, and in microservices it matters because the REST contract is the only boundary between teams: if the Orders team (Luis) can guess how a Catalog endpoint behaves just by reading its URI and its verb, there are fewer meetings, less documentation and fewer surprises.
- Resources and URIs
TechCorp's rules for naming URIs, aligned with the principle from 02-01 that "contracts express business":
| Rule | Good | Bad | Why |
|---|---|---|---|
| Plural nouns | /orders, /products |
/order, /getOrders |
The collection is the resource; HTTP supplies the verb. |
| Hierarchies for containment relationships | /orders/ord-88213/lines |
/lines?order=ord-88213 (acceptable, but secondary) |
Lines do not exist without their order (aggregate from 02-03). |
| Opaque, prefixed identifiers | /customers/c-1024 |
/customers/1024 |
The opaque ids from 02-04 do not leak database details. |
| Lowercase and hyphens | /order-lines |
/orderLines, /order_lines |
URIs are case-sensitive; the hyphen is the readable separator. |
| No extensions or verbs | /orders/ord-88213 |
/orders/ord-88213.json, /orders/cancel |
The format goes in Accept; the action, in the verb or in a sub-resource. |
| Business names, not table names | POST /reservations |
PATCH /stock/{id} |
We already decided this in 02-01: the reservation is the concept, the stock is the detail. |
One case that causes confusion: actions that do not fit a verb. How do you cancel an order? There are two respectable options:
- Treat the cancellation as a state change:
PATCH /orders/ord-88213with{"status": "CANCELLED"}. Simple, but it mixes very different changes into a single endpoint. - Model the action as a sub-resource:
POST /orders/ord-88213/cancellation. It creates "a cancellation" of the order, with its own body (reason) and its own validation. This is the one TechCorp will use, because the saga from 02-05 treats the cancellation as a business fact with areason, not as a field edit.
What we will never do is POST /orders/ord-88213/cancel with the verb in the URI, or expose the internal structure: /orders/ord-88213/lines yes, /order_lines?order_id= no.
- HTTP verbs, semantics and idempotency
Each verb has a meaning and two properties that are critical in distributed systems: whether it is safe (does not modify state) and whether it is idempotent (repeating it N times has the same effect as doing it once).
| Verb | Meaning | Safe | Idempotent | Use at TechCorp |
|---|---|---|---|---|
GET |
Read a representation | Yes | Yes | GET /orders/{id}, GET /products?ids= |
POST |
Create a subordinate resource or run a process | No | No | POST /orders, POST /reservations |
PUT |
Replace the whole resource at that URI | No | Yes | PUT /customers/{id}/address (replace the address) |
PATCH |
Modify partially | No | Depends on the body | PATCH /products/{id} with {"price": 54.90} |
DELETE |
Delete | No | Yes | DELETE /reservations/{id} (release a reservation) |
Why idempotency matters: in 01-02 we saw that the network is not reliable. If Orders calls PUT /customers/c-1024/address and the response gets lost, it can repeat the call without fear: the address will end up the same. If it repeats a POST /reservations that was already processed, there will be two reservations and the stock of p-501 will drop twice. That is why, in 02-05, we introduced the Idempotency-Key header for POST /orders: it makes a POST repeatable without changing its semantics. We will apply it to POST /reservations as well.
About PATCH: {"price": 54.90} is idempotent (repeating it leaves the same price); {"incrementStock": 5} is not. TechCorp's rule: PATCH bodies describe the desired state, never deltas.
- The status codes we will use
There is no need to memorize the ~60 HTTP codes. TechCorp uses a closed subset, and every service applies it the same way (we will package it in @techcorp/common-http, the technical library from 02-02):
| Code | Name | When we return it | Example at TechCorp |
|---|---|---|---|
200 OK |
Success with a body | Reads and modifications that return the resource | GET /orders/ord-88213 |
201 Created |
Resource created and already available | Complete synchronous creation; carries Location |
POST /reservations (the reservation exists when we respond) |
202 Accepted |
Request accepted, process in progress | Creation that triggers a saga; carries Location |
POST /orders (decision from 02-05) |
204 No Content |
Success without a body | DELETE, some PUT |
DELETE /reservations/res-4471 |
400 Bad Request |
Malformed request | Invalid JSON, missing required field, wrong type | POST /orders without lines |
401 Unauthorized |
Not authenticated | JWT token missing or invalid (07-01) | Any protected route |
403 Forbidden |
Authenticated but not allowed | A customer trying to read someone else's order | GET /orders/ord-99000 belonging to someone else |
404 Not Found |
The resource does not exist | Nonexistent id | GET /customers/c-9999 |
409 Conflict |
Conflict with the current state | Out of stock, stale version (If-Match), invalid state transition |
POST /reservations without stock; cancelling an order that is already CONFIRMED |
422 Unprocessable Entity |
Correct syntax, wrong semantics | Negative quantity, postal code that does not exist | POST /orders with quantity: -1 |
429 Too Many Requests |
Request limit exceeded | Rate limiting at the gateway (03-04) | 1,000 requests/min from one IP |
500 Internal Server Error |
Unhandled server failure | Uncaught exception, database down and not handled | Never on purpose |
503 Service Unavailable |
Service temporarily unavailable | Startup, dependency down, maintenance; may carry Retry-After |
Orders when Catalog is not responding |
Two nuances that TechCorp fixes by convention so that every team responds the same way:
400versus422.400is "I don't understand your request" (it is not JSON, a field is missing, a number arrives as text).422is "I understand it but it makes no sense" (quantity0, delivery date in the past). The distinction helps the client know whether the error is about serialization or about business.404versus409in business errors. From the monolith we inherited four errors:CUSTOMER_NOT_FOUND→404,PRODUCT_UNAVAILABLE→400,OUT_OF_STOCK→409,PAYMENT_REJECTED→402. In the new architecturePOST /ordersresponds202before knowing whether there is stock or whether the payment goes through, soOUT_OF_STOCKandPAYMENT_REJECTEDstop being HTTP responses of that endpoint and become reasons oforder.cancelled.OUT_OF_STOCKis still a409fromPOST /reservations(Inventory).PRODUCT_UNAVAILABLEbecomes422(the request is well-formed, but asks for something that is not on sale).
- TechCorp's key REST contracts
5.1 POST /orders (Orders, port 3002)
This is the most important contract in the course. Request: the client sends only what it knows: what it wants and where to send it. It does not send product names or prices (Orders freezes them by querying Catalog, as the aggregate from 02-03 establishes), nor the total (Orders computes it).
POST /orders HTTP/1.1
Host: orders-service:3002
Content-Type: application/json
Accept: application/json
Idempotency-Key: 7f3c9a2e-1b4d-4e8f-9c21-5a6b7c8d9e0f
X-Request-Id: req-01J4ZK9X2M
{
"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"
}
}Response: 202 Accepted because, as we decided in 02-05, the saga (stock reservation, charge, confirmation) is still in progress. Location says where to check the progress. The body returns the order as it was saved, with the prices already frozen and the status PENDING.
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /orders/ord-88213
X-Request-Id: req-01J4ZK9X2M
{
"id": "ord-88213",
"status": "PENDING",
"customerId": "c-1024",
"lines": [
{ "productId": "p-501", "name": "BT X200 Headphones", "quantity": 1, "unitPrice": 59.90 },
{ "productId": "p-777", "name": "USB-C Cable 2 m", "quantity": 2, "unitPrice": 9.90 }
],
"total": 79.70,
"shippingAddress": { "street": "Gran Vía 12", "postalCode": "28013", "city": "Madrid", "country": "ES" },
"createdAt": "2026-08-15T10:32:07Z",
"_links": {
"self": { "href": "/orders/ord-88213" },
"cancel": { "href": "/orders/ord-88213/cancellation", "method": "POST" }
}
}Behavior with Idempotency-Key: if the same client repeats the request with the same key (because it lost the response), Orders looks up idempotency_keys (02-05) and returns exactly the same response, without creating another order. If the key is reused with a different body, it responds 422 with code IDEMPOTENCY_KEY_REUSED.
Possible errors from this endpoint (all in the format of section 6):
| Situation | Code | code |
|---|---|---|
lines or customerId missing, invalid JSON |
400 |
INVALID_REQUEST |
quantity ≤ 0, empty lines, unsupported country |
422 |
INVALID_DATA |
The customer does not exist (Orders checks customers_ref or calls Customers) |
404 |
CUSTOMER_NOT_FOUND |
| Some product is not on sale according to Catalog | 422 |
PRODUCT_UNAVAILABLE |
| Catalog does not respond in time | 503 |
DEPENDENCY_UNAVAILABLE |
5.2 GET /orders/{id}
This is the read that follows the 202: the web polls (or receives a notification) until the status is no longer PENDING.
GET /orders/ord-88213 HTTP/1.1
Host: orders-service:3002
Accept: application/json
If-None-Match: "v3"HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v4"
{
"id": "ord-88213",
"status": "CONFIRMED",
"customerId": "c-1024",
"lines": [ "..." ],
"total": 79.70,
"history": [
{ "status": "PENDING", "at": "2026-08-15T10:32:07Z" },
{ "status": "STOCK_RESERVED", "at": "2026-08-15T10:32:08Z" },
{ "status": "PAID", "at": "2026-08-15T10:32:10Z" },
{ "status": "CONFIRMED", "at": "2026-08-15T10:32:10Z" }
],
"_links": { "self": { "href": "/orders/ord-88213" } }
}If the order has not changed since version "v3", the server responds 304 Not Modified without a body (it saves bandwidth during polling). If the order belongs to another customer, 403; if it does not exist, 404.
5.3 GET /products?ids=p-501,p-777 (Catalog, port 3001)
This is the most frequent internal synchronous call: Orders needs the name, price and availability of each line in order to freeze them. Designing GET /products/{id} and calling it once per line would be the classic N+1 problem: an order with 20 lines would mean 20 network round trips. That is why the contract accepts a batch:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=30
{
"data": [
{ "id": "p-501", "name": "BT X200 Headphones", "price": 59.90, "currency": "EUR", "available": true },
{ "id": "p-777", "name": "USB-C Cable 2 m", "price": 9.90, "currency": "EUR", "available": true }
],
"notFound": []
}Contract decisions worth explaining:
- Ids that do not exist do not cause a
404. A batch is a query against the collection; one missing id does not make the request fail. They are returned innotFoundand it is Orders that decides what to do (respond422 PRODUCT_UNAVAILABLE). - Batch limit. At most 100 ids; above that,
400. Without a limit, someone would end up requesting 5,000 products in a URL. Cache-Control: max-age=30. The catalog changes little; allowing 30 s of client-side caching reduces load during the ×20 peaks.- This JSON is the published language from 02-03; Orders translates it with its
productTranslatorinto its own model, so that if Catalog changesprice(we will see it in 03-06), only the translator changes.
5.4 GET /customers/{id} (Customers, port 3004)
Orders usually resolves the customer from its local replica customers_ref (02-04), but when the replica does not have it yet (a freshly registered customer) it makes this call:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "c-1024:7"
{
"id": "c-1024",
"name": "Ana Ruiz",
"email": "[email protected]",
"addresses": [
{ "id": "addr-1", "street": "Gran Vía 12", "postalCode": "28013", "city": "Madrid", "country": "ES", "isDefault": true }
]
}Note what it does not return: password, tokens, payment details. The representation is a business view of the customer, not a dump of the table.
5.5 POST /reservations (Inventory, port 3006)
In the choreography saga from 02-05, Inventory reserves stock by reacting to the order.created event, not through an HTTP call. Why a POST /reservations then? For three reasons: for the internal admin panel, for testing, and because in 02-03 we left open a partnership relationship Orders↔Inventory that in the future could become synchronous (gRPC in 03-03). Designing the contract now costs little and fixes the vocabulary.
POST /reservations HTTP/1.1
Host: inventory-service:3006
Content-Type: application/json
Idempotency-Key: ord-88213
{
"orderId": "ord-88213",
"lines": [
{ "productId": "p-501", "quantity": 1 },
{ "productId": "p-777", "quantity": 2 }
],
"expiresInSeconds": 900
}Response when there is stock. Here it is 201, because the reservation is made at that very instant (a local transaction in Inventory's database):
HTTP/1.1 201 Created
Content-Type: application/json
Location: /reservations/res-4471
{
"id": "res-4471",
"orderId": "ord-88213",
"status": "ACTIVE",
"expiresAt": "2026-08-15T10:47:07Z",
"lines": [
{ "productId": "p-501", "quantity": 1 },
{ "productId": "p-777", "quantity": 2 }
]
}And when there is no stock, a 409 detailing what is missing (information the saga turns into stock.rejected):
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://techcorp.example/errors/out-of-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Not enough units of 1 product",
"code": "OUT_OF_STOCK",
"instance": "/reservations",
"missing": [ { "productId": "p-501", "requested": 1, "available": 0 } ]
}Using orderId as the Idempotency-Key is deliberate: "one reservation per order" is exactly the guarantee we want.
- Uniform error format: RFC 7807
In the monolith, each controller returned errors however it saw fit: sometimes {"error": "..."}, sometimes {"message": "..."}, sometimes HTML. With six services and four teams, that multiplies. RFC 7807 (Problem Details for HTTP APIs) defines a standard format with the MIME type application/problem+json:
| Field | Required | Meaning |
|---|---|---|
type |
Yes (defaults to about:blank) |
URI identifying the type of problem. It does not have to resolve to anything, but it is useful if it points to documentation. |
title |
Yes | Human-readable summary, the same for every error of the same type. |
status |
Yes | The HTTP code, repeated in the body (useful when the body is logged or forwarded without the headers). |
detail |
No | Explanation specific to this occurrence. |
instance |
No | URI of the specific request that failed. |
| extensions | No | Any additional field. TechCorp always adds code. |
The code field is the extension TechCorp standardizes: a stable identifier in UPPER_SNAKE_CASE (CUSTOMER_NOT_FOUND, OUT_OF_STOCK, PRODUCT_UNAVAILABLE, INVALID_REQUEST, INVALID_DATA, DEPENDENCY_UNAVAILABLE, STALE_VERSION, IDEMPOTENCY_KEY_REUSED). Clients switch on code, never on detail, which is text for humans and may change.
Example of a validation error with per-field detail (errors extension):
{
"type": "https://techcorp.example/errors/invalid-data",
"title": "The request data is not valid",
"status": 422,
"detail": "1 field failed validation",
"code": "INVALID_DATA",
"instance": "/orders",
"errors": [
{ "field": "lines[1].quantity", "message": "must be greater than 0" }
]
}Mapping of the errors inherited from the monolith to the new model:
| Monolith error | HTTP code before | Now: where it appears | HTTP code now |
|---|---|---|---|
CUSTOMER_NOT_FOUND |
404 | POST /orders (upfront validation) |
404 |
PRODUCT_UNAVAILABLE |
400 | POST /orders after querying Catalog |
422 |
OUT_OF_STOCK |
409 | POST /reservations (Inventory); in the saga, reason of order.cancelled |
409 |
PAYMENT_REJECTED |
402 | No longer an HTTP response: it is the payment.rejected event and the PAYMENT_REJECTED reason of order.cancelled |
— |
A security tip: on a 500, detail never includes the exception message or the stack trace (it would leak paths, SQL queries, host names). It is logged with the X-Request-Id and the client receives a generic detail with that identifier so it can report it.
- Pagination, filtering and sorting
Every collection that can grow is paginated; GET /orders without a limit would end up returning 3,000 daily orders accumulated over years.
| Strategy | Request | Advantages | Drawbacks | Use at TechCorp |
|---|---|---|---|---|
| Offset | GET /products?limit=20&offset=40 |
Simple; lets you jump to page N; easy in SQL (LIMIT 20 OFFSET 40) |
Slow at large offsets; if an element is inserted between two pages, one is repeated or skipped | Catalog admin panel (small collections, "go to page 7" is needed) |
| Cursor | GET /orders?limit=20&cursor=eyJjcmVhdGVkQXQiOi4uLn0 |
Stable under insertions; constant performance (WHERE (created_at, id) < (...)) |
Cannot jump to an arbitrary page; the cursor is opaque | Customer order history, event listings: large collections that grow at one end |
Cursor-paginated response. The cursor is an opaque string (usually base64 of the {createdAt, id} of the last element) that the client sends back as is:
{
"data": [
{ "id": "ord-88213", "status": "CONFIRMED", "total": 79.70, "createdAt": "2026-08-15T10:32:07Z" },
{ "id": "ord-88102", "status": "CONFIRMED", "total": 24.50, "createdAt": "2026-08-14T18:05:44Z" }
],
"pagination": {
"limit": 2,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE0VDE4OjA1OjQ0WiIsImlkIjoib3JkLTg4MTAyIn0",
"hasNext": true
},
"_links": {
"self": { "href": "/orders?customerId=c-1024&limit=2&sort=-createdAt" },
"next": { "href": "/orders?customerId=c-1024&limit=2&sort=-createdAt&cursor=eyJjcmVhdGVkQXQiOi..." }
}
}TechCorp's conventions for filtering and sorting:
- Filters as query parameters named after the field:
?status=CONFIRMED,?customerId=c-1024. Ranges with suffixes:?createdFrom=2026-08-01&createdTo=2026-08-15. - Sorting with
sort=fieldascending andsort=-fielddescending; multiple fields separated by commas:sort=-createdAt,id. limitwith a default value (20) and a maximum (100). A client asking for 10,000 gets400.- Only filters and sort orders documented in OpenAPI are supported; an unknown parameter is ignored (tolerance, which 03-06 will formalize), but an invalid value in a known one yields
400.
- HATEOAS at a practical level
HATEOAS (Hypermedia As The Engine Of Application State) is the REST constraint that says the response must include links to the possible actions, so that the client "navigates" the API instead of building URIs. Taken to the extreme it produces APIs that are hard to consume, and few people apply it fully. TechCorp adopts a pragmatic version:
- A
_linksobject withself, always. - Links to actions that depend on the state: a
PENDINGorder includescancel; aCONFIRMEDone includesinvoicebut notcancel. This way the front end does not duplicate the state machine from 02-05 to know which button to show. - Pagination links (
next,prev). - Nothing else. Resources from other services are not linked (an order does not link to Customers'
/customers/{id}), because the client always enters through the gateway and internal URIs are of no use to it.
{
"id": "ord-88213",
"status": "PENDING",
"_links": {
"self": { "href": "/orders/ord-88213" },
"cancel": { "href": "/orders/ord-88213/cancellation", "method": "POST" }
}
}
- Useful headers
| Header | Direction | What we use it for |
|---|---|---|
Content-Type: application/json |
Request and response | Body format. Errors use application/problem+json. |
Accept: application/json |
Request | Desired format. If the server cannot provide it, 406. In 03-06 it will also be used for versioning. |
Location |
201/202 response |
URI of the created resource or of the one to poll for progress. |
ETag / If-None-Match |
Response / GET request |
Conditional caching: 304 if unchanged. |
ETag / If-Match |
Response / PUT/PATCH request |
Optimistic concurrency: the client says "modify only if it is still at the version I saw"; otherwise 412 Precondition Failed (or 409 with STALE_VERSION, TechCorp's convention for uniformity). |
Idempotency-Key |
POST request |
Repeat without duplicating (02-05). UUID generated by the client. |
X-Request-Id |
Both | Correlation identifier: the gateway generates it if absent, each service propagates it in its calls and its logs. It is the seed of the distributed tracing covered in 06-02. |
Cache-Control |
Response | max-age on cacheable reads (catalog); no-store on orders and personal data. |
Retry-After |
429/503 response |
How many seconds to wait before retrying. |
Optimistic concurrency example: two administrators edit the price of p-501 at the same time.
PATCH /products/p-501 HTTP/1.1
Content-Type: application/json
If-Match: "p-501:12"
{ "price": 54.90 }If the product is already at version 13 because the other administrator saved first, Catalog responds 409 with code: STALE_VERSION and the client reloads and decides. Without If-Match, the second save would silently overwrite the first.
- Documentation with OpenAPI 3
A contract that only lives in Luis's head is not a contract. OpenAPI 3 is the standard specification (YAML or JSON) for describing REST APIs: routes, parameters, bodies, responses and schemas. From it you generate browsable documentation (Swagger UI, Redoc), clients, request validators and, in 04-05, tests. Fragment of the Orders and Catalog specification:
openapi: 3.0.3
info:
title: TechCorp - Orders API
version: 1.0.0
servers:
- url: http://orders-service:3002
paths:
/orders:
post:
summary: Create an order
operationId: createOrder
parameters:
- in: header
name: Idempotency-Key
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NewOrder' }
responses:
'202':
description: Order accepted; the saga is still in progress
headers:
Location:
schema: { type: string, example: /orders/ord-88213 }
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'404':
$ref: '#/components/responses/CustomerNotFound'
'422':
$ref: '#/components/responses/InvalidData'
components:
schemas:
NewOrder:
type: object
required: [customerId, lines, shippingAddress]
properties:
customerId: { type: string, example: c-1024 }
lines:
type: array
minItems: 1
items:
type: object
required: [productId, quantity]
properties:
productId: { type: string, example: p-501 }
quantity: { type: integer, minimum: 1 }
shippingAddress: { $ref: '#/components/schemas/Address' }
Order:
type: object
properties:
id: { type: string, example: ord-88213 }
status:
type: string
enum: [PENDING, STOCK_RESERVED, PAID, CONFIRMED, CANCELLED]
total: { type: number, format: double, example: 79.70 }
Problem:
type: object
required: [type, title, status, code]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
code: { type: string, example: CUSTOMER_NOT_FOUND }
responses:
CustomerNotFound:
description: The given customer does not exist
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
InvalidData:
description: The data fails business validation
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }And the Catalog batch, in its own document (each service publishes its own OpenAPI; there is no global file):
paths:
/products:
get:
summary: Get products by batch of ids
operationId: getProducts
parameters:
- in: query
name: ids
required: true
description: Comma-separated ids (maximum 100)
schema: { type: string, example: "p-501,p-777" }
responses:
'200':
description: Products found; nonexistent ids go in notFound
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Product' }
notFound:
type: array
items: { type: string }How to read it: paths groups routes; within each verb, parameters describes query/headers/path, requestBody the body, and responses each code with its schema. components avoids repeating schemas and responses. In 03-06 we will see that writing this YAML before the code (design-first) is how Orders and Catalog get to work in parallel.
- An Express endpoint and a
fetch client that apply the contract
fetch client that apply the contractWe are not building the full service yet (structure, configuration and startup belong to module 4); only the piece that materializes the POST /orders contract.
// routes/orders.js (orders-service) — just the route, without the rest of the service
const express = require('express');
const { randomUUID } = require('node:crypto');
const router = express.Router();
// 1. Shape validation (400) and basic business validation (422)
function validateNewOrder(body) {
if (!body || typeof body.customerId !== 'string' || !Array.isArray(body.lines)) {
return { status: 400, code: 'INVALID_REQUEST', detail: 'customerId or lines missing' };
}
const errors = [];
if (body.lines.length === 0) errors.push({ field: 'lines', message: 'must have at least one line' });
body.lines.forEach((l, i) => {
if (!Number.isInteger(l.quantity) || l.quantity < 1) {
errors.push({ field: `lines[${i}].quantity`, message: 'must be an integer greater than 0' });
}
});
if (errors.length > 0) {
return { status: 422, code: 'INVALID_DATA', detail: `${errors.length} invalid field(s)`, errors };
}
return null;
}
// 2. Helper to respond with errors in RFC 7807 format
function sendProblem(res, req, problem) {
res.status(problem.status)
.type('application/problem+json')
.json({
type: `https://techcorp.example/errors/${problem.code.toLowerCase().replace(/_/g, '-')}`,
title: problem.title ?? 'Request error',
status: problem.status,
detail: problem.detail,
code: problem.code,
instance: req.originalUrl,
...(problem.errors && { errors: problem.errors })
});
}
// 3. The route: validates, delegates to the use case, responds 202 + Location
router.post('/orders', async (req, res, next) => {
const idempotencyKey = req.get('Idempotency-Key');
if (!idempotencyKey) {
return sendProblem(res, req, { status: 400, code: 'INVALID_REQUEST', detail: 'Idempotency-Key header missing' });
}
const problem = validateNewOrder(req.body);
if (problem) return sendProblem(res, req, problem);
try {
// createOrder queries Catalog, freezes prices, saves order + event in the outbox (02-05)
// and returns the order in PENDING status. Its full implementation belongs to module 4.
const order = await req.app.locals.useCases.createOrder(req.body, {
idempotencyKey,
requestId: req.get('X-Request-Id') ?? randomUUID()
});
res.status(202)
.location(`/orders/${order.id}`)
.json({
...order,
_links: {
self: { href: `/orders/${order.id}` },
cancel: { href: `/orders/${order.id}/cancellation`, method: 'POST' }
}
});
} catch (err) {
// Known business errors → contract codes; anything else → 500 (error middleware)
if (err.code === 'CUSTOMER_NOT_FOUND') return sendProblem(res, req, { status: 404, code: err.code, detail: err.message });
if (err.code === 'PRODUCT_UNAVAILABLE') return sendProblem(res, req, { status: 422, code: err.code, detail: err.message });
if (err.code === 'DEPENDENCY_UNAVAILABLE') return sendProblem(res, req, { status: 503, code: err.code, detail: err.message });
next(err);
}
});
module.exports = router;Step-by-step explanation:
validateNewOrderseparates the two levels: if the shape is wrong it returns400; if the shape is fine but the values are not,422with the list of fields. It is exactly the distinction from section 4.sendProblemcentralizes the RFC 7807 format. In the real project this function lives in@techcorp/common-httpso that Catalog, Inventory and the rest respond identically.- The route checks
Idempotency-Key, validates, delegates to the use case (which we already know from 02-05: it saves the order and itsorder.createdin the same transaction withsaveWithEvents()), and responds202withLocation. Notice that the route knows nothing about RabbitMQ or stock: it only translates HTTP into business and business into HTTP. - Business errors are mapped to contract codes; anything else goes to Express's error middleware, which responds
500without leaking details.
And the client: how Orders calls the Catalog batch. Node.js 20 includes fetch natively, and AbortSignal.timeout is the minimal way not to wait forever.
// clients/catalogClient.js (inside orders-service)
const CATALOG_URL = process.env.CATALOG_URL ?? 'http://catalog-service:3001';
async function getProducts(ids, { requestId }) {
const url = `${CATALOG_URL}/products?ids=${encodeURIComponent(ids.join(','))}`;
let response;
try {
response = await fetch(url, {
headers: { 'Accept': 'application/json', 'X-Request-Id': requestId },
// If Catalog does not respond within 2 s, fetch throws a TimeoutError
signal: AbortSignal.timeout(2000)
});
} catch (err) {
// Timeout or network error: for the POST /orders contract this is a 503
const error = new Error(`Catalog unavailable: ${err.name}`);
error.code = 'DEPENDENCY_UNAVAILABLE';
throw error;
}
if (!response.ok) {
// Catalog responded, but with an error: read the problem+json to log the code
const problem = await response.json().catch(() => ({}));
const error = new Error(`Catalog responded ${response.status} (${problem.code ?? 'no code'})`);
error.code = response.status >= 500 ? 'DEPENDENCY_UNAVAILABLE' : 'INVALID_REQUEST';
throw error;
}
const { data, notFound } = await response.json();
if (notFound.length > 0) {
const error = new Error(`Products unavailable: ${notFound.join(', ')}`);
error.code = 'PRODUCT_UNAVAILABLE';
throw error;
}
return data; // the productTranslator (ACL from 02-03) will convert them into the Orders model
}
module.exports = { getProducts };Key points of the client: the base URL comes from configuration (CATALOG_URL, whose default value is the stable service name we will justify in 03-05 and manage in 04-03); X-Request-Id is propagated; there is a 2 s timeout (without it, a Catalog outage would exhaust Orders' connections); "did not respond" is distinguished from "responded with an error"; and notFound is translated into the business error of the POST /orders contract. What is not here (retries, circuit breaker, fallback) is the subject of 06-03.
Common Mistakes and Tips
- Verbs in URIs (
/orders/create,/products/search). If you catch yourself writing a verb, ask what the resource is: it is almost always a collection (POST /orders) or a sub-resource (POST /orders/{id}/cancellation). - Returning
200for everything and putting the error in the body ({"ok": false}). It breaks HTTP caching, the gateway's monitors and every client's intuition. Use the table from section 4. 201when the work is not done. If at response time you still do not know whether there will be stock, it is202. A201promises that the resource is complete.- N+1 out of convenience.
GET /products/{id}in a loop looks clean until a 15-line order takes 15 × 40 ms. Design batches (?ids=) from the start. Idempotency-Keywithout persistence. Storing keys in process memory does not work with two replicas: theidempotency_keystable from 02-05 is mandatory.- Error messages as the contract. Clients must look at
code, notdetail. Changing a text should not break anyone. - Leaking internals in
500errors. Neverdetail: err.stack. Full log withX-Request-Id; to the client, a generic message with that id. - Forgetting the timeout in the client.
fetchwithoutsignalwaits indefinitely. Two seconds for internal calls is a good starting point. - Documenting afterwards. An OpenAPI written after the fact goes stale. Write it first (03-06) and validate requests against it.
Exercises
Exercise 1. Design the REST contract for "cancel an order" for the Orders service: URI and verb, request body (with reason), success and error responses with their status codes and code, taking into account the state machine from 02-05 (cancellation is only possible in PENDING, STOCK_RESERVED or PAID; not in CONFIRMED nor if it is already CANCELLED). Write an http example of a request and of an error response.
Exercise 2. The Shopping Experience team proposes GET /customers/c-1024/orders (in the Customers service) so that the web can show a customer's order history. Reason about whether that endpoint should live in Customers or in Orders, which URI you would propose, and which pagination strategy (offset or cursor) you would choose and why. Write the JSON response of the first page with two orders.
Exercise 3. Write an Express patch('/products/:id') function for Catalog that applies optimistic concurrency with If-Match. Assume there is a repository.get(id) that returns { ...product, version } and a repository.updateIfVersion(id, changes, expectedVersion) that returns the updated product or null if the version does not match. Respond 428 Precondition Required if If-Match is missing, 404 if it does not exist, 409 STALE_VERSION if the version does not match and 200 with a new ETag if it succeeds.
Solutions
Solution 1.
Contract: POST /orders/{id}/cancellation. The cancellation is a business fact with its own data (reason, who cancels), not the edit of a field, and POST is the verb for "create a sub-resource". It should accept Idempotency-Key (or, more simply, be idempotent by design: cancelling an already-cancelled order for the same reason returns 200 with the same body).
POST /orders/ord-88213/cancellation HTTP/1.1
Content-Type: application/json
Idempotency-Key: 2c1e5f9a-8b3d-4a7c-9e6f-1d2c3b4a5f60
{ "reason": "CUSTOMER_CHANGED_MIND", "comment": "Order duplicated by mistake" }Responses:
| Situation | Code | code |
|---|---|---|
Cancellation accepted (triggers order.cancelled; if there was a reservation or a payment, the saga compensates them) |
202 (the compensation is asynchronous; the order status becomes CANCELLED immediately but the stock release and the refund are still in progress) with Location: /orders/ord-88213 |
— |
| Order does not exist | 404 |
ORDER_NOT_FOUND |
| Order belongs to another customer | 403 |
ACCESS_DENIED |
Order in CONFIRMED |
409 |
TRANSITION_NOT_ALLOWED |
reason missing |
400 |
INVALID_REQUEST |
reason not in the allowed list |
422 |
INVALID_DATA |
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://techcorp.example/errors/transition-not-allowed",
"title": "The state transition is not allowed",
"status": 409,
"detail": "Order ord-88213 is CONFIRMED and can no longer be cancelled",
"code": "TRANSITION_NOT_ALLOWED",
"instance": "/orders/ord-88213/cancellation",
"currentStatus": "CONFIRMED"
}One could argue for 200 instead of 202 if the order is considered CANCELLED synchronously and the compensations are an internal detail; both are defensible, but the chosen one must be documented in OpenAPI and applied the same way across the whole API.
Solution 2.
Orders belong to the Orders bounded context (02-03): the history must be served by the Orders service, which owns the data and its up-to-date status. Putting the endpoint in Customers would force Customers to query Orders (unnecessary coupling) or to replicate orders (which do not belong to it). Proposed URI: GET /orders?customerId=c-1024&sort=-createdAt&limit=20 in Orders. The nested route /customers/{id}/orders can be offered by the gateway or the BFF (03-04) as a convenience for the front end, redirecting it internally to Orders, but the canonical contract is the filter on the /orders collection.
Pagination: cursor. A customer's history grows at one end (new orders come in at the top), it is a potentially large collection and the use case is "infinite scroll" on the web and in the app, not "go to page 7". With offset, a new order created while the user browses would shift everything and repeat an element between pages.
{
"data": [
{ "id": "ord-88213", "status": "CONFIRMED", "total": 79.70, "createdAt": "2026-08-15T10:32:07Z", "_links": { "self": { "href": "/orders/ord-88213" } } },
{ "id": "ord-88102", "status": "CONFIRMED", "total": 24.50, "createdAt": "2026-08-14T18:05:44Z", "_links": { "self": { "href": "/orders/ord-88102" } } }
],
"pagination": { "limit": 2, "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE0VDE4OjA1OjQ0WiIsImlkIjoib3JkLTg4MTAyIn0", "hasNext": true },
"_links": {
"self": { "href": "/orders?customerId=c-1024&sort=-createdAt&limit=2" },
"next": { "href": "/orders?customerId=c-1024&sort=-createdAt&limit=2&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE0VDE4OjA1OjQ0WiIsImlkIjoib3JkLTg4MTAyIn0" }
}
}Solution 3.
router.patch('/products/:id', async (req, res, next) => {
const ifMatch = req.get('If-Match');
if (!ifMatch) {
return sendProblem(res, req, {
status: 428, code: 'PRECONDITION_REQUIRED',
detail: 'You must send If-Match with the version of the product you are modifying'
});
}
try {
const current = await repository.get(req.params.id);
if (!current) {
return sendProblem(res, req, { status: 404, code: 'PRODUCT_NOT_FOUND', detail: `${req.params.id} does not exist` });
}
// The ETag has the form "p-501:12"; we extract the version number between the quotes
const expectedVersion = Number(ifMatch.replace(/"/g, '').split(':')[1]);
const updated = await repository.updateIfVersion(req.params.id, req.body, expectedVersion);
if (!updated) {
return sendProblem(res, req, {
status: 409, code: 'STALE_VERSION',
detail: `Product ${req.params.id} has changed; reload it (current version ${current.version})`
});
}
res.set('ETag', `"${updated.id}:${updated.version}"`).status(200).json(updated);
} catch (err) {
next(err);
}
});Points to check: the response ETag reflects the new version (13), so that a second PATCH from the same client can chain; and updateIfVersion must perform the check in the same SQL statement (UPDATE ... WHERE id = $1 AND version = $2), not in two steps, for the optimistic concurrency to be real.
Conclusion
We have turned the design decisions from module 2 into concrete REST contracts: URIs with nouns and hierarchies, verbs with their idempotency (and Idempotency-Key when the verb lacks it), a closed table of status codes that every service applies the same way, TechCorp's five key contracts (POST /orders with 202 + Location, GET /orders/{id} with ETag, GET /products?ids= in batch to avoid the N+1, GET /customers/{id}, POST /reservations with 201/409), uniform RFC 7807 errors with the code field replacing the monolith's scattered errors, cursor pagination for growing collections, minimal HATEOAS, caching, optimistic concurrency and correlation headers, OpenAPI 3 as the written form of the contract, and an Express route and a fetch client with timeout that apply it.
REST covers the synchronous calls, but in 02-05 we decided that the heart of the order flow (reserving stock, charging, confirming, notifying) travels through events: order.created, stock.reserved, payment.confirmed, order.confirmed, order.cancelled. We still need to see how they are really published and consumed: what an exchange and a queue are in RabbitMQ, how each event is routed to whoever is interested, what happens when a consumer fails and how we guarantee that an event is neither lost nor processed twice. That is asynchronous messaging, the next lesson.
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
