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

  1. REST in microservices: what it is and what it is not
  2. Resources and URIs
  3. HTTP verbs, semantics and idempotency
  4. The status codes we will use
  5. TechCorp's key REST contracts
  6. Uniform error format: RFC 7807
  7. Pagination, filtering and sorting
  8. HATEOAS at a practical level
  9. Useful headers
  10. Documentation with OpenAPI 3
  11. An Express endpoint and a fetch client that apply the contract

  1. 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 invents POST /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.

  1. 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-88213 with {"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 a reason, 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.

  1. 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.

  1. 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:

  • 400 versus 422. 400 is "I don't understand your request" (it is not JSON, a field is missing, a number arrives as text). 422 is "I understand it but it makes no sense" (quantity 0, delivery date in the past). The distinction helps the client know whether the error is about serialization or about business.
  • 404 versus 409 in 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 architecture POST /orders responds 202 before knowing whether there is stock or whether the payment goes through, so OUT_OF_STOCK and PAYMENT_REJECTED stop being HTTP responses of that endpoint and become reasons of order.cancelled. OUT_OF_STOCK is still a 409 from POST /reservations (Inventory). PRODUCT_UNAVAILABLE becomes 422 (the request is well-formed, but asks for something that is not on sale).

  1. 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:

GET /products?ids=p-501,p-777 HTTP/1.1
Host: catalog-service:3001
Accept: application/json
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 in notFound and it is Orders that decides what to do (respond 422 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 productTranslator into its own model, so that if Catalog changes price (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:

GET /customers/c-1024 HTTP/1.1
Host: customers-service:3004
Accept: application/json
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.

  1. 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.

  1. 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:

GET /orders?customerId=c-1024&limit=2&sort=-createdAt HTTP/1.1
{
  "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=field ascending and sort=-field descending; multiple fields separated by commas: sort=-createdAt,id.
  • limit with a default value (20) and a maximum (100). A client asking for 10,000 gets 400.
  • 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.

  1. 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 _links object with self, always.
  • Links to actions that depend on the state: a PENDING order includes cancel; a CONFIRMED one includes invoice but not cancel. 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" }
  }
}

  1. 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.

  1. 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.

  1. An Express endpoint and a fetch client that apply the contract

We 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:

  1. validateNewOrder separates the two levels: if the shape is wrong it returns 400; if the shape is fine but the values are not, 422 with the list of fields. It is exactly the distinction from section 4.
  2. sendProblem centralizes the RFC 7807 format. In the real project this function lives in @techcorp/common-http so that Catalog, Inventory and the rest respond identically.
  3. The route checks Idempotency-Key, validates, delegates to the use case (which we already know from 02-05: it saves the order and its order.created in the same transaction with saveWithEvents()), and responds 202 with Location. Notice that the route knows nothing about RabbitMQ or stock: it only translates HTTP into business and business into HTTP.
  4. Business errors are mapped to contract codes; anything else goes to Express's error middleware, which responds 500 without 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 200 for 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.
  • 201 when the work is not done. If at response time you still do not know whether there will be stock, it is 202. A 201 promises 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-Key without persistence. Storing keys in process memory does not work with two replicas: the idempotency_keys table from 02-05 is mandatory.
  • Error messages as the contract. Clients must look at code, not detail. Changing a text should not break anyone.
  • Leaking internals in 500 errors. Never detail: err.stack. Full log with X-Request-Id; to the client, a generic message with that id.
  • Forgetting the timeout in the client. fetch without signal waits 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

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved