Aroma Store's v1 contract is complete: resources, URIs, methods, codes, representations, collections and a versioning policy. But a contract that only exists in the heads of the team that designed it is not a contract: it is a tacit agreement waiting to be broken. This lesson covers the piece that turns the design into something other people can use. We will see why documentation is part of the product and not an extra, which kinds of document serve which reader, what each endpoint's reference must include, and what changes radically when the contract is written in a machine-readable format such as OpenAPI. We close module 2 by taking stock of the whole designed contract and preparing for the jump to module 3.
Contents
- Documentation is part of the product
- Types of documentation and who each one serves
- Anatomy of an endpoint's reference
- Contract-driven documentation: OpenAPI
- A real fragment:
GET /coffeesin OpenAPI - What a machine-readable specification unlocks
- Documentation as code
- Good writing practices
- Keeping documentation alive
- Taking stock of Aroma Store's contract
- Documentation is part of the product
An API has no visual interface. There are no buttons to explore and no menus hinting at what you can do. For a developer consuming it, the documentation literally is the product: if it is not documented, it does not exist.
Think about it from the other side. When SwiftShip's team sits down to integrate with Aroma Store, their experience consists of: reading, trying a curl, reading again, writing code, hitting an undocumented error, writing an email, waiting two days. Every one of those steps is cost. What good documentation reduces is not "annoyance": it is time to the first correct call, the metric that decides whether your API gets adopted or abandoned.
Practical consequences of taking it seriously:
- It is planned and estimated like any other feature. An undocumented endpoint is not finished.
- It is tested: the examples are run, not copied from memory.
- It has an owner and it is reviewed in pull requests.
- It reduces support: every question that arrives by email is a question the documentation failed to answer, and the right response is not to answer the email but to fix the documentation.
- Types of documentation and who each one serves
The most common mistake is writing a single giant document. There are different readers, at different moments, with incompatible needs.
| Type | Reader | When they read it | Question it answers |
|---|---|---|---|
| Quick start | A new developer | The first 15 minutes | How do I make my first call? |
| Concept guide | An integrator | When starting the design | How does this domain work? |
| Tutorials | An integrator | When implementing a use case | How do I do a complete flow? |
| Reference | Everyone | Constantly, while programming | Which parameters does this endpoint accept? |
| Changelog | An already active integrator | When upgrading, or when something fails | What has changed? |
| Runnable examples | Everyone | When testing | Can I see this working right now? |
Quick start
The goal is one single thing: one correct call in under five minutes. No architecture, no theory.
curl -H "Authorization: Bearer YOUR_TOKEN"
"https://api.aromastore.example/v1/coffees?limit=3"
{ "data": [ { "id": "cof_001", "name": "Ethiopia Yirgacheffe", "priceEuros": 14.50 } ], "total": 137 }
Concept guide
It explains the mental model, which no reference conveys. For Aroma Store: what a cart is and how it differs from an order, the order state machine, the review moderation cycle, how signed webhooks work, what it means for identifiers to be opaque. Without this, the integrator infers the model by trial and error, and infers it wrongly.
Tutorials
They walk through a complete use case from start to finish: "From cart to paid order", with the seven chained calls, their real responses and the likely errors at each step. It is what people are most grateful for and what gets written least.
Changelog
One entry per change, with the date, the type (added / changed / deprecated / removed / fixed) and a link to the migration guide where relevant:
## 2026-04-02
### Added
- `GET /v1/coffees` accepts the `available` filter (boolean).
- Coffees include `averageRating` and `reviewCount`.
### Deprecated
- The `price` field in the coffee representation. Use `priceEuros`.
Planned withdrawal: 2027-04-02. See the [migration guide](./migration-price).It is the cheapest document to maintain and the one that builds the most trust: it shows that the API is alive and that changes are announced.
- Anatomy of an endpoint's reference
The reference is what gets consulted daily. Every endpoint needs all of these elements; if one is missing, somebody will end up asking about it by email.
| Element | Detail |
|---|---|
| Method and path | GET /v1/coffees/{coffeeId} |
| Description | One sentence saying what it does and what it is for |
| Permissions | Which token or role is needed |
| Path parameters | Name, type, format, example |
| Query parameters | Name, type, whether required, default, accepted values, maximums |
| Headers | The ones it accepts or requires (Idempotency-Key, Accept-Language…) |
| Request body | Complete schema, mandatory fields, validations |
| Success response | Code, relevant headers and a complete example |
| Error responses | Every possible code with its error code |
| Limits | Rate limiting, maximum sizes, cost |
| Idempotency | Whether it is idempotent, and how that is guaranteed |
| Examples | A complete, copyable, working curl |
An abbreviated example of how it looks for Aroma Store:
### POST /v1/orders/{orderId}/payment
Pays for a pending order. It creates the payment resource associated with the
order and, if it completes, changes the order's status to `paid` and emits the
`order.paid` event towards SwiftShip.
**Permissions:** the customer who owns the order, or an internal panel token.
**Idempotency:** mandatory. You must send `Idempotency-Key` with a UUID that is
unique per payment attempt. Retries with the same key and the same body return
the original response with `Idempotent-Replay: true`.
**Path parameters**
| Name | Type | Description |
|---|---|---|
| `orderId` | string | The order's identifier. E.g.: `ord_5001` |
**Body**
| Field | Type | Required | Description |
|---|---|---|---|
| `method` | string | Yes | `card` or `bank_transfer` |
| `cardToken` | string | If `method` is `card` | The gateway's token |
**Responses**
| Code | When | Error `code` |
|---|---|---|
| `201` | Payment completed (`Location` header) | — |
| `400` | Invalid data or missing `Idempotency-Key` | `invalid_data`, `idempotency_key_required` |
| `401` | No valid authentication | `not_authenticated` |
| `403` | The order is not yours | `insufficient_permissions` |
| `404` | The order does not exist | `order_not_found` |
| `409` | The order is already paid or a payment is in progress | `order_already_paid`, `operation_in_progress` |
| `422` | `Idempotency-Key` reused with a different body | `idempotency_key_reused` |
| `502` | The payment gateway does not respond correctly | `service_unavailable` |
**Example**
curl -i -X POST "https://api.aromastore.example/v1/orders/ord_5001/payment"
-H "Authorization: Bearer $AROMA_TOKEN"
-H "Idempotency-Key: 5f3b9c2a-1d7e-4a44-9f30-8b1c2d3e4f50"
-H "Content-Type: application/json"
-d '{ "method": "card", "cardToken": "tok_visa_4242" }'
The error column is the one most often left out and the one that prevents the most incidents: without it, the integrator discovers the 409 the day a user clicks twice.
- Contract-driven documentation: OpenAPI
Everything above can be written by hand in Markdown. It works, and for a small API it may be enough. But there is an alternative that changes the rules of the game: writing the contract in a format machines understand.
OpenAPI (formerly Swagger) is a specification —today at version 3.1— for describing an HTTP API in YAML or JSON: its paths, methods, parameters, data schemas, responses, errors and security. It is not documentation about the API: it is the API described formally, and readable documentation is only one of its outputs.
graph TD
O["<b>openapi.yaml</b><br/>the contract"] --> D["Browsable reference<br/>documentation"]
O --> C["Generated clients<br/>(JS, Java, Python…)"]
O --> M["Simulated servers<br/>(mocks)"]
O --> T["Automated contract<br/>tests"]
O --> V["Request and response<br/>validation"]
O --> G["Gateway and portal<br/>configuration"]
The difference from hand-written documentation is one of nature, not of degree: a Markdown document describes the contract and can lie; an OpenAPI specification is the contract and can be verified against the implementation automatically.
This lesson stops at the why and at an illustrative fragment. Swagger and OpenAPI in depth —editors, generators, the interactive interface, good writing practices— are lesson 05-02, and using the contract for mocks and automated tests is 05-04.
- A real fragment:
GET /coffees in OpenAPI
GET /coffees in OpenAPIThis is what the contract of the coffee collection we designed in 02-06 looks like, written in OpenAPI 3.1:
openapi: 3.1.0
info:
title: Aroma Store API
version: 1.7.0
description: |
REST API of the speciality coffee shop Aroma Store.
All amounts are in euros with two decimals and all dates
are in ISO-8601 UTC.
servers:
- url: https://api.aromastore.example/v1
description: Production
paths:
/coffees:
get:
summary: Lists the coffee catalogue
description: |
Returns the catalogue's coffees, filtered, sorted and paginated.
Pagination is mandatory: if `limit` is not given, 20 is applied.
operationId: getCoffees
tags: [Coffees]
parameters:
- name: origin
in: query
description: Filters by country of origin. Accepts several comma-separated values.
schema: { type: string }
example: Colombia
- name: roast
in: query
description: Filters by roast level. Accepts several comma-separated values.
schema:
type: string
example: light,medium
- name: priceMin
in: query
description: Minimum price in euros, inclusive.
schema: { type: number, minimum: 0 }
- name: priceMax
in: query
description: Maximum price in euros, inclusive.
schema: { type: number, minimum: 0 }
- name: q
in: query
description: Text search over name, origin and tasting notes.
schema: { type: string, minLength: 2, maxLength: 100 }
- name: sort
in: query
description: |
Sorting field. Prefix with `-` for descending order.
Ties in the ordering are always broken by `id` ascending.
schema:
type: string
enum: [name, -name, priceEuros, -priceEuros, stock, -stock, createdAt, -createdAt]
default: name
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/offset'
responses:
'200':
description: List of coffees.
headers:
Link:
description: Pagination links (RFC 8288) with rel next, prev, first and last.
schema: { type: string }
content:
application/json:
schema:
type: object
required: [data, total]
properties:
data:
type: array
items: { $ref: '#/components/schemas/Coffee' }
total:
type: integer
description: Total number of elements matching the filter.
example:
data:
- id: cof_001
name: Ethiopia Yirgacheffe
origin: Ethiopia
roast: light
priceEuros: 14.50
stock: 120
tastingNotes: [citrus, floral, black tea]
createdAt: '2026-01-15T08:30:00Z'
total: 137
'400':
description: Invalid query parameter.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
code: invalid_parameter
message: "The 'limit' parameter cannot exceed 100."
details: []
components:
parameters:
limit:
name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
offset:
name: offset
in: query
schema: { type: integer, minimum: 0, maximum: 10000, default: 0 }
schemas:
Coffee:
type: object
required: [id, name, origin, roast, priceEuros, stock]
properties:
id:
type: string
pattern: '^cof_[a-zA-Z0-9]+$'
description: Opaque identifier. Do not parse it.
name: { type: string, maxLength: 120 }
origin: { type: string }
roast:
type: string
enum: [light, medium, dark]
description: New values may be added without prior notice.
priceEuros: { type: number, minimum: 0, multipleOf: 0.01 }
stock: { type: integer, minimum: 0 }
tastingNotes:
type: array
items: { type: string }
createdAt: { type: string, format: date-time }
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, details]
properties:
code: { type: string, example: coffee_not_found }
message: { type: string }
details: { type: array, items: { type: object } }Notice how much of this module's contract is encoded here, and verifiably so:
- The default
limitof 20 and maximum of 100, and theoffsetcap (02-06). - The
idtie-break in the ordering, documented in the description. - The
data/totalenvelope (02-05) and the error format withcode/message/details(02-04). - The identifier's pattern and the opacity warning (02-02).
- Amounts with
multipleOf: 0.01, that is, two decimals (02-05). - The warning that the
roastenumeration may grow (02-07). - The
Linkheader as a declared part of the response.
The $refs to components avoid repeating limit, offset, Coffee and Error on every endpoint: they are written once and referenced, which is exactly the consistency 02-01 asked for, now guaranteed by construction.
- What a machine-readable specification unlocks
| Output | What it is | Benefit | Covered in |
|---|---|---|---|
| Browsable reference | Generated HTML documentation | Never falls out of sync with the contract | 05-02 |
| Interactive interface | "Try it" from the browser | A first call without writing code | 05-02 |
| Generated clients | An SDK in several languages | The consumer writes no HTTP code | 05-02 |
| Simulated servers | A mock that responds according to the contract | The SPA moves forward without waiting for the server | 05-04 |
| Contract tests | Verify the implementation against the specification | They detect drift automatically | 05-04 |
| Runtime validation | Middleware validating requests and responses | Consistent errors without writing them by hand | 03-04 |
| Style linters | Check the style guide's conventions | The style guide stops being voluntary | 05-05 |
| Gateway configuration | Routes, limits and security imported | Less duplicated configuration | 05-06 |
The most important one for an API-first project like ours is the mock: with the openapi.yaml finished, the SPA and Aroma Mobile can start integrating on day one against a simulated server while the server team implements. That is what makes "design before you program" not lost time but parallelism gained.
- Documentation as code
Documentation is treated exactly like code:
- It lives in the repository, next to the implementation.
openapi.yamlat the root, guides indocs/. - It is versioned with Git, so the history answers "since when has it said this?".
- It is reviewed in pull requests: an API change that touches neither the contract nor the changelog is not approved.
- It is validated in continuous integration: the specification is checked syntactically, a style linter is run and the contract tests are executed.
- It is published automatically when merged into the main branch.
Typical structure of the Aroma Store repository:
aroma-store-api/ ├── openapi.yaml # the contract ├── CHANGELOG.md # changes by date ├── docs/ │ ├── style-guide.md # the conventions from 02-01 │ ├── quick-start.md │ ├── concepts/ │ │ ├── order-states.md │ │ ├── idempotency.md │ │ └── webhooks.md │ ├── tutorials/ │ │ └── from-cart-to-paid-order.md │ └── migrations/ │ └── migration-price.md └── src/ # the implementation (module 3)
The detail that makes this genuinely work: the quality gate in CI. If the pipeline fails when the implementation does not comply with the contract, documentation stops depending on goodwill. It is set up in 05-05.
- Good writing practices
Real, copyable examples. No <YOUR_ID_HERE> mixed in with fake data. Use the domain's identifiers (cof_001, ord_5001, cus_842) consistently across the whole documentation: whoever reads a tutorial recognises the same data in the reference.
# ✗ Useless: it cannot be run and it does not say what it returns
GET /coffees?params
# ✓ Copyable, runnable, with the expected response
curl -H "Authorization: Bearer $AROMA_TOKEN" \
"https://api.aromastore.example/v1/coffees?roast=medium&limit=2"A complete curl, always. With the authentication header, the URL in quotes (because of the &, as we saw in 01-03) and the whole body. It is the lowest common denominator: it works on any system and it assumes no programming language.
Document the errors as much as the successes. It is the most visible difference between professional and amateur documentation.
Explain the why, not just the what. "Idempotency-Key is mandatory because a retry after a timeout could charge twice" teaches; "Idempotency-Key: string, required" merely informs.
No "TBD", "to be documented" or "pending". A declared gap is worse than an absence: the reader wastes time trusting that it will turn up.
Consistency with the style guide. If the guide says camelCase, no example carries snake_case. Documentation is where inconsistencies show, and where they destroy trust fastest.
Write for someone who does not know your domain. The first use of "cart", "item" or "moderation" deserves a definition. SwiftShip's team knows about logistics, not about speciality coffee.
Careful with screenshots. They age badly and they cannot be searched or copied. Prefer code blocks.
- Keeping documentation alive
The enemy has a name: drift, the gap that opens between what the documentation says and what the API does. A badly documented API is a problem; an API that is badly documented but looks well documented is worse, because the integrator trusts it and fails.
Generated versus hand-written
| Approach | How it works | For | Against |
|---|---|---|---|
| Contract first | You write openapi.yaml and documentation, mocks and validation come out of it |
Consistent with API-first; allows mocks before implementing | Requires discipline so that the code follows the contract |
| Code first | You annotate the controllers and the specification is generated | Hard for it to fall out of sync with the implementation | It documents what exists, not what was agreed; it arrives late |
| Mixed | A hand-written contract + tests that verify the implementation | The best of both | You have to set up the contract tests |
Aroma Store uses the mixed approach: a hand-written openapi.yaml —it is the source of truth, consistent with the API-first stance of 02-01— and contract tests in CI that fail if the implementation deviates. Guides and tutorials are always written by hand: no generator explains why a cart is not an order.
Detecting drift
| Technique | What it detects | Where it is covered |
|---|---|---|
| Contract tests in CI | Responses that do not comply with the schema | 05-04 |
| Response validation in staging | New undocumented fields | 03-04 |
| Running the documentation's examples as tests | Stale examples | 05-04 |
| An OpenAPI linter | Broken conventions, missing descriptions | 05-05 |
| Mandatory review on every PR that touches the API | Undocumented changes | Process |
| Usage metrics compared with documented endpoints | Undocumented "ghost" endpoints | 04-07 |
Warning signs
- The changelog has had no entries for months, but the API has changed.
- The examples use fields that no longer exist.
- There are endpoints in production that do not appear in the specification.
- The real error responses do not match the documented ones.
- The team answers questions over chat that the documentation should be answering.
- Taking stock of Aroma Store's contract
This is what we have designed over the course of module 2, and it is exactly what module 3 is going to implement.
Method and principles (02-01). An API-first approach, identified consumers (SPA, Aroma Mobile, internal panel, SwiftShip), resources extracted from the domain and a written style guide.
Resources and URIs (02-02). A complete map of 24 URIs on https://api.aromastore.example/v1, lowercase plurals, kebab-case, a maximum of two levels of nesting, singular singletons, opaque prefixed identifiers and non-CRUD actions modelled as sub-resources with POST (/payment, /cancellation, /approval, /rejection).
Methods (02-03). GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS assigned resource by resource; PATCH with JSON Merge Patch; soft deletes invisible from the outside; Idempotency-Key mandatory on POST /orders and POST /orders/{id}/payment.
Codes (02-04). The codes that actually get used, with 201 + Location, 409 for state conflicts, the 401/403 and 404/410 distinctions; an in-house error format {"error": {"code", "message", "details"}} against problem+json; a catalogue of 28 business error codes.
Representations (02-05). camelCase, ISO-8601 UTC, euros with two decimals, extensible snake_case enumerations, null versus absent, the data/total envelope, the criteria for embedding versus linking, _links for selective hypermedia, expand and fields, and negotiation with Accept, Accept-Language, Accept-Encoding and Vary.
Collections (02-06). Explicit filters, Min/Max and From/To ranges, multi-value with commas, sort with an id tie-break, offset for /coffees and cursors for /orders, total in the body and navigation in the Link header, ?q= for search, and default and maximum limits.
Evolution (02-07). The version in the path, two live versions at most, 12 months of deprecation, Deprecation and Sunset headers and shutdown with 410 Gone.
Documentation (02-08). openapi.yaml as the source of truth, hand-written guides and tutorials, a changelog by date, everything in the repository and validated in CI.
Common Mistakes and Tips
- Leaving documentation until the end. That end never arrives. Document the endpoint in the same pull request that creates it.
- Documenting only the happy path. Errors are half the integrator's work: undocumented
409s and422s generate incidents nobody can explain. - Examples that cannot be run. Test every
curlin the documentation. If one example fails, you have lost the reader's trust in everything else. - Confusing the reference with a guide. The reference says what an endpoint accepts; it never explains how to chain six calls to pay for an order. You need both.
- Generating documentation from the code and calling it good. It documents what exists, bugs included, and it says nothing about concepts or intent.
- Not documenting the limits. Rate limits, maximum sizes, the maximum
limitand expansion depth are part of the contract: without them, the integrator discovers them with a400in production. - Forgetting the changelog. It is the cheapest document and the one an external consumer appreciates most.
- Tip: measure the "time to the first correct call". Sit down with somebody who does not know the API, give them the documentation and time them in silence. You will learn more in twenty minutes than in three meetings.
- Tip: treat every support question as a documentation failure. The answer is not to reply to the email, it is to fix the document and then reply with the link.
Exercises
Exercise 1: write an endpoint's reference
Write the complete reference documentation for POST /v1/coffees/{coffeeId}/reviews with all the elements from section 3. Use the contract designed in this module: the body carries rating (an integer from 1 to 5) and comment (text of up to 5,000 characters); the review is created with the status pending_moderation; only customers who have bought that coffee can review it.
Exercise 2: complete the OpenAPI contract
Extend the fragment from section 5 by adding the GET /coffees/{coffeeId} operation: the path parameter, a 200 response with the reused Coffee schema, a 404 response with the Error schema and an example of the coffee_not_found error, and a 304 response for conditional caching. Reuse the existing components.
Exercise 3: detect drift
A developer joins Aroma Store and finds this. Identify all the documentation problems and propose the concrete remedy and the process that would stop it happening again.
- The reference says
GET /v1/coffeesreturns an array; the API returns{"data": [...], "total": n}. - There is no mention at all of the
expandparameter, which nevertheless works. - The
POST /v1/ordersexample does not include theIdempotency-Keyheader, which is mandatory. - The error table for
POST /v1/orders/{id}/paymentonly lists400and500. - The changelog stops eight months ago.
- There is a
/v1/promotionsendpoint in production that appears nowhere.
Solutions
Solution 1
### POST /v1/coffees/{coffeeId}/reviews
Creates a review of a coffee. The review is created with the status
`pending_moderation` and does not appear in public listings until a moderator
approves it with `POST /v1/reviews/{reviewId}/approval`.
**Permissions:** an authenticated customer who has bought the coffee in an order
with the status `shipped`. Otherwise `403` is returned.
**Idempotency:** not mandatory. `Idempotency-Key` is optional and recommended to
avoid duplicate reviews from a double form submission.
**Path parameters**
| Name | Type | Description |
|---|---|---|
| `coffeeId` | string | The coffee's identifier. E.g.: `cof_001` |
**Body**
| Field | Type | Required | Validation |
|---|---|---|---|
| `rating` | integer | Yes | Between 1 and 5, both included |
| `comment` | string | Yes | Between 10 and 5,000 characters |
**Responses**
| Code | When | Error `code` |
|---|---|---|
| `201` | Review created (`Location` header) | — |
| `400` | Validation failed | `invalid_data` |
| `401` | No authentication | `not_authenticated` |
| `403` | The customer has not bought this coffee | `insufficient_permissions` |
| `404` | The coffee does not exist | `coffee_not_found` |
| `409` | The customer has already reviewed this coffee | `duplicate_review` |
| `410` | The coffee has been discontinued | `coffee_discontinued` |
| `429` | Request limit exceeded | `rate_limit_exceeded` |
**Limits:** a maximum of 5 reviews per customer per day.
**Example**
curl -i -X POST "https://api.aromastore.example/v1/coffees/cof_001/reviews"
-H "Authorization: Bearer $AROMA_TOKEN"
-H "Content-Type: application/json"
-d '{ "rating": 5, "comment": "Citrus and floral, spectacular in a V60." }'
HTTP/1.1 201 Created Location: https://api.aromastore.example/v1/reviews/rev_101
{ "id": "rev_101", "coffeeId": "cof_001", "customerId": "cus_842", "rating": 5, "comment": "Citrus and floral, spectacular in a V60.", "status": "pending_moderation", "createdAt": "2026-03-14T10:30:00Z", "_links": { "self": { "href": "/v1/reviews/rev_101" }, "coffee": { "href": "/v1/coffees/cof_001" } } }
Notice that a new error code has appeared, duplicate_review (409): documenting forces you to close decisions the design had left open. That is one of the great benefits of writing the reference before implementing.
Solution 2
/coffees/{coffeeId}:
get:
summary: Retrieves one specific coffee
operationId: getCoffeeById
tags: [Coffees]
parameters:
- name: coffeeId
in: path
required: true
description: The coffee's opaque identifier.
schema:
type: string
pattern: '^cof_[a-zA-Z0-9]+$'
example: cof_001
- name: If-None-Match
in: header
required: false
description: The ETag of a previous copy, for conditional caching.
schema: { type: string }
example: '"a1b2c3d4"'
responses:
'200':
description: The requested coffee.
headers:
ETag:
description: Version identifier of the representation.
schema: { type: string }
content:
application/json:
schema: { $ref: '#/components/schemas/Coffee' }
example:
id: cof_001
name: Ethiopia Yirgacheffe
origin: Ethiopia
roast: light
priceEuros: 14.50
stock: 120
tastingNotes: [citrus, floral, black tea]
createdAt: '2026-01-15T08:30:00Z'
'304':
description: |
The representation has not changed since the version given in
`If-None-Match`. No body.
headers:
ETag:
schema: { type: string }
'404':
description: No coffee exists with that identifier.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
code: coffee_not_found
message: "No coffee exists with the identifier 'cof_999'."
details: []
'410':
description: The coffee existed and has been permanently discontinued.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
code: coffee_discontinued
message: "The coffee 'cof_001' was withdrawn from the catalogue on 2026-02-01."
details: []Note that Coffee and Error are reused with $ref: when a field is added to the Coffee schema tomorrow, it automatically appears in both operations and in the generated documentation. That is consistency guaranteed by construction, not by human review.
Solution 3
| Problem | Severity | Remedy | Prevention |
|---|---|---|---|
| The reference says array and the API returns an envelope | Critical: every new integrator writes code that fails on the first call | Fix the schema in openapi.yaml and publish |
Contract tests in CI (05-04): a response that does not comply with the schema must break the build |
expand undocumented |
High: invisible functionality that nobody is guaranteeing to maintain either | Document it with its rules (one level, maximum 3, 400 if unknown) |
Mandatory PR review: no new parameter is merged without contract |
Missing Idempotency-Key in the example |
High: the copied example returns 400 |
Fix the example and mark the header as mandatory | Run the examples as tests in CI |
| Incomplete error table | High: the 409 order_already_paid shows up in production with no warning |
Complete it with 401, 403, 404, 409, 422 and 502 |
An endpoint template with the error table as a mandatory field |
| Abandoned changelog | Medium: trust is lost and deprecations go unnoticed | Rebuild it from the Git history and pick it up again | A CI check: if openapi.yaml changes, CHANGELOG.md must change |
Ghost endpoint /v1/promotions |
Critical: an undocumented, unversioned and probably security-unaudited surface | Decide: document it or withdraw it. There is no third option | Compare the real routes (metrics from 04-07) with those in the specification and alert on the differences |
The process that prevents all of it, in one sentence: the specification is the source of truth, it lives in the repository, it is validated on every pull request and the pipeline fails if the implementation does not comply with it. Without an automatic gate, drift is only a matter of time.
Conclusion
Documentation is not what you write after programming: it is the visible face of a product that has no interface. You now know that different readers need different documents —quick start, concepts, tutorials, reference, changelog—, what each endpoint's reference must contain (including all of its errors, which is what gets left out most), and why writing the contract in OpenAPI changes the nature of the matter: it stops being a text that describes the API and becomes the contract itself, out of which come the browsable documentation, the clients, the mocks, the tests and the gateway configuration. With documentation as code, reviewed in pull requests and validated in CI, drift stops depending on goodwill.
With this we close module 2. You have designed, piece by piece and on paper, the complete contract of the Aroma Store API: its working method and its style guide, its 24 URIs with the actions modelled as sub-resources, its methods with the idempotency of payment resolved, its table of codes and its catalogue of business errors, its representations with selective hypermedia, its filtered and paginated collections, its versioning and deprecation policy, and its documentation. None of this has needed a single line of server code yet, and that was exactly the idea: in API-first, the contract goes first. Now it has to be honoured. In module 3, Developing RESTful APIs, we will set up the environment with Node.js 20, bring up the server with Express and turn every decision from this module into code: the routes of the URI map, the validation that returns invalid_data with its details, the persistence layer that converts cents into euros, the authentication that distinguishes 401 from 403, the error middleware that emits the format we have fixed and the tests that verify the implementation respects the contract. We start at the beginning: 03-01, Setting Up the Development Environment.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
