Across five modules we have been building the Aroma Store API piece by piece: first the concepts, then the design, then the code, the security, the operation and the tooling. Each lesson solved one problem and left the next one open. This lesson does something different: it looks at the finished result and asks why it turned out that way.
It is not a summary. A summary would repeat what you already know; what matters here is the justification taken as a whole, which is precisely what never shows up when you learn one piece at a time. Why the cart ended up being a resource and the checkout did not. Why orders are paginated by cursor and coffees by offset. Why money travels in euros but is stored in cents. And, above all, what was rejected at every fork in the road and what price was paid for choosing.
Think of this lesson as the technical write-up you would hand to a team inheriting the project: context, decisions with their rejected alternative, complete flows, the hard cases almost nobody documents, the deployed architecture, the service level objectives and an honest critique of what did not go well. By the end you will have a checklist you can apply to any API you design.
Contents
- The business and its constraints
- The consumers: five clients, five different needs
- Critical use cases and non-functional requirements
- From the domain model to the resources
- The complete URI map
- The design decisions and the alternatives that were rejected
- The complete flow of a purchase
- Hard cases and how they were solved
- The deployed architecture
- Service metrics and SLOs
- What we would do differently
- Final project checklist
- The business and its constraints
Aroma Store sells speciality coffee. It is not a supermarket: the catalogue holds between 40 and 120 live references, with origin, roast, tasting notes and batches that run out. Margins are high, volume is moderate and customer loyalty is everything: a regular buyer orders every three or four weeks for years on end.
These three characteristics of the business shape the API far more than you might expect:
| Business trait | Technical consequence |
|---|---|
| Small, slow-changing catalogue | The catalogue is aggressively cacheable; it needs neither cursor pagination nor distributed search |
| Real, finite stock per batch | Stock is consistent and transactional, not approximate; we cannot sell what we do not have |
| Few orders, but high value each | A duplicated order is a serious problem: idempotency is not a luxury |
| Repeat purchasing | Orders grow without end per customer: cursor pagination on /orders |
| Christmas campaign | Peaks of 20-30 times normal traffic for six weeks |
| Personal data of European customers | GDPR applies: rights of access, rectification and erasure |
None of these decisions comes out of a style guide. They come out of the business. That is the first message of the lesson: the same set of REST rules produces different APIs depending on the domain, and in 06-02 we will see that far more brutally.
- The consumers: five clients, five different needs
In 01-01 we said an API is designed for its consumers, not for its database. Aroma Store has five, and each one pulls the design in a different direction.
| Consumer | Who it is | What it needs | How it pulled the design |
|---|---|---|---|
SPA aromastore.example |
Public web, browser | Fast catalogue, cart, checkout | Forced CORS with an allowlist (04-05), caching with ETag (04-06) and _links on orders |
| Aroma Mobile | Native iOS/Android app | The same, but over a poor network and expensive data | Forced fields for partial responses, compression and tolerance to retries |
Internal panel panel.aromastore.example |
Employees and administrators | Moderate reviews, manage orders, watch live | Forced roles (employee, administrator) and SSE for real time |
| SwiftShip | Carrier, system to system | To learn about paid orders without polling | Forced signed webhooks with HMAC-SHA256 and retries |
| CataBox | Third-party partner app | To read the catalogue and write reviews on the user's behalf | Forced OAuth 2.0 with scopes (04-03) and the partner role |
The important detail: all five consume the same API. There is no API for mobile and another for web. That was a conscious decision, and it has a cost: the SPA receives some fields it never uses, and the mobile app has to ask for fields=id,name,priceEuros to slim down the response. The alternative —a backend for frontend per client— would have given each of them perfect responses in exchange for tripling the code, the tests and the contract. With five consumers and a small team, it did not pay off. With twenty consumers and three teams, the answer would have been different.
- Critical use cases and non-functional requirements
Four use cases account for 95 % of the traffic and all of the risk:
- Find a coffee —
GET /coffeeswith filters. It is 70 % of the requests. It has to be blazingly fast and it is entirely cacheable. - Buy — cart, order, payment. It is 5 % of the requests and 100 % of the revenue. It has to be correct even if it is slow.
- Track the shipment —
GET /orders/{id}and/shipment. It generates repeated polling: plenty of conditional caching and304s. - Moderate reviews — internal panel. Low volume, strict authorisation.
And the non-functional requirements we agreed with the business:
| Requirement | Target | Where it was solved |
|---|---|---|
| Availability | 99.9 % monthly (about 43 min of downtime) | Replicas, readiness, blue-green (05-05) |
| Read latency | p95 < 200 ms, p99 < 500 ms | HTTP cache + Redis (04-06), indexes (03-05) |
| Write latency | p95 < 400 ms | Short transactions, asynchronous webhooks |
| Campaign peak | 30× base traffic for 6 weeks | Catalogue caching, horizontal scaling, rate limiting (04-04) |
| Data protection | GDPR: access, rectification, erasure | Anonymisation, log minimisation (04-02) |
| Order correctness | Zero duplicated orders, zero overselling | Idempotency-Key + transaction + 409 |
Notice the deliberate asymmetry: reads are optimised for speed and writes for correctness. A catalogue that takes 400 ms is annoying; an order charged twice is a call to the bank, a refund and a lost customer.
- From the domain model to the resources
In 02-02 we saw the method: write down how the business describes its own work, underline the nouns and ask which of them have their own identity, state and lifecycle. Applied to Aroma Store:
| Business noun | Resource? | Reason |
|---|---|---|
| Coffee | Yes, collection /coffees |
Own identity, it is listed, filtered, linked |
| Customer | Yes, /customers |
Own identity and personal data |
| Order | Yes, /orders |
Identity, state and a long lifecycle |
| Review | Yes, /reviews |
Own identity; it is moderated independently |
| Cart | Yes, /carts |
It has state that survives between requests |
| Cart item | Yes, sub-resource /carts/{id}/items/{coffeeId} |
It is manipulated individually |
| Session | Yes, /sessions |
Login as the creation of a resource |
| Coffee image | Yes, /coffees/{id}/image |
A binary representation with its own caching |
| Checkout | No | It is a process, not a thing |
| Search | No | It is a GET /coffees with parameters |
| Discount | No (v1) | Solved as a computed field on the order |
Why the cart is a resource and the checkout is not
This is the distinction people find hardest, and the one that best separates someone who has understood REST from someone who translates functions into URLs.
The cart is a resource because it passes all three tests: it has identity (crt_77), it has state that persists between requests (the items you added are still there tomorrow) and it responds meaningfully to the HTTP methods. GET /carts/crt_77 returns something. DELETE /carts/crt_77 means emptying it. You can link to it. You can cache it (badly, because it changes, but the verb makes sense).
The checkout is not a resource because it is a process: the transition from a cart to an order. It has no state of its own to query. GET /checkout means nothing. And above all: the result of the process is a resource, and it already has a name. The process is expressed by creating that resource:
POST /v1/orders
Idempotency-Key: 6f1b2c9e-8a4d-4f7a-9c3e-2b5d7e1f0a44
{"cartId": "crt_77", "shippingAddressId": "adr_12"}The alternative would have been POST /checkout, which works but does not say what has been created, cannot return a coherent Location and cannot be versioned or linked cleanly. The rule we take away: if a process produces something with identity, expose what it produces, not the process. And if a process produces nothing new but changes the state of something that already exists —paying, shipping, cancelling— expose that transition as a sub-resource with POST, which is exactly what we did with /orders/{id}/payment.
- The complete URI map
This is the full v1 contract, as it ended up:
https://api.aromastore.example/v1
Catalogue
GET /coffees List (filters, sorting, offset)
POST /coffees Create [administrator]
GET /coffees/{id} Detail
PUT /coffees/{id} Replace (If-Match) [administrator]
PATCH /coffees/{id} Modify (If-Match) [administrator]
DELETE /coffees/{id} Withdraw [administrator]
GET /coffees/{id}/image Image (binary, long cache)
PUT /coffees/{id}/image Replace image [administrator]
GET /coffees/{id}/reviews Reviews of the coffee
POST /coffees/{id}/reviews Publish review [customer|partner]
Customers
GET /customers List [employee+]
POST /customers Sign up
GET /customers/{id} Detail [owner|employee+]
PATCH /customers/{id} Modify [owner|administrator]
DELETE /customers/{id} Close account (anonymises) [owner|administrator]
GET /customers/{id}/preferences Preferences
PUT /customers/{id}/preferences Replace preferences
GET /customers/{id}/orders Customer's orders (cursor)
Carts
POST /carts Create cart
GET /carts/{id} View cart
DELETE /carts/{id} Empty
PUT /carts/{id}/items/{coffeeId} Set quantity (idempotent)
DELETE /carts/{id}/items/{coffeeId} Remove item
Orders
GET /orders List (cursor)
POST /orders Create (Idempotency-Key)
GET /orders/{id} Detail (ETag, _links)
POST /orders/{id}/payment Pay
POST /orders/{id}/shipment Mark as shipped [employee+]
GET /orders/{id}/invoice Download invoice (PDF)
POST /orders/{id}/cancellation Cancel
POST /orders/{id}/return Return
Reviews
GET /reviews List (moderation) [employee+]
GET /reviews/{id} Detail
POST /reviews/{id}/approval Approve [reviews.moderate]
POST /reviews/{id}/rejection Reject [reviews.moderate]
POST /reviews/{id}/replies Reply [employee+]
Sessions and system
POST /sessions Login (returns a JWT)
DELETE /sessions/current Logout
GET /health/live Liveness
GET /health/ready Readiness
GET /metrics Prometheus [internal]
GET /docs Swagger UIThree regularities hold the whole map together, and a new consumer picks them up in five minutes:
- Always plural, with no exceptions.
/coffees, not/coffeeor/coffeeList. - No verbs in the path; the verb is the HTTP method. The only "actions" are transition nouns (
/payment,/cancellation). - A maximum of two levels of nesting, and the sub-resource always genuinely belongs to its parent.
- The design decisions and the alternatives that were rejected
This is the most valuable table in a technical write-up. Every row is a real fork in the project's road.
| # | Decision taken | Alternative rejected | Why |
|---|---|---|---|
| 1 | Actions as a sub-resource with POST (/orders/{id}/payment) |
Verbs in the path (/orders/{id}/pay) or PATCH with {"status":"paid"} |
The sub-resource allows its own body, its own response and different OAuth scopes. A status PATCH turns the state machine into an editable field, and then nothing stops anyone jumping from pending_payment to shipped |
| 2 | Nesting capped at two levels | /customers/{c}/orders/{o}/items/{i} |
With three levels, an item's URI stops being stable if the order changes customer, and you have to know the whole hierarchy in order to link to it |
| 3 | Prefixed ids (cof_001, ord_5001) |
Auto-incrementing integers or bare UUIDs | The prefix makes mistakes obvious in logs and support (coffee_not_found: ord_5001 gives itself away), it does not leak business volume and it lets you change the storage without changing the public format |
| 4 | A {"data": [...], "total": n} envelope on collections |
A bare array [...] |
It leaves room for future metadata without breaking the contract. (See the self-critique in section 11: the decision was right but incomplete) |
| 5 | Selective _links: only on orders, and depending on status |
Full HATEOAS on every resource, or none at all | Richardson level 2 with hypermedia where it adds something. On an order, knowing whether payment is available stops the client reimplementing the state machine. On a coffee, a self is all there is and nobody is going to use it |
| 6 | Money in cents on the inside, euros with two decimals on the outside | Floats everywhere, or cents in the JSON as well | 0.1 + 0.2 !== 0.3 ruins totals. But exposing 1450 forces every consumer to know the scale; exposing "14.50" is unambiguous and readable in the browser |
| 7 | Offset pagination on /coffees, cursor on /orders |
A single model for the whole API | Consistency misunderstood. The catalogue is small and stable and people want to jump to "page 3"; orders grow endlessly and are inserted at the front, where offsets produce duplicates and gaps (02-06) |
| 8 | Version in the path (/v1) |
An Accept header with a profile, or a ?version= parameter |
Visible in logs, in the browser, in per-route metrics and in the gateway configuration. The header is purer and far less operable |
| 9 | Our own errors {"error":{"code",...}} |
application/problem+json (RFC 9457) |
Chosen out of team familiarity. (See section 11: it was a mistake) |
| 10 | Reviews nested for writing (POST /coffees/{id}/reviews) and flat for moderating (GET /reviews) |
Only nested, or only flat | Writing always happens in the context of a coffee; moderating never does. (With caveats: section 11) |
| 11 | Idempotency-Key mandatory on POST /orders and /payment |
Trusting the client not to retry | Mobile networks retry on their own. A timeout does not tell you whether the server processed the request |
| 12 | An Aroma- prefix of our own on non-standard headers |
X- (obsolete since RFC 6648) |
X- is discouraged and collides; a brand prefix is unambiguous |
- The complete flow of a purchase
This is where the design is put to the test. We follow Marta García (cus_842) from the moment she searches for coffee until she downloads her invoice. Every request is real according to the v1 contract.
Step 1 — Search the catalogue
GET /v1/coffees?roast=light&priceMax=16.00&available=true&sort=-averageRating&limit=20 HTTP/1.1
Host: api.aromastore.example
Accept: application/json
Origin: https://aromastore.exampleHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "cat-9f2a17b4"
Vary: Accept, Accept-Encoding, Origin
Link: <https://api.aromastore.example/v1/coffees?roast=light&limit=20&offset=20>; rel="next"
Aroma-RateLimit-Remaining: 98
{
"data": [
{
"id": "cof_001",
"name": "Ethiopia Yirgacheffe",
"origin": "Ethiopia",
"roast": "light",
"priceEuros": "14.50",
"stock": 120,
"tastingNotes": ["jasmine", "bergamot", "peach"],
"version": 7,
"_links": { "self": { "href": "/v1/coffees/cof_001" } }
}
],
"total": 1
}No authentication: the catalogue is public. With an ETag, so the next visit gets a 150-byte 304 (04-06). With Vary: Origin because the response carries CORS headers and a shared cache must not mix them up (04-05).
Step 2 — Add to the cart
PUT /v1/carts/crt_77/items/cof_001 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
{"quantity": 2}HTTP/1.1 200 OK
Content-Type: application/json
{
"coffeeId": "cof_001",
"name": "Ethiopia Yirgacheffe",
"quantity": 2,
"unitPriceEuros": "14.50",
"subtotalEuros": "29.00"
}PUT and not POST. This is one of the most useful decisions in the whole contract and it deserves an explanation. With POST /carts/crt_77/items, if the user hits "add" twice they end up with two items for the same coffee, or with merge logic hidden away in the server. With PUT on the item's URI, the operation is idempotent (02-03): "the quantity of cof_001 in this cart is 2". Pressing it ten times leaves the same result. The cart interface, with its quantity selector, fits naturally: every change of the selector is a PUT.
And the sub-resource has a URI of its own, so removing a coffee is DELETE /v1/carts/crt_77/items/cof_001, with no body and no ambiguity.
Stock is not reserved here. That is deliberate: reserving in the cart forces you to expire reservations, complicates inventory and produces false "out of stock" during campaigns. Stock is checked and deducted when the order is created, inside a transaction.
Step 3 — Create the order
POST /v1/orders HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
Idempotency-Key: 6f1b2c9e-8a4d-4f7a-9c3e-2b5d7e1f0a44
{"cartId": "crt_77", "shippingAddressId": "adr_12"}HTTP/1.1 201 Created
Location: /v1/orders/ord_5001
ETag: "ord-5001-v1"
Content-Type: application/json
{
"id": "ord_5001",
"customerId": "cus_842",
"status": "pending_payment",
"items": [
{"coffeeId": "cof_001", "name": "Ethiopia Yirgacheffe", "quantity": 2,
"unitPriceEuros": "14.50", "subtotalEuros": "29.00"}
],
"totalEuros": "29.00",
"createdAt": "2026-08-15T09:14:22Z",
"version": 1,
"_links": {
"self": {"href": "/v1/orders/ord_5001"},
"payment": {"href": "/v1/orders/ord_5001/payment", "method": "POST"},
"cancellation": {"href": "/v1/orders/ord_5001/cancellation", "method": "POST"}
}
}Inside, everything happens in one transaction (03-05): the cart items are read, the stock levels are locked and checked, the unit price is frozen on each item, the total is computed in cents, the order is inserted, the stock is deducted and the cart is emptied. If anything fails, no trace is left behind.
And in _links you can see the selective hypermedia of decision 5: because the order is pending_payment, the client sees payment and cancellation. It will not see shipment or return, because they are not possible yet. The SPA does not need to know the state machine: it is enough for it to render the links it receives.
Step 4 — Pay
POST /v1/orders/ord_5001/payment HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Idempotency-Key: 3d8e5a11-77c0-4b2e-9a10-4c6f8b0e2d31
If-Match: "ord-5001-v1"
Content-Type: application/json
{"method": "card", "gatewayToken": "tok_dummy_9f3a"}HTTP/1.1 200 OK
ETag: "ord-5001-v2"
Content-Type: application/json
{
"id": "ord_5001",
"status": "paid",
"totalEuros": "29.00",
"version": 2,
"_links": {
"self": {"href": "/v1/orders/ord_5001"},
"invoice": {"href": "/v1/orders/ord_5001/invoice"},
"shipment": {"href": "/v1/orders/ord_5001/shipment"}
}
}Three mechanisms acting at once, and it is worth telling them apart because they get confused:
Idempotency-Keyprotects against the same client retrying: if the response was lost, repeating the request returns the stored response without charging again.If-Matchprotects against writing over a stale version: if another process has already changed the order, it responds412(04-06 and 03-05).- The state transition protects against the semantically impossible: paying twice with different keys responds
409 order_already_paid.
And notice how the _links change: payment and cancellation have gone, and invoice and shipment have appeared. The links are the state machine.
Step 5 — The webhook to SwiftShip
Payment fires an event. The API does not call SwiftShip inside the transaction; it queues the event and delivers it afterwards, because a slow carrier cannot hold up a payment.
POST /hooks/aroma HTTP/1.1
Host: api.swiftship.example
Content-Type: application/json
Aroma-Event-Id: evt_88213
Aroma-Signature: sha256=9c1f...4b7e
Aroma-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736
{
"event": "order.paid",
"createdAt": "2026-08-15T09:15:03Z",
"data": {
"orderId": "ord_5001",
"totalEuros": "29.00",
"recipient": {"name": "Marta García", "postcode": "46001"}
}
}The signature is an HMAC-SHA256 of the raw body with the shared secret. SwiftShip verifies it, responds 2xx and queues its own work. If it responds 5xx or does not respond at all, we retry with exponential backoff. Aroma-Event-Id lets SwiftShip discard duplicates: delivery is "at least once", so the receiver has to be idempotent.
Step 6 — Track the shipment and download the invoice
When SwiftShip collects the parcel, an employee (or their integration) marks the shipment:
POST /v1/orders/ord_5001/shipment HTTP/1.1
Authorization: Bearer <token with the shipments.write scope>
Content-Type: application/json
{"carrier": "SwiftShip", "tracking": "SS9928471ES"}Marta checks the status. Because she polls every minute, conditional caching does its job:
GET /v1/orders/ord_5001 HTTP/1.1
If-None-Match: "ord-5001-v3"
HTTP/1.1 304 Not Modified
ETag: "ord-5001-v3"
Cache-Control: private, no-cacheAnd the invoice, which is a resource with a different representation:
GET /v1/orders/ord_5001/invoice HTTP/1.1
Accept: application/pdf
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="invoice-ord_5001.pdf"
Cache-Control: private, max-age=31536000, immutableimmutable because an issued invoice never changes. It is one of the few places in the whole API where that directive is fully justified.
The flow as a diagram
sequenceDiagram participant S as SPA participant G as Kong gateway participant A as Aroma API participant D as Database participant R as SwiftShip S->>G: GET /v1/coffees?roast=light G->>A: forwards A-->>S: 200 + ETag + Cache-Control S->>A: PUT /carts/crt_77/items/cof_001 A-->>S: 200 item set S->>A: POST /orders (Idempotency-Key) A->>D: TX: check stock, freeze price, insert D-->>A: ok A-->>S: 201 Created + Location + _links(payment) S->>A: POST /orders/ord_5001/payment (If-Match) A->>D: TX: status=paid, version=2 A-->>S: 200 + _links(invoice, shipment) A->>R: POST webhook order.paid (Aroma-Signature) R-->>A: 202 accepted R->>A: POST /orders/ord_5001/shipment A-->>R: 200 status=shipped S->>A: GET /orders/ord_5001 (If-None-Match) A-->>S: 304 Not Modified
- Hard cases and how they were solved
A design is judged by what it does when things go wrong. These six cases are the ones that really cost meetings.
8.1 Insufficient stock under concurrency
The problem. There are 2 units of cof_001 left and two customers create an order for 2 at exactly the same moment. If we check the stock and then deduct it in two separate steps, both read 2, both see enough and both sell. Overselling.
The solution. Everything inside one transaction, and the deduction with the condition built into the statement itself:
BEGIN IMMEDIATE;
UPDATE coffees
SET stock = stock - :quantity
WHERE id = :coffeeId
AND stock >= :quantity;
-- if changes() = 0 there was no stock: abort
INSERT INTO orders (...) VALUES (...);
COMMIT;The check and the write are the same atomic operation. If changes() returns 0, there was no stock and the error is thrown:
// src/services/orders.js (extract)
const result = coffeeRepository.deductStock(coffeeId, quantity);
if (result.changes === 0) {
throw new ApiError(409, 'insufficient_stock',
'There are not enough units of the requested coffee', [
{ field: 'items[0].quantity', coffeeId, requested: quantity }
]);
}Why 409 and not 400. A 400 says "your request is badly written"; resending it unchanged will always fail. A 409 says "your request is valid but it clashes with the current state"; tomorrow, with the stock replenished, the same request will work. The difference matters to the client, which in the second case can offer "let me know when it is back".
8.2 Double payment
The problem. Marta's phone loses signal just after sending POST /payment. The server takes the money; the response never arrives. The app retries. Is she charged twice?
The solution, in two layers.
The first is the Idempotency-Key. Before processing, we try to insert the key into a table with a unique constraint, together with a fingerprint of the body:
// src/middleware/idempotency.js (extract)
const record = idempotencyRepository.find(key);
if (record) {
if (record.fingerprint !== fingerprintOf(req.body)) {
throw new ApiError(422, 'idempotency_key_reused',
'That idempotency key was already used with a different body');
}
if (record.status === 'in_progress') {
throw new ApiError(409, 'operation_in_progress',
'The operation with this key is still being processed');
}
return res.status(record.statusCode).set(record.headers).json(record.response);
}The retry gets back the same stored response, with the same 201/200 and the same body. Nobody is charged twice.
The second layer is the state machine: if a payment arrives with a new key against an order that is already paid, it responds 409 order_already_paid. Idempotency covers the retry; state covers the genuine mistake.
8.3 The price changes after the order has been created
The problem. Marta creates the order at 9:14 with cof_001 at €14.50. At 9:20, an administrator raises the price to €15.90. Marta pays at 9:25. How much does she pay?
The solution: the price is frozen on the item. Every order item stores its own unitPriceCents, copied from the catalogue at the moment of creation. The order does not look up the coffee's price when it is displayed or when it is paid.
CREATE TABLE order_items (
order_id TEXT NOT NULL,
coffee_id TEXT NOT NULL,
coffee_name TEXT NOT NULL, -- copied, not referenced
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL, -- frozen
PRIMARY KEY (order_id, coffee_id)
);Notice that the name is copied too. That is not careless redundancy: if the coffee is renamed or withdrawn from the catalogue, Marta's invoice must still say what she bought. An order is a historical document, not a view of current data, and that distinction changes how the table is modelled.
The consequence you have to accept: an old cart may display out-of-date prices. It was solved by recalculating the cart prices on every GET /carts/{id} (the cart is a current view) and warning in the interface if anything has changed since the last visit.
8.4 A review from someone who did not buy the coffee
The problem. Can cus_842 review cof_002 if she has never bought it?
The business decision was: yes, but with a visible distinction. Reviewing is deliberately a low barrier, because review volume matters commercially. But a review from a verified buyer is worth more.
The technical solution. When the review is created, the service checks whether there is any paid order from that customer containing that coffee, and stamps the result onto the resource:
// src/services/reviews.js (extract)
const verifiedPurchase = orderRepository.customerBoughtCoffee(customerId, coffeeId);
return reviewRepository.create({
coffeeId, customerId, rating, comment,
verifiedPurchase, // an immutable stamp
status: 'pending_moderation' // every review is moderated
});{
"id": "rev_101",
"coffeeId": "cof_001",
"customerId": "cus_842",
"rating": 5,
"comment": "Floral and clean, highly recommended.",
"verifiedPurchase": true,
"status": "pending_moderation",
"createdAt": "2026-08-15T10:02:00Z"
}And one added rule: one review per customer and coffee, with a unique constraint in the database. A second attempt responds 409. That rule lives in the index, not only in the service, because with two API instances running in parallel a check in code is not enough.
8.5 Deleting a customer who has orders
The problem. Marta exercises her right to erasure (GDPR, article 17). But her orders are accounting documents that commercial law obliges us to keep for several years. Two legal obligations pointing in opposite directions.
The solution: anonymise, do not delete. DELETE /customers/cus_842 does not run a DELETE on the table. It replaces the personal data with neutral values, keeps the record with its identifier and deactivates the account:
UPDATE customers
SET name = 'Deleted customer',
email = '[email protected]',
phone = NULL,
addresses = NULL,
anonymised_at = :now,
active = 0
WHERE id = :customerId;
-- Orders keep their customer_id: the amount and the date remain auditable,
-- but there is no longer any way to know who it was.The response is 204 No Content. From then on, GET /customers/cus_842 responds 404 customer_not_found to anyone other than an internal audit, and order ord_5001 still exists with its amount, its date and its VAT, but with no person behind it.
An important warning. What you have just read is a plausible technical solution, not legal advice. What may be kept, for how long, on what legal basis and what counts as effective anonymisation —as opposed to mere pseudonymisation, which is still personal data— depends on the jurisdiction, the sector and the specific case. In a real project, this design is validated with the data protection officer and with legal counsel before the first line of code is written. You also have to decide what happens to backups, to logs and to the third-party systems the data was sent to (here, SwiftShip), and an
UPDATErarely settles that.
8.6 A webhook SwiftShip never acknowledged
The problem. We send order.paid and no response comes back. Did they receive it? Did they process it? There is no way to know from the outside.
The solution: a persistent queue with retries and deactivation. The event is stored in a table with its status, and a process retries it with exponential backoff and jitter: 1 min, 2, 4, 8, 16, 32, 64 min. After seven failed attempts it moves to permanently_failed, an alert fires and the event remains available for manual resending from the panel.
// src/services/webhooks.js (extract)
const BACKOFF_MINUTES = [1, 2, 4, 8, 16, 32, 64];
function nextAttemptAt(attemptNumber) {
const base = BACKOFF_MINUTES[attemptNumber] ?? 64;
const jitter = Math.random() * base * 0.2; // avoids synchronised storms
return new Date(Date.now() + (base + jitter) * 60_000);
}Three details that make this work in production:
Aroma-Event-Idstays stable across retries. The same event is retried with the same identifier, so the receiver can discard duplicates. If it changed, every retry would look like a new event and SwiftShip would create seven shipments.- Delivery is "at least once", never "exactly once". The latter is impossible to guarantee over an unreliable network, and promising it in the documentation is deceptive. What is documented is: we retry; be idempotent.
- A circuit breaker. If SwiftShip has failed 50 times in a row, we stop trying for a few minutes instead of punishing a service that is already down.
- The deployed architecture
graph TB SPA[Web SPA] --> CDN[CDN] MOB[Aroma Mobile] --> GW PAN[Internal panel] --> GW CB[CataBox OAuth] --> GW CDN --> GW[Kong gateway<br/>TLS, rate limit, CORS, JWT] GW --> API1[Aroma API 1] GW --> API2[Aroma API 2] GW --> API3[Aroma API 3] API1 --> RED[(Redis<br/>cache + rate limit + idempotency)] API2 --> RED API3 --> RED API1 --> DB[(Database<br/>primary + replica)] API2 --> DB API3 --> DB API2 --> INV[Inventory service<br/>internal gRPC] API2 -.signed webhook.-> SS[SwiftShip] PAN -.SSE.-> API3 API1 --> OBS[Observability<br/>Prometheus + traces + logs]
Every piece is where it is for a concrete reason:
| Piece | What it solves | What would happen without it |
|---|---|---|
| CDN | Serves coffee images and cached public responses | The Christmas peak would hit the API in full |
| Kong gateway | TLS, global rate limiting, CORS, JWT validation, per-consumer quotas | Every instance would repeat those rules and they would diverge |
| 3 stateless instances | Horizontal scaling and blue-green deployment | You could not deploy without downtime or absorb peaks |
| Redis | Cache-aside caching, rate limit counters, idempotency keys | Limits would be per instance (and therefore 3× the real one) and idempotency would not cross instances |
| Primary + replica | Writes to the primary, heavy reads to the replica | The catalogue would compete with orders for the same database |
| Inventory over gRPC | Stock queries against the physical warehouse, a typed contract, low latency | Internal REST with more latency and no shared types (01-07) |
| Observability | Golden signals, traces correlated by Aroma-Trace-Id |
Diagnosing blind (04-07) |
Why idempotency lives in Redis and not in memory. With three instances behind the gateway, the retry from Marta's phone may land on a different instance from the original. An in-memory cache would not see it and would charge again. It is the same reasoning that took rate limiting to Redis in 04-04: any state shared between requests has to live outside the process, or horizontal scaling breaks it silently.
- Service metrics and SLOs
The SLOs derive from the requirements in section 3, and each has its measurable indicator (04-07):
| SLO | Target | Indicator | Monthly error budget |
|---|---|---|---|
| Read availability | 99.95 % | % of GETs without a 5xx |
~22 min |
| Purchase availability | 99.9 % | % of POST /orders and /payment without a 5xx |
~43 min |
| Catalogue latency | p95 < 200 ms | Histogram per route | — |
| Purchase latency | p95 < 400 ms | Histogram per route | — |
| Webhook delivery | 99 % in < 5 min | % of acknowledged events |
— |
| Order correctness | 0 duplicates | Counter of idempotency collisions | 0 |
And the alerts that genuinely wake somebody up at night —deliberately few, because an alert that fires without consequence trains the team to ignore it—:
# extract from the alerting rules
- alert: High5xxErrorRate
expr: sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) > 0.01
for: 5m
severity: critical
- alert: PurchasesFailing
expr: sum(rate(http_requests_total{route="/v1/orders",method="POST",status=~"5.."}[5m])) > 0
for: 2m
severity: critical # any purchase failure is money lost
- alert: WebhooksStuck
expr: aroma_webhooks_pending > 100
for: 10m
severity: warningPurchasesFailing fires on a single error, whereas the general rate tolerates 1 %. That asymmetry is intentional and mirrors the one in section 3: not every endpoint is worth the same.
- What we would do differently
No honest technical write-up ends without this section. Four decisions that, with the project already in production, we would not repeat.
11.1 The data/total envelope: right but incomplete
What we did. {"data": [...], "total": 42}.
What went wrong. The envelope was the right call —it left room for metadata— but it stopped halfway. The pagination metadata ended up split between the Link header and the total field, and every consumer had to learn a different place for each thing. Worse: total forces a COUNT(*) on every listing, which on /orders with broad filters is the slowest query in the whole API. And in a cursor-paginated collection, an exact total is conceptually dubious as well.
What we would do. An explicit meta object, with total optional and on demand:
The cost of fixing it now. It is a breaking change for all five consumers. It goes on the v2 list, and that is exactly the kind of contract debt covered in 06-03.
11.2 Not having used application/problem+json
What we did. A format of our own: {"error": {"code", "message", "details"}}.
What went wrong. It is a reasonable, coherent, well-documented format. But it is ours. There is a standard, RFC 9457, with type, title, status, detail and instance, which client libraries, gateways and monitoring tools already understand. When we integrated CataBox we had to explain our format from scratch and write an adapter; with problem+json it would have been one line of configuration. And the Content-Type would have been self-descriptive, which is precisely the REST constraint we most like to quote (01-04).
What we would do. problem+json with our own extensions, which the standard allows:
{
"type": "https://api.aromastore.example/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "There is 1 unit of cof_001 left and 2 were requested",
"instance": "/v1/orders",
"code": "insufficient_stock",
"details": [{"coffeeId": "cof_001", "available": 1, "requested": 2}]
}The general lesson: before inventing a format, check whether a standard one already exists. Yours being coherent does not make up for the whole world speaking a different language.
11.3 Nested reviews: two paths to the same thing
What we did. POST /coffees/{id}/reviews to create, GET /reviews to moderate, GET /coffees/{id}/reviews to list a coffee's reviews.
What went wrong. We ended up with two routes for the same resource, and you pay for that in unexpected places: two entries in the OpenAPI that have to be kept in sync, two routes in the per-route metrics —which makes it hard to answer "how many reviews are created per day?"—, two rate limiting rules, two routes in the gateway, two sets of tests. When POST /reviews/{id}/replies arrived, the asymmetry became obvious: replies hang off the flat review, not off the coffee. And some client started using GET /reviews?coffeeId=cof_001, which returns the same thing as GET /coffees/cof_001/reviews but with a different way of paginating.
What we would do. A flat /reviews collection as the single source, with POST /reviews carrying coffeeId in the body, and GET /coffees/{id}/reviews kept only as a read shortcut documented as such —or removed, replaced by GET /reviews?coffeeId=cof_001.
The honest caveat: there is a solid argument in favour of what we did, namely that POST /coffees/{id}/reviews makes it impossible to create a review without a coffee and keeps coffeeId out of the body, where it cannot be forged. It is not an obviously bad decision; it is a decision whose cost we did not weigh up properly beforehand.
11.4 Not reserving stock in the cart
What we did. Stock is checked and deducted only when the order is created.
What went wrong. During the Christmas peak, with small batches, 409 insufficient_stock responses increased at exactly the final step of the purchase. From the user's point of view, that is the worst possible experience: you have made it all the way to payment and only then are you told there is none left.
What we would do. Keep the underlying decision —reserving in the cart brings more problems than it solves— but warn earlier: show the remaining stock in the cart, warn when few units are left and check availability when the checkout process opens, not only when it is confirmed. The 409 error would still exist, but it would stop being the first anyone hears of it.
The general lesson: some API design problems are better solved with early information than with more transactional machinery.
Common Mistakes and Tips
Confusing "action" with "resource" at the first obstacle. As soon as an operation appears that does not fit CRUD, the temptation is POST /orders/{id}/processPaymentAndNotify. Ask yourself what thing it produces or what state it changes. There is almost always a noun behind it: a payment, a cancellation, a return.
Consistency misunderstood. Using the same pagination model across the whole API sounds like good practice, but at Aroma Store it would have been a mistake: the catalogue and the orders have opposite dynamics. The consistency that matters is consistency of criteria, not of mechanisms: "small, stable collections by offset; large collections that grow at the front by cursor" is a coherent rule that produces two different mechanisms.
Storing money in floats. 0.1 + 0.2 gives 0.30000000000000004. On one line it does not show; on a campaign total, it does. Integer cents on the inside, a string with two decimals on the outside.
An order that queries the catalogue to display prices. It is the most expensive modelling mistake in this domain. An order is frozen history; the catalogue is the present. Copy what you need, even if it looks redundant.
Sending webhooks inside the transaction. If the call to SwiftShip happens before the COMMIT, a later failure leaves an order notified that does not exist. And if it happens inside, a slow carrier stretches the transaction and blocks the database. Commit first, queue afterwards.
Promising "exactly once" in your webhook documentation. It is not achievable over an unreliable network. Document "at least once" and require idempotency from the receiver. It is more honest and it avoids broken integrations.
Tip: write the decision table while you design, not afterwards. The column that matters is not "what we did", which can be inferred from the code, but "what we rejected and why". A year from now, when somebody proposes changing the pagination model, that column saves you from having the whole discussion again.
Tip: walk the complete flow before writing any code. Writing out the ten requests and responses of a purchase in a text file, chained together, reveals gaps no resource diagram shows: missing headers, ids nobody returned, impossible states.
Exercises
Exercise 1 — A new resource: the subscription
Aroma Store wants to launch subscriptions: the customer receives 500 g of a coffee every month, can pause it, resume it, change the coffee or cancel it, and every month an order is generated automatically.
- Decide whether "subscription" is a resource and justify it with the three tests from section 4.
- Design the complete URI map, including pausing, resuming, cancelling and querying the generated orders.
- State which
_linksyou would return for a subscription in theactivestatus and in thepausedstatus. - Decide the pagination model for
/subscriptionsand justify it.
Exercise 2 — Reproduce and fix the overselling
Write an integration test that demonstrates the overselling when the stock check and the deduction happen in two separate steps, and then verify that the solution from section 8.1 prevents it. Check the error code and body as well.
Exercise 3 — A decision audit
Take the table from section 6 and, for each of these three rows, argue the opposite case convincingly: (a) selective _links, (b) the version in the path, (c) prefixed ids. Then decide whether you would keep the original decision and why. The goal is to distinguish well-founded decisions from those that survive on habit alone.
Solutions
Solution 1
1. Is it a resource? Yes, without a doubt. Identity: sub_301. Persistent state: active, paused, cancelled, plus the date of the next delivery. Lifecycle: long, with well-defined transitions. It passes all three tests more clearly than the cart does.
2. URI map:
GET /v1/subscriptions List [owner|employee+]
POST /v1/subscriptions Create (Idempotency-Key)
GET /v1/subscriptions/{id} Detail (ETag)
PATCH /v1/subscriptions/{id} Change coffee/quantity (If-Match)
DELETE /v1/subscriptions/{id} Cancel (or POST .../cancellation)
POST /v1/subscriptions/{id}/pause Pause
POST /v1/subscriptions/{id}/resumption Resume
GET /v1/subscriptions/{id}/orders Generated orders (cursor)
GET /v1/customers/{id}/subscriptions Read shortcutPausing and resuming are sub-resources with POST, just like /payment: they are state transitions, not field edits. A PATCH {"status":"paused"} would allow jumping to any status with no control.
Changing the coffee, on the other hand, is a PATCH, because it edits an attribute rather than moving the lifecycle along. The distinction is exactly the one in decision 1 of section 6.
3. Links by status:
// active
"_links": {
"self": {"href": "/v1/subscriptions/sub_301"},
"pause": {"href": "/v1/subscriptions/sub_301/pause", "method": "POST"},
"cancellation": {"href": "/v1/subscriptions/sub_301/cancellation", "method": "POST"},
"orders": {"href": "/v1/subscriptions/sub_301/orders"}
}
// paused
"_links": {
"self": {"href": "/v1/subscriptions/sub_301"},
"resumption": {"href": "/v1/subscriptions/sub_301/resumption", "method": "POST"},
"cancellation": {"href": "/v1/subscriptions/sub_301/cancellation", "method": "POST"},
"orders": {"href": "/v1/subscriptions/sub_301/orders"}
}A cancelled subscription would have only self and orders: it is terminal.
4. Pagination: offset. A customer has one, two or three subscriptions; not even the most enthusiastic will reach twenty. The collection is tiny, stable and consulted in full. A cursor would add complexity without solving any problem. /subscriptions/{id}/orders is a different matter: it grows every month for years and inherits the cursor from /orders.
Solution 2
// tests/integration/stock-concurrency.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from '../../src/app.js';
import { prepareTestDatabase } from '../helpers/test-database.js';
test('two simultaneous orders cannot sell more stock than exists', async (t) => {
const db = prepareTestDatabase();
db.prepare('UPDATE coffees SET stock = 2 WHERE id = ?').run('cof_001');
const app = createApp({ db });
const body = { cartId: 'crt_77', shippingAddressId: 'adr_12' };
const [a, b] = await Promise.all([
request(app).post('/v1/orders')
.set('Authorization', `Bearer ${t.tokenFor('cus_842')}`)
.set('Idempotency-Key', 'key-a')
.send(body),
request(app).post('/v1/orders')
.set('Authorization', `Bearer ${t.tokenFor('cus_001')}`)
.set('Idempotency-Key', 'key-b')
.send(body)
]);
const codes = [a.status, b.status].sort();
assert.deepEqual(codes, [201, 409], 'one must be created and the other must clash');
const failed = a.status === 409 ? a : b;
assert.equal(failed.body.error.code, 'insufficient_stock');
assert.ok(Array.isArray(failed.body.error.details));
assert.equal(failed.body.error.traceId, undefined, 'traceId only on 5xx');
const { stock } = db.prepare('SELECT stock FROM coffees WHERE id = ?').get('cof_001');
assert.equal(stock, 0, 'stock can never go negative');
});How to demonstrate the failure first. Temporarily replace the UPDATE ... WHERE stock >= :quantity with two steps —a SELECT stock and then an UPDATE stock = :newValue— outside any transaction. With the naive version you will see two 201s and stock = -2. Restore the correct version and the test passes. This test is valuable precisely because it fails with the naive implementation: a test that passes with broken code proves nothing.
Note: with SQLite and better-sqlite3 writes are serialised, which already helps; with PostgreSQL or MySQL, it is the conditional UPDATE ... WHERE inside the transaction that guarantees atomicity. Putting the condition in the statement itself is the portable part.
Solution 3
(a) The case against selective _links. "Selectivity forces the server to compute the links on every response, which is more code and more tests, and it produces an inconsistent API: some resources come with links and others do not, which confuses anyone discovering it. Besides, a client that wants to know whether an order is cancellable has to fetch the whole thing. Either full HATEOAS, or none at all and let the client know the state."
Verdict: keep it. The inconsistency argument is real, but it is solved by documenting it, not by removing the value. Order links stop five consumers reimplementing —and desynchronising— the same state machine. On coffees there was nothing to prevent.
(b) The case against the version in the path. "/v1/coffees/cof_001 and /v2/coffees/cof_001 are different URIs for the same resource, which contradicts the resource identification of 01-04. The version is a detail of the representation and its natural home is Accept. With the version in the path, every client has hardcoded links and HATEOAS becomes impossible to version cleanly."
Verdict: keep it, with the cost acknowledged. The argument is theoretically correct and we accept it. In exchange: the version appears in logs, metrics and gateway configuration, it can be tested from the browser, it is routed with no application logic and it answers "who is still on v1?" with a trivial query. In 06-03 you will see how much that is worth when migrating.
(c) The case against prefixed ids. "The prefix mixes the type with the identifier, it becomes a lie when a resource changes type or is merged with another, it takes up bytes in every response and it tempts clients into parsing the id to infer the type, creating a coupling to an internal detail." Verdict: keep it. The parsing risk is real and it is mitigated by documenting explicitly that the id is opaque. In exchange, every error message, every log line and every support ticket can be read without querying the database, and that pays off every single day.
- Final project checklist
A list you can apply to any API you design, derived from everything we have been through:
Design
- [ ] The consumers are identified, each with its concrete need.
- [ ] The resources come from the domain, not from the tables.
- [ ] Plural URIs, no verbs, nesting capped at two levels.
- [ ] Every HTTP method respects its semantics:
GETsafe,PUT/DELETEidempotent. - [ ] The status codes distinguish
400,409,412,422and429with judgement. - [ ] A closed error catalogue, documented and with stable codes.
- [ ] A pagination model chosen per domain and justified in writing.
- [ ] A versioning strategy and a deprecation policy published.
Implementation
- [ ] All input validated at the edge, with schemas.
- [ ] Transactions wherever there are invariants; conditions inside the statement.
- [ ] Optimistic concurrency with
versionandETag/If-Matchwhere it matters. - [ ] Idempotency on every operation with money or third-party effects.
- [ ] Errors unified through a single handler.
- [ ] Money in integers; dates in ISO-8601 UTC.
- [ ] Historical data copied, not referenced.
Security
- [ ] Authentication and authorisation by role and by ownership of the resource.
- [ ] CORS with an explicit allowlist; never
*with credentials. - [ ] Rate limiting with shared state, and
429withRetry-After. - [ ] Secrets outside the code; security headers with helmet.
- [ ] Personal data minimised in logs, and deletion with legally validated criteria.
Operation
- [ ] Structured logs with a propagated trace identifier.
- [ ] Golden signal metrics and SLOs agreed with the business.
- [ ]
livenessandreadinesskept distinct. - [ ] Few, actionable alerts, prioritised by business impact.
- [ ] HTTP caching calibrated resource by resource.
Contract and delivery
- [ ] A complete OpenAPI, validated with Spectral and published.
- [ ] Unit, integration, contract and e2e tests in CI.
- [ ] Automatic detection of breaking changes.
- [ ] Zero-downtime deployment with backwards-compatible migrations and rollback.
- [ ] Important decisions recorded as ADRs, with their rejected alternative.
Conclusion
You have walked through the complete Aroma Store API, from the requirements to the deployment, and above all you have seen why it is the way it is. The cart is a resource and the checkout is not, because one has state and the other is a process. Money goes in cents on the inside and euros on the outside, because arithmetic and readability ask for different things. Coffees are paginated by offset and orders by cursor, because they are collections with opposite dynamics and forcing a single mechanism would have been consistency misunderstood. The Idempotency-Key sits on the payment because mobile networks retry by themselves and a timeout does not tell you whether the server took the money.
You have also seen what is rarely taught: the hard cases solved with names attached —overselling closed off with a condition inside the UPDATE, the double payment closed off in two layers, the price frozen on the item because an order is history and not a view, the customer anonymisation with the warning that such a design is signed off by a lawyer and not by a programmer, and the webhooks delivered "at least once" because promising more would be a lie. And an honest self-critique: the envelope that stopped halfway, the problem+json we should have used, the reviews with two paths and the stock that warned people too late. That is the material useful technical write-ups are made of: not the list of things that went right, but the list of forks in the road with their price tags.
The risk with a lesson like this is drawing the wrong conclusion: believing you now have the template for a REST API and that all you have to do is swap coffees for whatever it is. It is not so. Every decision in this lesson is right for this domain: a small, stable catalogue, finite transactional stock, few writes of very high value, five known consumers and strong consistency as a non-negotiable requirement. Change the domain and several of those decisions stop holding up.
That is exactly what the next lesson does. In 06-02, Case Study: A Social Network API, we design CafeSocial, the tasters' network Aroma Store wants to launch, and several of today's certainties fall one by one: the follower graph forces us to model the relationship as a resource; the timeline is not an ordinary collection but a derived resource with the fan-out problem behind it; offset pagination stops working outright —duplicates and gaps— and the opaque cursor goes from an option to an obligation; "like" counters become deliberately approximate; images no longer get uploaded through the API; authorisation stops being "who owns this" and becomes "who is asking and what do we let them see", with 404s where the store answered 403; and real time stops being an extra for the panel and becomes the product. We will finish with the table that makes sense of the two cases together, decision by decision, and with the reason why GraphQL is a far more defensible alternative in that domain than it was here.
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
