The Aroma Store API works. It has a contract, layers, validation, persistence, authentication, unified errors and tests protecting all of it. And yet, if tomorrow you hand it over to an external team, things will happen: they will ask why limit accepts 100 but expand has no ceiling; they will discover that the "my orders" screen in the mobile app needs five calls; someone will send "role": "administrator" on registration just to see what happens; and someone else will complain that invalid_data tells them that something is wrong but not what to write to fix it. None of those problems is a bug. They are all design decisions, and no automated test detects them.

This lesson is different from the previous ones: it does not add new code to the project, it adds judgement. In 02-01 we saw the design principles before building anything; now that you know how to build, we revisit them from the other side, with the experience of having implemented every piece. By the end you will have a review checklist applicable to any API, a catalogue of antipatterns so you can recognise them in real work, and a strategy for when you discover — because you will — that you have already got something wrong.

Contents

  1. Correct versus excellent
  2. Consistency as the supreme value
  3. How consistency is guaranteed in practice
  4. Linting the specification with Spectral
  5. Designing for the consumer: the "my orders" screen
  6. Too fine, too coarse: granularity
  7. Predictability and the principle of least surprise
  8. Sensible and safe defaults
  9. The robustness principle and its limits
  10. Idempotency and retries as an explicit contract
  11. Actionable errors
  12. Forward compatibility by design
  13. Health, metadata and a discoverable root
  14. Mandatory pagination and default limits
  15. Time zones, units and localisation
  16. Antipatterns to avoid
  17. The Aroma Store design review checklist
  18. Design debt: what to do when you have already got it wrong

  1. Correct versus excellent

A correct API meets its specification: the status codes are the ones the contract says, the data that goes in comes back out intact, the errors leak nothing. That is what you built in module 3 and it is a necessary condition.

An excellent API adds something that appears in no specification: the cost of using it is low. It is measured in an awkward unit to quantify but an easy one to recognise: how long it takes a developer who does not know the API to integrate their first complete use case, and how many times they have to open the documentation after the first week.

Dimension Correct API Excellent API
Correctness Does what it says Does what it says
Learning Learned by reading the whole documentation Guessed; the documentation confirms
Consistency Each endpoint is correct on its own They all follow the same rules
Errors They indicate that something failed They indicate what to do next
Use cases Every resource is reachable Real flows need few calls
Evolution Changing breaks clients Changing is routine
Defects Detected in production Detected in the design review

The practical difference is economic. An internal API consumed by three teams, with 40 developers integrating against it over two years, multiplies every small piece of friction by hundreds of hours. A naming decision taken in five minutes is paid for over years.

  1. Consistency as the supreme value

If you had to pick one single design property and sacrifice all the others, pick consistency. The reason is cognitive: a consumer learns an API by building a mental model, and that model is an extrapolation machine. If GET /v1/coffees?limit=20 works, they assume GET /v1/orders?limit=20 works. If that extrapolation is right 100% of the time, they stop reading the documentation and move fast. If it is right 90% of the time, they cannot trust any of it: they have to check all ten, and they go slower than if the API were uniformly mediocre.

A consistently imperfect API is more usable than an inconsistently perfect one. It is counterintuitive and it is true. If Aroma Store had chosen snake_case in its JSON, that would be a worse decision than camelCase for JavaScript consumers, but applied across all 24 resources it would cost exactly one paragraph of documentation. Mixing the two conventions costs one documentation lookup per field, for ever.

The dimensions where consistency breaks most easily, in order of how often it really happens:

Dimension Aroma Store rule Symptom of a break
Field naming camelCase always priceEuros next to created_at
Resource names Plural, noun, lowercase /coffees next to /getOrder
Collection format {"data": [...], "total": n} An endpoint returning a bare array
Error format {"error": {code, message, details}} A stray {"message": "..."}
Status codes The decision tree from 02-04 A 200 where there should be a 201
Pagination limit/offset, cursor on /orders page/per_page on a new resource
Dates ISO-8601 UTC with Z A 1734567890 epoch in one field
Money Euros with two decimal places on the outside A field in cents that slips through
Identifiers string with the cof_, ord_ prefix A naked integer in a new resource
Own headers The Aroma- prefix An X-Total-Count inherited from an example

Notice the pattern: almost all the breaks happen when something new is added, months later, when whoever adds it took no part in the original decisions and copies the style from an example found on the internet. Consistency is not an act of design, it is a maintenance process.

  1. How consistency is guaranteed in practice

Three mechanisms, from least to most automatic. All three are necessary; none replaces the others.

The living style guide

In 02-01 we wrote the Aroma Store style guide. The important adjective is living: a document written once and filed away is worth nothing. A living guide has three properties:

  • It lives in the repository, not in a corporate wiki. It is versioned with the code, reviewed by pull request and can be linked to a specific commit.
  • Every rule is normative and checkable. "Use clear names" is not a rule, it is a wish. "Resource names are plural nouns, lowercase, with no underscores" is one: two people apply it the same way.
  • It records decisions along with their reasons. When somebody asks a year from now why money travels in euros with two decimal places rather than in cents, the answer has to be written down. If it is not, the decision gets reverted out of ignorance.

A practical format is the ADR (Architecture Decision Record): one short file per decision, with context, decision and consequences.

<!-- docs/decisions/0007-money-in-euros-with-two-decimals.md -->
# 0007. Money travels in euros with two decimal places

- Status: accepted
- Date: 2026-03-14

## Context
Internally we store `price_cents` as an integer to avoid floating-point
errors. Outwards there were two options: expose whole cents
(`1450`) or euros with two decimal places (`14.50`).

## Decision
We expose `priceEuros: 14.50`. The conversion lives in `src/services/mappers.js`
and nowhere else.

## Consequences
- (+) The SPA and the mobile app display the value with no conversion and no risk
  of dividing it wrongly.
- (+) The documentation is self-explanatory: nobody confuses 1450 with €1450.
- (-) A careless consumer can add up in floating point and accumulate error.
  Mitigated by documenting it and by returning `totalEuros` already computed by the server.
- The field is called `priceEuros`, with the unit in the name, precisely because of (-).

That twenty-line file saves an hour-long discussion every time somebody new joins.

The design review

This is a review that happens before any code is written, over the specification, not over the implementation. Its goal is that no public endpoint is born without at least one other person having looked at its shape. The conversation is short if the design is good and long if it is not, which is exactly what you want: the cheap moment to change /v1/orders/{id}/cancel into /v1/orders/{id}/cancellation is while it only exists in a YAML file.

A fifteen-minute script that works:

  1. What real use case does this endpoint enable? (If there is no concrete answer, it does not get built.)
  2. Is it a resource, or a verb in disguise?
  3. Do the names follow the style guide? Do they look like the ones that already exist?
  4. Which status codes does it return, and which are missing?
  5. What happens if it is called twice? Is it idempotent? Should it be?
  6. Who can call it? What does a client who is not the owner see?
  7. Can a field be added six months from now without breaking anybody?
  8. Is it paginated if it returns a collection?

Automatic linting

Whatever a machine can check should not consume human time in the review. That is where Spectral comes in.

  1. Linting the specification with Spectral

Spectral is a linter for OpenAPI and AsyncAPI files. It runs over openapi.yaml — the one we started in 02-08 — and applies rules written by you. It turns the style guide, which is prose, into checks that fail in continuous integration.

# Installed as a development dependency of the project
npm install --save-dev @stoplight/spectral-cli

# Run against the contract
npx spectral lint openapi.yaml

The rules file is called .spectral.yaml and lives in the root of the project:

# .spectral.yaml — style rules for the Aroma Store API
extends: ["spectral:oas"]          # inherits the OpenAPI base rules (valid structure)

rules:
  # --- Inherited rules we adjust ---
  operation-tag-defined: error     # every operation must have a declared tag
  info-contact: error              # the contract must say who to write to

  # --- Aroma Store's own rules ---

  aroma-paths-lowercase-and-plural:
    description: Paths use plural, lowercase nouns, with no underscores or camelCase.
    message: "{{property}} does not follow the Aroma Store path convention."
    severity: error
    given: $.paths[*]~             # the ~ selects the KEY (the path), not its value
    then:
      function: pattern
      functionOptions:
        match: "^(/[a-z0-9-]+|/\\{[a-zA-Z]+\\})+$"

  aroma-no-verbs-in-uri:
    description: URIs contain no verbs; the action is expressed by the HTTP method.
    message: "The path {{property}} contains a verb: use a noun or a subresource."
    severity: error
    given: $.paths[*]~
    then:
      function: pattern
      functionOptions:
        notMatch: "(create|get|fetch|list|delete|remove|update|search)"

  aroma-properties-camelcase:
    description: All schema properties are written in camelCase.
    severity: error
    given: $.components.schemas[*].properties[*]~
    then:
      function: casing
      functionOptions:
        type: camel

  aroma-paginated-collections:
    description: Every GET operation returning a collection declares limit and offset.
    severity: warn
    given: $.paths[*].get
    then:
      field: parameters
      function: schema
      functionOptions:
        schema:
          type: array
          contains:
            type: object
            properties:
              name: { const: limit }

  aroma-every-operation-declares-401:
    description: Operations under /v1 must document the 401 response.
    severity: warn
    given: $.paths[?(@property.match(/^\/(coffees|orders|customers|reviews|carts)/))][get,post,put,patch,delete]
    then:
      field: responses.401
      function: truthy

  aroma-own-headers-prefixed:
    description: Own headers carry the Aroma- prefix, never X-.
    severity: error
    given: $.paths[*][*].responses[*].headers[*]~
    then:
      function: pattern
      functionOptions:
        notMatch: "^[Xx]-"

Let us go over the less obvious pieces:

  • extends: ["spectral:oas"] loads the official rule set that verifies the document is a structurally valid OpenAPI file. Your rules are added on top of those.
  • given is a JSONPath expression selecting the nodes to check. The ~ suffix is Spectral-specific and means "apply the rule to the node's key, not to its value": that is why $.paths[*]~ selects /coffees/{id} as text.
  • then.function is the check. pattern accepts match (must match) and notMatch (must not match); casing verifies naming conventions; truthy requires the field to exist and not be empty.
  • severity decides whether a failure breaks the build (error) or merely warns (warn). A new rule is always introduced as warn, the existing violations are cleaned up and only then is it raised to error; otherwise nobody can merge anything on the day you add it.

In continuous integration (which we will see in 05-05) this is one more step:

npx spectral lint openapi.yaml --fail-severity=error

The cultural effect is bigger than the technical one: the argument about style stops happening in every pull request and happens once, when the rule is proposed.

  1. Designing for the consumer: the "my orders" screen

Here is the commonest mistake in API design, and it is not a naming mistake: designing from the data model instead of from the use case. The Aroma Store resources are an almost exact reflection of the SQLite tables, which is convenient for us and sometimes terrible for whoever consumes them.

Let us look at a concrete case. The Aroma Mobile app has a "My orders" screen that shows, for each of the customer's last ten orders: date, status, total, and a thumbnail with the name of the first coffee in the list.

With the API exactly as it stands at the end of module 3, the mobile client does this:

GET /v1/customers/cus_842/orders?limit=10&sort=-createdAt
GET /v1/coffees/cof_001
GET /v1/coffees/cof_002
GET /v1/coffees/cof_007
... (one per distinct coffee appearing in the items)

Eleven requests to paint one screen. On a mobile network with 150 ms of latency per request, if the client chains them that is 1.6 seconds of round trips alone. And this is the N+1 problem, the very one we attacked inside the database in 03-05, but now happening above HTTP, where each hop costs a thousand times more.

The solution is not to invent GET /v1/my-orders-screen. It is to use the mechanism we already designed in 02-05:

GET /v1/customers/cus_842/orders?limit=10&sort=-createdAt&expand=items.coffee&fields=id,createdAt,status,totalEuros,items

One request. The response brings back nested exactly what is needed:

{
  "data": [
    {
      "id": "ord_5001",
      "createdAt": "2026-08-02T09:14:22Z",
      "status": "shipped",
      "totalEuros": 41.90,
      "items": [
        {
          "coffeeId": "cof_001",
          "quantity": 2,
          "coffee": { "id": "cof_001", "name": "Ethiopia Yirgacheffe", "roast": "light" }
        }
      ],
      "_links": {
        "self": { "href": "/v1/orders/ord_5001" },
        "return": { "href": "/v1/orders/ord_5001/return", "method": "POST" }
      }
    }
  ],
  "total": 7
}

The general principle: the number of calls a real use case requires is a first-order design metric. When you design a resource, write next to it the two or three flows that will use it and count the calls. If a frequent flow needs more than two or three, a mechanism is missing.

The available mechanisms, in order of preference:

Mechanism When Cost
expand over relationships The extra data is one hop away Low; already implemented
fields to slim things down The response is large and the client uses little of it Low
Collection subresource (/customers/{id}/orders) The relationship is the natural query Low
A new aggregate resource A critical, very frequent flow justifies it High: a resource to maintain for ever
GraphQL alongside Many clients with very divergent needs Very high (see 01-07)

  1. Too fine, too coarse: granularity

The previous section pushes towards fatter responses. There is a limit, and overshooting has its own punishment.

API too fine API too coarse
Symptom 11 calls for one screen One call returning 400 KB
Cost Accumulated latency, battery, complexity in the client Bandwidth, memory, unnecessary SQL queries
Caching Each piece caches well on its own Everything is invalidated when any part changes
Bad example GET /v1/orders/{id}/total as a separate resource GET /v1/orders/{id}?expand=customer.orders.items.coffee.reviews
Permissions Easy to scope per resource A single endpoint mixes data with different permissions

The Aroma Store balance is explicit and worth stating as a rule:

The default resource is fine-grained; the consumer fattens it on demand with expand, and the server caps how far that can go.

That "the server caps" is not optional: an uncapped expand is a denial-of-service vector, because the consumer decides how much work your database does. The concrete rule Aroma Store applies is maximum depth 2 plus an allowlist of expandable paths; why that is an availability defence and not merely a performance one we will see in 04-04.

  1. Predictability and the principle of least surprise

The principle of least surprise says that, faced with two valid designs, you should pick the one the consumer would have guessed. Applied to an API, it translates into a very concrete test you can run with no tooling: show the list of endpoints to somebody who does not know the system and ask them to predict the response of three of them. Whatever they get wrong is a surprise, and every surprise is a documentation lookup repeated by every consumer for the entire life of the API.

The axes on which predictability is won or lost:

Axis Predictable Surprising
Resource name /v1/orders /v1/purchase-order-v2
Field name priceEuros (unit in the name) price (euros? cents?)
Booleans available notAvailable, outOfStock (double negation)
Enums pending_payment | paid | shipped 0 | 1 | 2
Empty collection {"data": [], "total": 0} with 200 404, or null, or {}
Absent field Omitted, or null — but always the same Sometimes null, sometimes absent, sometimes ""
Repeated delete 204 the first time, 404 afterwards 500
Order with no sort Stable and documented (by id) Whatever SQLite feels like that day

Two practical rules that resolve most cases:

  • Names are chosen in the consumer's domain, not in the database's. The table may be called t_ord_hdr; the resource is called orders.
  • The same question is always answered in the same place. If a collection's total is in total, it is in total across all eleven collections, not in a header in some and in the body in others.

  1. Sensible and safe defaults

Every optional parameter has a default value, whether you declare it or not. If you do not declare it, the default is whatever your implementation happens to produce, and that is a design decision taken by accident.

A good default meets two conditions at once:

  1. Sensible: it is what 80% of consumers want, so they do not have to write it.
  2. Safe: if the consumer does not know what they are doing, the damage is bounded. When in doubt, the default is the conservative one.

Aroma Store's defaults, already implemented, with their justification:

Parameter Default Sensible because Safe because
limit 20 It fits on one screen Without it, GET /v1/coffees would dump the whole table
limit maximum 100 Enough for a batch Bounds the work per request
offset maximum 10,000 Nobody seriously pages beyond that Avoids giant OFFSETs that sweep the table
sort id ascending Stable, reproducible ordering Without an explicit order, pagination duplicates and skips rows
fields All public ones What you would expect The mapper's allowlist prevents internals leaking
expand None The base response is cheap The extra cost is always an explicit choice
Accept-Language en The store's main language Deterministic
Visibility of a new resource Private It is published by adding it to the contract, not by deploying it

The fourth row deserves a comment, because it is a classic mistake we already avoided in 03-05 almost without noticing: pagination with no explicit ordering is not deterministic. If the engine returns rows in whatever order suits it, offset=20 may repeat rows you already saw at 0 and skip others. That is why the tie-break by id is not a cosmetic detail, it is correctness.

  1. The robustness principle and its limits

The robustness principle (or Postel's law) says: be conservative in what you send, liberal in what you accept. It was born with TCP and has been applied to protocol design for decades. Today it is accepted with important caveats.

The first half is unconditionally good. Being conservative in what you send means: dates always in the same format, fields always of the same type, collections always with the same envelope, errors always with the same shape. There is never a reason to relax it.

The second half is dangerous. Liberally accepting whatever arrives seems friendly, but it has a brutal deferred cost:

  • If you accept priceEuros: "14.50" (a string) as well as the number, that behaviour becomes a de facto contract the moment a consumer relies on it. You can no longer remove it.
  • If you silently ignore fields you do not know, a consumer who writes priceEuro (no s) will believe they have updated the price when they have not. The failure shows up somewhere else, days later.
  • Every tolerance is a branch of code you have to test and maintain for ever.

That is why Aroma Store is strict on input: the Zod schemas carry .strict(), an unknown field produces 400 invalid_data instead of being ignored, and types are not coerced. It is less friendly in the first minute of integration and far friendlier over the following two years.

Where being tolerant is appropriate, with judgement:

Situation Tolerate Reason
Whitespace around a text value Yes, with .trim() Trivial human error, no ambiguity
Uppercase in an email address Yes, normalising to lowercase The domain part is case-insensitive
?roast=Light versus light No It teaches a convention and then contradicts it
Unknown field in the body No, never It silences errors and enables mass assignment (04-02)
A date in another format No The ambiguity of 03/04 cannot be resolved
Unknown field in the response a client receives Yes, always This is the tolerant reader: see section 12

The asymmetry in the last row is the key one and is often confused: strict when receiving requests, tolerant when reading other people's responses. They are different roles.

  1. Idempotency and retries as an explicit contract

In 02-03 we studied idempotency as a property of HTTP methods and in 03-03 we implemented it with Idempotency-Key. What is missing is the design half: idempotency is not a technical feature you switch on, it is a documented promise without which the consumer cannot retry safely.

The consumer's reasoning when faced with a timeout is always the same, and it is a real dilemma:

graph TD
  A[POST /v1/orders] --> B{Does a response arrive?}
  B -->|Yes, 201| C[Order created. Done]
  B -->|Timeout / network down| D{Was the order created?}
  D -->|I do not know| E{Does the API promise idempotency?}
  E -->|Yes, documented| F[Retry with the same Idempotency-Key]
  F --> G[Same 201 response, a single order]
  E -->|It does not say| H[Do not retry and risk losing the order]
  E -->|It does not say| I[Retry and risk charging twice]

Without the written promise, the consumer picks between two bad options. With it, the case stops being a problem.

What has to be documented, endpoint by endpoint, is a table like this one — which is also exactly what a consumer looks for when something fails in production:

Operation Idempotent? Mechanism What to do on a timeout
GET (any) Yes, by definition Retry freely
PUT /v1/coffees/{id} Yes, by definition Retry; the final state is the same
DELETE /v1/reviews/{id} Yes Second call → 404 Retry; 404 means "it is already gone"
POST /v1/orders Yes, with a key Idempotency-Key mandatory, 24 h Retry with the same key
POST /v1/orders/{id}/payment Yes, with a key Idempotency-Key mandatory, 24 h Retry with the same key
POST /v1/coffees/{id}/reviews No Query before retrying
PATCH with merge-patch+json Depends on the body Retry only if the patch is absolute

The PATCH row is the subtlest: {"stock": 100} is idempotent because it sets an absolute value; a hypothetical {"stockIncrement": 10} would not be. That is one more reason to prefer absolute patches.

And there is a design detail that always gets forgotten: what happens if the key is reused with a different body. Aroma Store answers 409 idempotency_key_reused, and that is the right call, because it almost always indicates a client bug (a key generated once per session instead of once per operation) and silencing it would make it undetectable.

  1. Actionable errors

The question to ask of every error message is a single one: what does the developer reading it do immediately afterwards? If the answer is "open the documentation", "ask in the support chat" or "try things", the error is not actionable.

{
  "error": {
    "code": "invalid_data",
    "message": "The request contains invalid data.",
    "details": [
      { "field": "roast", "message": "Must be one of: light, medium, dark. Received: 'roasted'." },
      { "field": "priceEuros", "message": "Must be a number greater than 0 with at most two decimal places. Received: -3." },
      { "field": "tastingNote", "message": "Unrecognised field. Did you mean 'tastingNotes'?" }
    ]
  }
}

The four properties of an actionable detail:

Property In the example Without it
Points at where "field": "roast" The developer eyeballs 12 fields looking for it
Says what was expected "one of: light, medium, dark" They have to open the documentation
Says what was received "Received: 'roasted'" They do not know whether the problem is their code or their data
Suggests the fix "Did you mean 'tastingNotes'?" They lose ten minutes to a typo

And the three complementary rules, all already implemented:

  • All the failures at once, not just the first. A consumer who fixes them one at a time makes six round trips to correct six typos.
  • A stable, machine-readable code (insufficient_stock), separate from the human-readable message. The code is contract; the message can be rewritten or translated.
  • Never leak the internals when building the message: no SQL, no file paths, no column names. We closed that off in 03-07 and it is just as true here.

One last design nuance: an API's error messages are written for developers, not for end users. "The customerId field is required" is correct in the API; "Please tell us where to send your order" is the SPA's job. Confusing the two audiences produces messages that are useless to both.

  1. Forward compatibility by design

In 02-07 we saw versioning as a strategy. Here is the other half: the better you design, the fewer times you will need a new version. A /v2 is an expensive failure; the goal is for /v1 to live for years.

Three techniques applied at design time, not afterwards.

Optional fields from the start. Adding an optional field to a response is compatible; adding a mandatory one to a request is not. So when you hesitate between requiring a field and giving it a default, the default is cheaper to maintain.

Extensible enums. status today is pending_payment | paid | shipped. Tomorrow there will be returned and cancelled. If the consumer wrote a switch with no default branch, your addition breaks their application. That is why the contract must say explicitly, in these words: "the set of values of this enum may grow; clients must handle unknown values without failing". And the documentation must show how:

// TOLERANT client: new values do not break the screen.
const LABELS = {
  pending_payment: 'Pending payment',
  paid: 'Paid',
  shipped: 'Shipped',
};

function statusLabel(status) {
  // If the server adds 'returned', we show something reasonable instead of breaking.
  return LABELS[status] ?? 'Unknown status';
}

Tolerant reader. This is the pattern that makes a consumer resistant to change. A tolerant reader:

  • reads only the fields it needs and ignores the ones it does not know (it never fails because a new field arrives);
  • does not depend on the order of an object's keys or of an array's elements unless the contract guarantees it;
  • does not validate the response against a closed schema that rejects additional properties;
  • does not rebuild URLs: it follows the _links the server gives it (that is where HATEOAS stops being theory, as we saw in 01-05).
// TOLERANT reader of an Aroma Store response.
function readCoffee(json) {
  return {
    id: json.id,
    name: json.name,
    price: json.priceEuros,
    // If 'altitudeMetres' or 'variety' arrive tomorrow, they are simply not read.
  };
}

// FRAGILE reader: breaks the day the API adds a field. Do not do this.
function readCoffeeFragile(json) {
  const keys = Object.keys(json);
  if (keys.length !== 8) throw new Error('unexpected response'); // ← a time bomb
  return json;
}

The consequence for you as the API's designer is twofold: document that the client must be tolerant and, above all, do not publish a schema with additionalProperties: false in responses, because you would be promising that you will never add a field. In requests, on the contrary, that restriction is exactly what you want.

  1. Health, metadata and a discoverable root

Two endpoints that are not part of the domain and that are almost always forgotten until they are needed.

GET /health already exists in src/app.js, deliberately outside /v1: it is not part of the business contract, it is infrastructure, and it must not be versioned along with it. Its full role (liveness versus readiness, and what it should and should not check) we develop in 04-07, because it belongs to observability.

GET /v1, the discoverable root, is the entry point that lets a client get started with no knowledge beyond a URL:

{
  "name": "Aroma Store API",
  "version": "1.0.0",
  "documentation": "https://api.aromastore.example/docs",
  "_links": {
    "self":      { "href": "/v1" },
    "coffees":   { "href": "/v1/coffees" },
    "orders":    { "href": "/v1/orders" },
    "customers": { "href": "/v1/customers" },
    "reviews":   { "href": "/v1/reviews" },
    "carts":     { "href": "/v1/carts" },
    "sessions":  { "href": "/v1/sessions", "method": "POST" }
  }
}

It is coherent with Richardson level 3 (01-05) and with the _links that all the resources already return. It costs twenty lines and gives you three things: somewhere to point at in the documentation, a trivial check for a new consumer, and a natural place to announce the version and the link to the documentation.

  1. Mandatory pagination and default limits

It is worth stating as an absolute rule, because the exceptions age badly:

No collection is ever returned unpaginated. Ever. Not even the ones that have four elements today.

The argument is about growth: when /v1/coffees had 12 records, returning them all seemed reasonable. With 4,000 lines and thirty mobile consumers, that decision is an outage. And you cannot add pagination afterwards without breaking the clients who assumed they received everything: the change from [...] to {"data": [...], "total": n} is incompatible, and capping at 20 what previously came back complete is worse, because it does not break visibly but instead makes consumers start losing data silently.

Hence the {"data": [...], "total": n} envelope has been there since day one on all eleven Aroma Store collections, even the ones that return three elements. The cost of having it is zero; the cost of adding it late is a new version.

  1. Time zones, units and localisation

Three sources of subtle bugs that are decided once and applied across the whole API.

Dates. ISO-8601, always in UTC, always with the explicit Z: "2026-08-02T09:14:22Z".

Format Problem
1754126062 (epoch) Unreadable; seconds/milliseconds ambiguity
02/08/2026 2 August or 8 February?
2026-08-02T09:14:22 No zone: interpreted differently by each client
2026-08-02T11:14:22+02:00 Valid, but it mixes two things and complicates comparison
2026-08-02T09:14:22Z Unambiguous, sortable as text

Converting to the user's zone is the client's responsibility, since it is the only party that knows where it is. Watch out for one real exception: a civil date with no time (a birthday, a batch's expiry date) is "2026-08-02" and nothing more, not an instant; converting it to UTC shifts it by a day across half of Europe.

Units in the name. This is the cheapest, highest-return practice in this lesson: priceEuros, weightGrams, durationSeconds, altitudeMetres. A weight field forces a documentation lookup every time; weightGrams does not. And the name travels with the data: it shows up in the logs, in the dumps and in the client's code.

Money. The Aroma Store rule: whole cents on the inside (price_cents), euros with two decimal places on the outside (priceEuros). Never floating point in the database or in the calculations, because 0.1 + 0.2 !== 0.3. And if one day the store sells outside the eurozone, you will need currency: "EUR" alongside the amount and, better still, an object {"amount": 14.50, "currency": "EUR"}; designing that now costs little and avoids an incompatible migration.

Localisation. The language of the content is negotiated with Accept-Language (02-05) and declared with Vary: Accept-Language so that caches do not mix languages — something that becomes critical in 04-06. What is never translated is the contract: field names, enum values and error codes are identifiers, not text for humans. status: "shipped" is a stable symbol; the word "Shipped" the user sees is put there by the SPA.

  1. Antipatterns to avoid

Antipattern Example Why it is bad Alternative
Verbs in the URI POST /v1/createOrder, GET /v1/orders/getAll Duplicates what the method already says; multiplies endpoints; breaks caching and proxies POST /v1/orders, GET /v1/orders
200 with success: false 200 OK + {"success": false, "error": "out of stock"} HTTP clients, proxies, caches and monitoring all believe everything is fine; forces you to inspect the body every time 409 + {"error": {"code": "insufficient_stock"}}
Exposing the database schema {"t_ord_id": 5001, "fk_cus": 842, "flg_del": 0} Ties the contract to the table: you cannot refactor; leaks internal information An explicit mapper with an allowlist (03-03)
The "all-in-one" endpoint POST /v1/api with {"action": "create_order", ...} It is RPC over HTTP: a single status code, no caching, no per-resource permissions Resources and HTTP methods
Magic parameters ?mode=2, ?type=A, ?flags=15 Nobody remembers what 2 means; impossible to read in a log ?status=paid, ?includeCancelled=true
A response that changes shape data is an object when there is one and an array when there are several The client needs an if at every consumption point; it breaks typing Always an array for collections, even with one element
Leaking internal identifiers Returning an autoincrement id, password_hash, active, version Allows enumeration of other people's resources and leaks sensitive data Opaque prefixed ids; an allowlist in the mapper
Deep nesting /v1/customers/842/orders/5001/items/3/coffee/reviews/101 Unpredictable URLs; the same resource reachable via N paths One level at most; the rest by id: /v1/reviews/rev_101
A GET that modifies GET /v1/orders/ord_5001/cancel A crawler or a browser prefetch cancels orders POST /v1/orders/ord_5001/cancellation
An unpaginated collection GET /v1/orders returns all 400,000 A guaranteed outage; impossible to fix without breaking things Pagination from day one
Numbers as enums "status": 2 Unreadable; 2 ends up meaning something else "status": "paid"
Nulls with meaning priceEuros: -1 for "unavailable" A careless client adds −1 to the basket available: false

The GET-that-modifies row is not theoretical: it is one of the most repeated incidents in the history of the web. A crawler following links, or a browser's prefetch, executes destructive actions because somebody decided a link was more convenient than a form. The safety of GET we saw in 02-03 is a promise made by intermediaries all across the network, not a recommendation.

  1. The Aroma Store design review checklist

This checklist is applied to every new endpoint before its implementation is written. It is actionable: every line is answered yes or no.

Resource and URI

  • [ ] The name is a plural noun, lowercase, with no verbs.
  • [ ] The path has at most one level of nesting.
  • [ ] The identifier is opaque and prefixed (cof_, ord_, cus_).
  • [ ] The URI is stable: it contains nothing that is going to change (status, category, year).

Methods and semantics

  • [ ] The method matches the semantics: GET is safe, PUT/DELETE are idempotent.
  • [ ] If it is a POST and it is not naturally idempotent, a decision has been taken on whether it requires Idempotency-Key.
  • [ ] There is a 405 with an Allow header for the methods that path does not support.

Requests

  • [ ] The body has a Zod schema with .strict(); unknown fields produce 400.
  • [ ] Every query parameter is on the allowlist; an unknown one produces 400 invalid_parameter.
  • [ ] Optional parameters have a documented default that is sensible and safe.
  • [ ] The body size is bounded (100 kB globally).

Responses

  • [ ] The status code comes out of the decision tree from 02-04.
  • [ ] If it is a collection: the {"data", "total"} envelope, paginated, with Link.
  • [ ] The fields are camelCase, with the unit in the name where applicable.
  • [ ] Dates are ISO-8601 UTC with Z.
  • [ ] It goes through the mapper: no internal column reaches the JSON.
  • [ ] _links.self always; action links only if the action is possible right now.
  • [ ] If it creates a resource: 201 with Location.

Errors

  • [ ] All the codes used exist in the catalogue, or a decision has been taken to extend and document it.
  • [ ] invalid_data returns all the failures, with field, expected and received.
  • [ ] No message leaks SQL, file paths, versions or the existence of other people's resources.

Security and permissions

  • [ ] It has been decided which roles can call it (customer, employee, administrator, partner).
  • [ ] A customer cannot access another's data nor tell "does not exist" apart from "is not yours".
  • [ ] No sensitive field can be set by mass assignment (role, active, balance).

Evolution

  • [ ] A field can be added to the response without breaking anybody.
  • [ ] The enums are documented as extensible.
  • [ ] It is in openapi.yaml and npx spectral lint passes with no errors.

Tests

  • [ ] There is an integration test for the happy path and for at least two errors.
  • [ ] There is a permissions test: access to somebody else's data is rejected.

  1. Design debt: what to do when you have already got it wrong

You are going to get things wrong. The useful question is not how to avoid it, but what to do afterwards. The first step is to classify the mistake, because the treatment depends on the type:

Type of mistake Example in Aroma Store Cost of fixing it Treatment
Cosmetic, with no consumers A badly named field on an endpoint nobody uses yet None Fix it today
Additive expand missing on a resource Low Add it; it is compatible
Widening a tolerance limit maximum from 100 to 200 Low Widen it; nobody breaks
Change of shape total moves from a header to the body High Temporary coexistence and Deprecation
Change of semantics status: "paid" starts meaning something else Very high A new field; the old one is frozen
Structural mistake The wrong resource, RPC in disguise Maximum A /v2 for that resource, or a redesign with dual writes

The five rules that make design debt manageable:

  1. Acknowledge it in writing. A docs/design-debt.md file saying "we know that POST /v1/carts/{id}/items/{coffeeId} should be a PUT and why we are not changing it" stops every new person reopening the discussion and, above all, stops the mistake being copied into the next resource.
  2. Stop the bleeding. The first job is not to fix the old thing, it is to make sure the new thing does not repeat the mistake. A Spectral rule prevents the pattern spreading even if you cannot clean up the past.
  3. Coexist before breaking. The new field and the old one are returned together; the old one is marked deprecated in OpenAPI and with the Deprecation and Sunset headers from 02-07.
  4. Measure before retiring. If you do not know how many consumers use the old field, you cannot retire it. Instrumenting it is a design need, not just an operational one; in 04-07 you will see how to count it.
  5. Batch the incompatible changes. If you have to break, break once: accumulate the incompatible changes and release them together in /v2. Three versions in a year destroy trust more than a design mistake does.

Common Mistakes and Tips

Confusing consistency with rigidity. Consistency is about shape, not about capabilities. A resource may have its own parameters that no other has; what it may not do is name them with a different convention.

Designing for the consumer you have today. Aroma Mobile's "my orders" screen is a use case, not the use case. Optimising the API until it becomes the backend of one specific screen makes it useless for the next client. The test: if an endpoint's name contains the name of a screen, you have crossed the line.

Adding an aggregate endpoint at the first complaint. Before creating /v1/customer-summary, check whether expand and fields solve the case. Every aggregate resource has to be maintained, versioned, documented and tested for ever.

Believing the style guide enforces itself. Without Spectral in continuous integration, the guide erodes in three months. Automate what can be automated on the same day you write the rule.

Putting all the Spectral rules on error at once. You block the entire team. Come in as warn, clean up and then raise it.

Treating openapi.yaml as documentation. It is the contract. If the code and the YAML differ, there is a bug somewhere; which of the two is at fault we will see in 05-04, with contract testing.

Tip: write the example request and response before the code. Five minutes writing the JSON you want to receive detects more design problems than two hours of implementing.

Tip: read your own API as if it were somebody else's. Close the editor, open only the documentation and try to solve a complete use case. Anything that forces you to look at the code is a design failure.

Exercises

Exercise 1: an antipattern audit

A team proposes these five endpoints for the Aroma Store loyalty module. Identify the antipatterns in each one and propose the correct alternative.

1. POST /v1/customers/cus_842/calculatePoints
2. GET  /v1/points?customer=842&mode=3
3. GET  /v1/customers/cus_842/points   → 200 {"success": true, "data": {...}}
                                          200 {"success": false, "error": "no programme"}
4. GET  /v1/customers/cus_842/orders/ord_5001/items/1/coffee/points
5. GET  /v1/promotions   → returns all 1,200 historical promotions

Exercise 2: cutting down a screen's calls

Aroma Mobile's "Order detail" screen shows: the order's data, the name and photo of each coffee in the items, the customer's delivery address and the shipment status. Today it needs: 1 call for the order + 1 per coffee (up to 5) + 1 for the customer + 1 for the shipment = up to 8 calls.

Design the single request that solves the screen using only the mechanisms that already exist in the API, and justify what limit you would put on expand so that this flow does not turn into a problem.

Exercise 3: writing a Spectral rule

Write a Spectral rule called aroma-date-time-fields-end-in-at that warns when a schema property has date-time format and its name does not end in At. Justify why the severity should be warn and not error at the moment of introducing it.

Solutions

Solution 1

No. Antipatterns Alternative
1 A verb in the URI (calculatePoints); on top of that, a POST that only reads GET /v1/customers/cus_842/points
2 An identifier with no prefix (842); a magic parameter (mode=3); filtering by customer on a global collection when the subresource exists GET /v1/customers/cus_842/points?includeExpired=true
3 200 with success:false; a data/success envelope different from the rest of the API 200 with the resource, or 404 {"error":{"code":"programme_not_found"}}
4 Deep nesting (six levels); the same data reachable via several paths GET /v1/coffees/cof_001/points, or a points field in the coffee's representation
5 An unpaginated collection GET /v1/promotions?limit=20&offset=0 with total and Link

And one cross-cutting antipattern: the /v1/points collection in case 2 suggests that "point" is a top-level resource when it is really an attribute of the customer-programme relationship. If there is no GET /v1/points/pnt_1 returning an individual point, the collection probably should not exist.

Solution 2

GET /v1/orders/ord_5001?expand=items.coffee,customer,shipment HTTP/1.1
Host: api.aromastore.example
Authorization: Bearer <token>
Accept: application/json

And to avoid bringing back more than necessary, combine it with fields:

GET /v1/orders/ord_5001?expand=items.coffee,customer,shipment&fields=id,status,totalEuros,items,customer,shipment

From eight calls to one. On the limits for expand, three restrictions that must be imposed together:

  1. Maximum depth 2. items.coffee is valid; items.coffee.reviews.author is not. Every level multiplies the queries.
  2. An allowlist of expandable paths per resource, declared in the contract. Not just any combination goes: only the ones with an efficient query behind them.
  3. Expanding unbounded collections is forbidden. expand=customer brings back one object; a hypothetical expand=customer.orders would bring a whole collection inside another. If it is allowed, it is paginated or capped at the first N.

Without those three rules, the consumer decides how much work your database does, which is precisely what has to be avoided (04-04).

Solution 3

  aroma-date-time-fields-end-in-at:
    description: date-time properties must be named with an 'At' suffix.
    message: "The property {{property}} is date-time but does not end in 'At'."
    severity: warn
    given: $.components.schemas[*].properties[?(@.format == 'date-time')]~
    then:
      function: pattern
      functionOptions:
        match: "At$"

The given combines two things: the JSONPath filter [?(@.format == 'date-time')] selects only the properties with that format, and the trailing ~ makes the check apply to the property's name rather than to its definition.

Why warn and not error when introducing it: the current specification already has properties that violate it (an inherited creationDate, for example). If the rule comes in as error, continuous integration goes red and blocks the whole team over a matter of style, and the likely reaction will be to switch it off. The correct procedure is to come in as warn, correct the violations in a dedicated pull request — renaming with a coexistence period if the field is already public — and only then raise it to error so that nobody can reintroduce the problem.

Conclusion

What separates a correct API from an excellent one is not a technique, it is a set of decisions taken with judgement and sustained over time. Consistency above all, because it is what lets the consumer extrapolate and stop reading the documentation; designing from the use case rather than from the data model, which turns eleven calls into one without inventing artificial resources; predictability, sensible and safe defaults, strictness on input and tolerance on reading; idempotency as a documented promise rather than an implementation detail; errors that say what to do next; and forward compatibility designed in from the start, so that /v1 lives for years. You also have the catalogue of antipatterns for recognising them in any API, the checklist to apply to every new endpoint, Spectral so that the style guide enforces itself, and a strategy for the design debt that already exists.

All of this improves the API for whoever uses it well. The next lesson deals with whoever uses it badly: in 04-02, Security in RESTful APIs, we will walk the OWASP API Security Top 10 over Aroma Store — with GET /v1/orders/ord_5001 belonging to another customer as the BOLA example, "role": "administrator" on registration as mass assignment and forgotten test endpoints as uncontrolled inventory — we will see why transport is always encrypted, which injections are still possible after the prepared statements from 03-05, we will add helmet to src/app.js with its exact position in the middleware chain, and we will finish with a lightweight threat model that says, asset by asset, where each defence is implemented.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved