We closed 06-03 by saying that the quality of an API is not measured on launch day, but by how easily an integrator can keep working with it when nobody from the original team is still at the company. That is the bar. Over six modules we have read, analysed and criticised other people's work —including our own, in the technical write-up of 06-01 and in the three production years of 06-03. Now it is your turn: you are going to design and build a complete API from scratch, in phases, with verifiable acceptance criteria and a rubric you can use to assess yourself with nobody marking your work. Everything we have done up to here has been preparing you for this brief.
This is not an exercise in writing Express routes. It is an exercise in deciding: what counts as a resource, what your contract guarantees, what happens when two people compete for the last seat, and what you will tell whoever depends on you two years from now.
Contents
- The brief: the Aroma Academy API
- Alternative domains
- Delivery in phases and acceptance criteria
- Self-assessment rubric
- Getting started: day one
- A week-by-week work plan
- Hints for the points where almost everybody gets stuck
- Reference solution for Phase 2
- Common mistakes and tips
- Exercises
- Course conclusion
- The brief: the Aroma Academy API
Aroma Store wants to open a new line of business: Aroma Academy, a platform for speciality coffee courses and in-person tastings. You design and build its public API from scratch, with the same stack and the same contract conventions we have used throughout the course (prefixed ids, camelCase, money in cents on the inside and euros on the outside, ISO-8601 UTC dates, collections wrapped in {"data": [...], "total": n}, errors with a code catalogue, versioning in the path).
1.1 The domain
| Concept | Description |
|---|---|
| Course | A repeatable training product: "Sensory tasting of African origins", "Latte art level 1". It has a title, description, duration, level and base price. |
| Session | A concrete run of a course: date and time, venue, capacity, assigned instructor. A course has many sessions. |
| Enrolment | A person takes a seat on a session. It has a status, a price paid and a date. |
| Waiting list | People who wanted to enrol when the session was full and are waiting for a free seat. |
| Attendee | An identified person (an account with an email address and a password) who enrols. They may or may not be a customer of the store. |
| Rating | A score from 1 to 5 and a comment that an attendee leaves about a session they attended. |
| Instructor | Whoever teaches sessions. They have a biography, specialities and a derived average rating. |
1.2 Business rules
These rules are not decoration: they are the reason the project is interesting. Each one forces a design decision.
- Capacity. Every session has a maximum number of seats. There can never be more confirmed enrolments than seats, not even with simultaneous requests.
- Enrolment cut-off. No new enrolments are accepted from
Xhours before the start (configurable per course; 24 by default). - Waiting list. If the session is full, the person can join the waiting list. When somebody cancels, the first person on the list gets a time window in which to confirm.
- Cancellation and refund. Cancelling more than 72 hours in advance refunds 100 %; between 72 and 24 hours, 50 %; with less than 24 hours, nothing. A cancellation by Aroma Academy always refunds 100 %.
- Conditional rating. An attendee can only rate a session if their enrolment is marked as attended, and only once.
- Discounts. Aroma Store customers with purchases in the last 12 months get a 15 % discount; there are promotional codes with a fixed amount or a percentage; discounts do not stack unless explicitly stated.
1.3 The four real challenges
- Limited seats and concurrency. Two requests at once for the last seat. The check "is there room?" and the reservation have to be atomic. You already saw the pattern in
06-01: the condition travels inside theUPDATE, not in a precedingSELECT. - Cancellations and waiting lists. Cancelling triggers a chain of effects (free the seat, promote from the list, notify, compute the refund). Is that a
DELETE? Is it a resource? - Dates and time zones. Sessions are in person and happen at a specific local time. The contract says UTC, but "Saturday's sessions" depends on the time zone of whoever is asking. Daylight saving changes exist and they will bite you.
- Prices and discounts. Cents on the inside, euros on the outside (
02-05). The final price depends on who is asking, when, and with what code. Is it computed in the course representation or only at enrolment time?
- Alternative domains
If Aroma Academy does not appeal to you, pick another. The bar is the same: it must have at least one resource with a concurrency constraint, a state-change flow and a non-trivial relationship.
| Domain | What challenge it adds |
|---|---|
| Library management | Copies versus works: the same book has several physical copies, and a loan is made against a copy, not against a title. Modelling that difference without leaking it to the consumer is the exercise. |
| Support helpdesk | The state machine is the heart of the domain (open, assigned, waiting on the customer, resolved, closed) with restricted transitions, and authorisation depends on both role and ownership. |
| Room booking | Overlapping in time: the constraint is not a counter, it is an interval that cannot cross another, and recurring bookings multiply the problem. |
- Delivery in phases and acceptance criteria
One phase per module. Do not move on to the next one without meeting the criteria: half the value of the project lies in resisting the temptation to write code in phase 1.
Phase 1 — Analysis (module 1)
Deliverable: docs/analysis.md.
| # | Acceptance criterion |
|---|---|
| 1.1 | A list of the consumers identified (public web, admin panel, instructors' app, integration with the store) with what each one needs. |
| 1.2 | At least 12 use cases written as "as a role, I want action so that outcome". |
| 1.3 | Non-functional requirements quantified: target latency, expected volume, availability, data retention. |
| 1.4 | A domain glossary with 15+ terms and their exact name in the contract (in English, consistent). |
| 1.5 | A justification of why REST and not another option, referring to 01-07. |
| 1.6 | The target level in the Richardson model (01-05) declared and reasoned. |
Phase 2 — Contract (module 2)
Deliverable: openapi.yaml + docs/contract.md.
| # | Acceptance criterion |
|---|---|
| 2.1 | A complete resource and URI map, with plural nouns and no verbs (02-02). |
| 2.2 | A method × resource table with the success status code and the error codes for every combination (02-03, 02-04). |
| 2.3 | Representation schemas for every resource, with types, whether they are required, and an example. |
| 2.4 | Your own error catalogue, with a snake_case code, an HTTP status and a message. |
| 2.5 | Pagination and filters defined per collection, stating which uses limit/offset and which uses cursor (02-06). |
| 2.6 | A written versioning strategy, with what counts as a compatible change (02-07). |
| 2.7 | An openapi.yaml in OpenAPI 3.1 that passes spectral lint with no errors (05-02). |
| 2.8 | At least one request and response example per operation. |
Phase 3 — Implementation (module 3)
Deliverable: code in src/, migrations/, tests/.
| # | Acceptance criterion |
|---|---|
| 3.1 | The layered structure respected: routes do not touch the database and repositories know nothing about HTTP (03-05). |
| 3.2 | Validation with Zod of the body, path parameters and query string, with errors translated into the catalogue format (03-04). |
| 3.3 | Migrations versioned and reproducible from scratch with a single command. |
| 3.4 | The seat reservation happens inside a transaction and is correct under concurrency (with a test that proves it). |
| 3.5 | JWT + bcrypt authentication and authorisation by role and by ownership (03-06). |
| 3.6 | Unified error handling: no 500 unlogged and no stack trace leaked to the client (03-07). |
| 3.7 | Tests with node:test + Supertest, coverage ≥ 70 % in the services, with success and error cases (03-08). |
Phase 4 — Hardening (module 4)
Deliverable: middleware, configuration and docs/security.md.
| # | Acceptance criterion |
|---|---|
| 4.1 | helmet active and the headers reviewed one by one, not a blind default (04-02). |
| 4.2 | Rate limiting with different limits for writes and reads, and 429 with Retry-After (04-04). |
| 4.3 | CORS with an explicit allowlist of origins, not * in production (04-05). |
| 4.4 | ETag + If-None-Match on at least two frequently read collections, with the 304 verified in a test (04-06). |
| 4.5 | Structured logs with pino, a correlation id per request and no personal data in the clear (04-07). |
| 4.6 | Metrics with prom-client: a request counter, a latency histogram and at least one business metric (seats taken, for example). |
Phase 5 — Tooling (module 5)
Deliverable: postman/, Dockerfile, .github/workflows/ci.yml.
| # | Acceptance criterion |
|---|---|
| 5.1 | A Postman collection that walks the complete flow (sign-up → enrolment → cancellation → rating) and passes with newman run (05-01). |
| 5.2 | Swagger UI served from the API itself at /v1/docs, fed by openapi.yaml. |
| 5.3 | Contract validation in the tests: at least one test checks that the real response matches the declared schema (05-04). |
| 5.4 | A multi-stage Dockerfile that starts the API with a single docker run. |
| 5.5 | CI on GitHub Actions that runs lint, Spectral, the tests and Newman, and fails if anything fails (05-05). |
Phase 6 — Write-up (module 6)
Deliverable: docs/write-up.md + docs/decisions/*.md (ADRs).
| # | Acceptance criterion |
|---|---|
| 6.1 | At least 6 ADRs with the decision, context, rejected alternatives and consequences, in the style of 06-01. |
| 6.2 | An honest self-critique: three things you would redo and why. |
| 6.3 | A one-year evolution plan, with what you would add without breaking and what would force a v2 (06-03). |
| 6.4 | A written deprecation policy: headers, timescales and communication. |
| 6.5 | A brief comparison with the CafeSocial case of 06-02: which of your decisions would not hold up in another domain. |
- Self-assessment rubric
Score each criterion 0 (inadequate), 0.6 (adequate) or 1 (excellent) and multiply by the weight. Below 60 points, go back to the weakest phase before carrying on.
| Criterion | Weight | Inadequate | Adequate | Excellent |
|---|---|---|---|---|
| Contract design | 20 | URIs with verbs, invented codes, inconsistent errors | Correct resources and methods, catalogued errors | A contract you understand without reading the code; edge cases anticipated |
| Fidelity to the domain | 15 | Business rules missing or contradicting each other | All the rules implemented | Rules implemented and expressed in the contract, not hidden away |
| Correctness under concurrency | 15 | Capacity can be oversold | Atomic reservation inside a transaction | Plus proven with real simultaneous requests |
| Implementation quality | 10 | Everything in the routes, no layers | Layers respected, validation complete | Readable code, reusable services, no repetition |
| Security | 10 | No authentication, or secrets in the repository | JWT, roles, helmet, CORS, rate limiting | Authorisation by ownership and a minimal exposed surface |
| Tests | 10 | Anecdotal or non-existent | ≥ 70 % in the services, error cases | Unit + integration + e2e + contract |
| Documentation | 10 | A terse README | Valid OpenAPI and Swagger UI | Runnable examples and a getting-started guide for the integrator |
| Observability | 5 | console.log |
Structured logs and basic metrics | Request correlation and useful business metrics |
| Write-up and self-critique | 5 | A description of what was done | Justified decisions | Rejected alternatives and a credible evolution plan |
| Total | 100 |
- Getting started: day one
# 1. Project and dependencies
mkdir aroma-academy && cd aroma-academy
npm init -y
npm pkg set type="module"
npm pkg set engines.node=">=20"
npm install express@4 zod better-sqlite3 jsonwebtoken bcrypt \
helmet cors express-rate-limit pino pino-http prom-client \
swagger-ui-express yaml
npm install --save-dev supertest @stoplight/spectral-cli newman c8
# 2. Layered structure (the same one used throughout the course)
mkdir -p src/{config,routes,controllers,services,repositories,schemas,middleware,errors,observability}
mkdir -p migrations tests/{unit,integration,e2e,helpers} docs/decisions postman
touch src/app.js src/server.js openapi.yaml docs/analysis.md
# 3. Minimal scripts
npm pkg set scripts.dev="node --watch src/server.js"
npm pkg set scripts.migrate="node migrations/run.js"
npm pkg set scripts.test="node --test tests/"
npm pkg set scripts.contract="spectral lint openapi.yaml"
# 4. Hygiene from minute one
printf "node_modules\n*.db\n.env\n" > .gitignore
git init && git add -A && git commit -m "Initial structure for Aroma Academy"Before you write the first route, write the first ADR. docs/decisions/0001-version-in-the-path.md will cost you ten minutes and save you an argument with yourself in three weeks' time.
- A week-by-week work plan
Estimated for about 8-10 hours a week. Adjust the calendar, not the order.
| Week | Focus | By the end you should have |
|---|---|---|
| 1 | Phase 1 complete | Analysis, glossary and use cases finished. Zero code. |
| 2 | Phase 2: resources, methods, errors | The URI map and the error catalogue reviewed twice |
| 3 | Phase 2: openapi.yaml |
A contract that passes Spectral, with examples for every operation |
| 4 | Phase 3: skeleton, migrations, CRUD for courses and sessions | GET/POST working with validation and unified errors |
| 5 | Phase 3: enrolments, capacity, cancellation, waiting list | The hard logic solved and tested under concurrency |
| 6 | Phase 3: authentication, authorisation, coverage | Green tests and ≥ 70 % in the services |
| 7 | Phase 4 complete | Headers, limits, CORS, ETag, logs and metrics |
| 8 | Phase 5 complete | Postman + Newman, Swagger UI, Docker and CI all green |
| 9 | Phase 6 and a pass with the rubric | Write-up, ADRs and an honest score |
- Hints for the points where almost everybody gets stuck
7.1 Is the enrolment a resource of its own or a sub-resource of the session?
Both, and that is not a trick. It is created where the constraint lives (POST /v1/sessions/{id}/enrolments: the seat belongs to the session) and it is queried and manipulated through its own identity (GET /v1/enrolments/{id}), because an enrolment has a lifecycle, appears under "my enrolments" and is referenced from ratings and invoices. The practical rule: if something is listed independently of its parent or linked to from elsewhere, it needs a canonical URI of its own (02-02).
7.2 Seats and concurrency: the condition goes inside the UPDATE
The classic mistake is to read, check in JavaScript and write. A whole other request fits between the read and the write. The check has to be part of the write, as in 06-01:
-- Correct: the condition lives in the UPDATE. If it returns 0 rows, there was no seat.
UPDATE sessions
SET seats_taken = seats_taken + 1
WHERE id = ?
AND seats_taken < capacity
AND status = 'open';// services/enrolments.js
export function enrol(sessionId, attendeeId) {
return db.transaction(() => {
const res = sessionRepository.takeSeat(sessionId); // the UPDATE above
if (res.changes === 0) {
// We do not know whether it is full or closed: we ask now, with no race left
const session = sessionRepository.findById(sessionId);
if (!session) throw new NotFoundError('session_not_found');
if (session.status !== 'open') throw new ConflictError('enrolments_closed');
throw new ConflictError('session_full'); // 409, with a link to the waiting list
}
return enrolmentRepository.create({ sessionId, attendeeId, status: 'confirmed' });
})();
}And add the safety net in the schema, so that the database does not depend on your code being perfect:
CREATE TABLE enrolments (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
attendee_id TEXT NOT NULL REFERENCES attendees(id),
status TEXT NOT NULL CHECK (status IN ('confirmed','cancelled','attended','absent')),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
created_at TEXT NOT NULL
);
-- One person cannot hold two live enrolments on the same session
CREATE UNIQUE INDEX ux_live_enrolment
ON enrolments (session_id, attendee_id)
WHERE status <> 'cancelled';7.3 400 or 409?
The right question is: could resending the very same request later work?
| Situation | Code | Why |
|---|---|---|
rating: 9 on a rating that runs 1 to 5 |
400 |
The request is malformed; it will never be valid |
sessionId missing |
400 |
Body syntax |
| Session full | 409 |
The request is valid; the server's state prevents it, and that can change |
| Enrolments already closed by the cut-off | 409 |
Valid, but incompatible with the current state |
| Rating a session you did not attend | 403 |
It is a question of permission over the resource, not of shape |
| Non-existent session | 404 |
There is no resource to apply the operation to |
An extra trick: 422 is legitimate when the body is syntactically valid but semantically impossible (an end date earlier than the start date). Pick one of the two conventions (400 for everything, or 400/422 kept separate) and apply it with no exceptions; what breaks integrators is the inconsistency, not the choice (02-04).
7.4 Dates and time zones
Always store in UTC and emit ISO-8601 with a Z. But add the venue's time zone to the session ("timeZone": "Europe/Madrid") and the already-formatted local time if your clients are going to display it: do not force every consumer to work it out. For filtering, accept an explicit range rather than an ambiguous concept:
Avoid ?date=2027-03-28, because "that day" depends on the time zone of whoever is asking and 28 March has 23 hours in Madrid. If you still want to offer it for convenience, document explicitly that it is interpreted in the venue's zone.
7.5 The waiting list is a status, not another collection
It is tempting to create a parallel table and a parallel resource. But a person on the waiting list is an enrolment with status: "waiting" and a position. That way, promoting somebody is a status change and not a move between collections, "my enrolments" returns everything with one filter, and you do not duplicate the uniqueness rules. Expose /v1/sessions/{id}/waiting-list as a filtered view of that session's enrolments, not as a separate store.
7.6 Do not couple the JSON to the tables
Your table has seats_taken, capacity and price_cents. Your representation should offer what the consumer needs: availableSeats (derived), price in euros with two decimals, a computed status ("open", "full", "closed"). If you later change how seats are computed, the contract never notices. This is the line that separates an API from a form on top of a database (02-01, 03-05).
- Reference solution for Phase 2
I am giving you the contract worked out so you have a yardstick. Do not copy without understanding: every row has a reason, and some decisions are debatable on purpose. The rest of the phases are down to you.
8.1 URI map
| Method and URI | What it does | Success | Frequent errors | Auth |
|---|---|---|---|---|
GET /v1/courses |
Lists courses, with level, maxDuration, q, limit/offset |
200 |
400 |
No |
GET /v1/courses/{id} |
Detail with _links to its sessions |
200 |
404 |
No |
POST /v1/courses |
Creates a course | 201 + Location |
400, 403 |
Admin |
PATCH /v1/courses/{id} |
Modifies individual fields | 200 |
400, 404, 409 |
Admin |
GET /v1/courses/{id}/sessions |
A course's sessions, filterable by from/to/venue |
200 |
400, 404 |
No |
GET /v1/sessions |
All sessions, same filtering | 200 |
400 |
No |
GET /v1/sessions/{id} |
Detail with availableSeats and status |
200 |
404 |
No |
POST /v1/sessions |
Schedules a session of a course | 201 |
400, 404, 409 |
Admin |
POST /v1/sessions/{id}/enrolments |
Takes a seat (accepts Idempotency-Key) |
201 |
400, 401, 404, 409 |
Attendee |
GET /v1/sessions/{id}/enrolments |
The session's enrolled attendees | 200 |
403, 404 |
Instructor/Admin |
GET /v1/sessions/{id}/waiting-list |
View of the waiting enrolments, ordered by position |
200 |
404 |
Instructor/Admin |
POST /v1/sessions/{id}/waiting-list |
Joins the waiting list when the session is full | 201 |
401, 404, 409 |
Attendee |
GET /v1/enrolments |
My enrolments, filtered by status |
200 |
401 |
Attendee |
GET /v1/enrolments/{id} |
Canonical detail | 200 |
401, 403, 404 |
Owner/Admin |
PUT /v1/enrolments/{id}/cancellation |
Cancels and returns the computed refund | 200 |
401, 403, 404, 409 |
Owner/Admin |
POST /v1/ratings |
Rates an attended session | 201 |
400, 401, 403, 409 |
Attendee |
GET /v1/ratings |
Lists by sessionId, courseId or instructorId, with cursor |
200 |
400 |
No |
DELETE /v1/ratings/{id} |
Withdraws your own rating | 204 |
401, 403, 404 |
Owner/Admin |
GET /v1/instructors |
List with specialities | 200 |
400 |
No |
GET /v1/instructors/{id} |
Detail with averageRating and totalSessions |
200 |
404 |
No |
Two decisions deserve a comment. Cancellation is a sub-resource with PUT, not a DELETE /enrolments/{id}: cancelling deletes nothing (the enrolment still exists, with its history and its refund), it has a result of its own to return and it is idempotent —cancelling twice leaves the same state. And POST /v1/ratings does not hang off the session because a rating is listed and queried across the board (by instructor, by course, by author) and its natural link is the enrolment, which already identifies both the session and the person.
8.2 Error catalogue
| Code | HTTP | When |
|---|---|---|
invalid_data |
400 | Validation failure; details carries the field and the reason |
invalid_date_range |
400 | from later than to, or a non-ISO-8601 format |
invalid_cursor |
400 | Malformed or expired cursor |
invalid_credentials |
401 | Wrong username or password |
token_expired |
401 | Expired JWT; the client must renew |
insufficient_permissions |
403 | A role with no access to the operation |
did_not_attend_session |
403 | An attempt to rate without an attended enrolment |
course_not_found |
404 | Non-existent id |
session_not_found |
404 | Non-existent id |
enrolment_not_found |
404 | Non-existent id, or somebody else's without privileges |
session_full |
409 | No seats left; _links.waitingList points to the way out |
enrolments_closed |
409 | The minimum notice period was exceeded |
already_enrolled |
409 | A live enrolment already exists on that session |
already_rated |
409 | Only one rating per enrolment is accepted |
session_already_cancelled |
409 | An operation on a cancelled session |
promo_code_not_applicable |
409 | Expired, used up or incompatible |
rate_limit_exceeded |
429 | Limit exceeded; see Retry-After |
internal_error |
500 | An unforeseen failure; logged with a correlation id |
An example error body, in the course's format:
{
"error": {
"code": "session_full",
"message": "Session ses_0412 has no seats available.",
"details": [
{ "field": "sessionId", "reason": "capacity 12 of 12 taken" }
]
},
"_links": {
"waitingList": "/v1/sessions/ses_0412/waiting-list",
"alternativeSessions": "/v1/courses/crs_007/sessions?from=2027-04-01T00:00:00Z"
}
}Notice the detail: the error does not just say no, it says what to do next. That is HATEOAS applied with judgement (01-05), without pointless ceremony.
8.3 An openapi.yaml fragment
openapi: 3.1.0
info:
title: Aroma Academy API
version: 1.0.0
description: Courses, in-person sessions and enrolments for Aroma Store.
servers:
- url: https://api.aroma-academy.example/v1
paths:
/sessions/{sessionId}/enrolments:
post:
summary: Enrols the authenticated attendee on a session
operationId: createEnrolment
tags: [Enrolments]
security: [{ bearerAuth: [] }]
parameters:
- name: sessionId
in: path
required: true
schema: { type: string, pattern: '^ses_[0-9]{4}$' }
- name: Idempotency-Key
in: header
required: false
description: Repeating the request with the same key does not create a second enrolment.
schema: { type: string, maxLength: 64 }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
promoCode: { type: string, maxLength: 24 }
additionalProperties: false
responses:
'201':
description: Enrolment confirmed
headers:
Location:
schema: { type: string }
example: /v1/enrolments/enr_10233
content:
application/json:
schema: { $ref: '#/components/schemas/Enrolment' }
'409':
description: Session full or enrolments closed
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
Enrolment:
type: object
required: [id, sessionId, attendeeId, status, price, createdAt]
properties:
id: { type: string, example: enr_10233 }
sessionId: { type: string, example: ses_0412 }
attendeeId: { type: string, example: att_0091 }
status:
type: string
enum: [confirmed, waiting, cancelled, attended, absent]
waitingPosition:
type: [integer, 'null']
description: Position on the waiting list; null if confirmed.
price: { type: string, example: '45.00', description: Euros with two decimals }
appliedDiscount: { type: string, example: '15%' }
createdAt: { type: string, format: date-time }
_links:
type: object
properties:
self: { type: string }
session: { type: string }
cancellation: { type: string }
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code: { type: string, example: session_full }
message: { type: string }
details:
type: array
items:
type: object
properties:
field: { type: string }
reason: { type: string }8.4 The flow you have to make work
sequenceDiagram
participant C as Client
participant A as Aroma Academy API
participant D as SQLite
C->>A: POST /v1/sessions/ses_0412/enrolments
A->>A: Validates the JWT and the body (Zod)
A->>D: BEGIN + UPDATE sessions ... WHERE seats_taken < capacity
alt A seat was left
D-->>A: 1 row modified
A->>D: INSERT enrolment (confirmed) + COMMIT
A-->>C: 201 Created + Location
else Session full
D-->>A: 0 rows modified
A->>D: ROLLBACK
A-->>C: 409 session_full + _links.waitingList
end
Common Mistakes and Tips
- Starting with the code. The symptom is an
openapi.yamlwritten at the end to document what already exists. The contract is designed first; otherwise you end up exposing your table schema and discovering in phase 5 that the pagination does not fit. - Checking capacity with a preceding
SELECT. It works in development, where there are never two requests at once, and it oversells on the first real day. Write a test that fires 20 simultaneous enrolments at a 5-seat session and demands exactly 5 confirmed. - Using
DELETEto cancel. You lose the history, you cannot return the computed refund and you have nowhere to put the reason. Model cancellation as a state transition. - Duplicating the waiting list in another table and another resource. It multiplies the rules, invites inconsistencies (somebody confirmed and waiting) and complicates "my enrolments".
- Returning cents in some places and euros in others. Choose, document it in the contract and put it in the converter of the representation layer, not in every controller (
02-05). - Error messages as the only information. Clients program against the
code, not against the text. If you change a message nothing happens; if you change a code, you break integrations (06-03). - Authorising by role alone. "They are an attendee" is not enough: you have to check that that enrolment is theirs. Ownership is verified in the service, with the id from the token, never with an id that comes from the body.
- Leaving observability until the end. Adding the correlation id once there are 40 files costs five times what it costs to put it in on day one.
- A tip on pace: if a phase blocks you for more than two days, deliver the minimum version that meets the criteria and move on. Coming back with the whole project assembled is easier than perfecting it in a vacuum.
- A final tip: record every doubtful decision in an ADR at the moment you doubt it. The phase 6 write-up will then almost write itself.
Exercises
Exercise 1 — Classifying modelling decisions
For each element of Aroma Academy, decide whether it should be (a) a resource with its own URI, (b) a sub-resource, (c) a field of another resource, or (d) a query parameter. Justify it in one sentence.
- The waiting list of a session.
- The 15 % discount for store customers.
- An instructor's average rating.
- The cancellation of an enrolment.
- The sessions of a course within a date range.
Exercise 2 — Choosing the status code
State the HTTP code and the catalogue error code for each situation:
POST /v1/ratingswith"rating": 0.POST /v1/sessions/ses_0412/enrolmentson a session with 12 of 12 seats taken.PUT /v1/enrolments/enr_555/cancellationwhenenr_555belongs to somebody else.POST /v1/sessions/ses_9999/enrolmentswhereses_9999does not exist.POST /v1/sessions/ses_0412/enrolments6 hours before a session with a 24-hour minimum notice.
Exercise 3 — The Zod schema for the enrolment
Write the Zod schema that validates the body of POST /v1/sessions/{sessionId}/enrolments and that of PUT /v1/enrolments/{id}/cancellation, knowing that the cancellation accepts an optional reason of up to 200 characters and that no extra field may slip through.
Solutions
Exercise 1
- (b) A sub-resource of the session, but as a filtered view of its enrolments:
GET /v1/sessions/{id}/waiting-list. It is not an independent collection; inside, it isstatus: "waiting". - (c) A derived field of the price in the representation (
basePrice,price,appliedDiscount). It has no identity of its own and is not queried on its own; it is computed when representing and when enrolling. - (c) A computed field of the instructor (
averageRating,totalRatings). The consumer wants it alongside the instructor, and forcing a second request for a single number is bad design. - (b) A sub-resource of the enrolment with
PUT: it has effects of its own (the refund), a result to return and it is idempotent. ADELETEwould lose the information. - (d) Query parameters
fromandtoonGET /v1/courses/{id}/sessions. A filter does not create a new resource (02-06).
Exercise 2
| # | Code | Error | Reason |
|---|---|---|---|
| 1 | 400 |
invalid_data |
Outside the 1-5 range: the request will never be valid |
| 2 | 409 |
session_full |
A valid request, an incompatible and changeable server state |
| 3 | 403 |
insufficient_permissions |
An existing resource over which you have no permission |
| 4 | 404 |
session_not_found |
The target resource does not exist |
| 5 | 409 |
enrolments_closed |
Valid, but the time window has already closed |
In case 3, if you prefer not to reveal the existence of other people's enrolments, a 404 is defensible: it is a security decision, not a semantic one, and it must be documented (04-02).
Exercise 3
// schemas/enrolments.js
import { z } from 'zod';
// Body of POST /v1/sessions/{sessionId}/enrolments
export const createEnrolmentSchema = z.object({
promoCode: z.string().trim().min(3).max(24).regex(/^[A-Z0-9-]+$/, {
message: 'Capitals, digits and hyphens only'
}).optional()
}).strict(); // strict() rejects extra fields: no sneaking in "status" or "price"
// Path parameter, validated separately so responsibilities do not get mixed up
export const sessionIdSchema = z.object({
sessionId: z.string().regex(/^ses_[0-9]{4}$/, { message: 'Invalid session identifier' })
});
// Body of PUT /v1/enrolments/{id}/cancellation
export const cancellationSchema = z.object({
reason: z.string().trim().max(200).optional()
}).strict();The important detail: the client does not send attendeeId. It comes from the JWT. If you accepted it from the body, anybody could enrol somebody else; it is the most common authorisation flaw in projects of this kind (03-06).
Conclusion
We have come a long way. We started in module 1 asking what an API really is and why HTTP, born to serve documents, ended up being the best foundation for connecting systems; we went through Richardson, HATEOAS and the honest comparison with SOAP, GraphQL and gRPC. In module 2 we stopped programming in order to design: resources, URIs, methods, codes, representations, pagination, versioning and documentation. Module 3 turned that contract into code with layers, validation, persistence, authentication, errors and tests. Module 4 hardened it for the real world: security, OAuth, limits, CORS, caching and observability. Module 5 gave us the tools that make the daily work sustainable. And module 6 taught us, through three cases, that an API is judged by how it ages.
If in a few years' time you forget the details —and you will, because Express versions change and libraries get replaced— hold on to what does not expire:
- The contract is the product. The code is replaceable; the promise you have made to whoever consumes you is not.
- You design for the consumer, not for the database. The structure of your tables is your own business. Letting it leak into the JSON is the origin of half the bad APIs out there.
- HTTP solves more than it looks. Status codes, conditional headers, caching, content negotiation, idempotency: almost every time you want to invent a mechanism, check first whether it already exists.
- Compatibility is a commitment. Adding without breaking is not a technical limitation, it is a form of respect towards people who trusted you.
- Security and observability are not optional. An API without correct authorisation is a breach waiting for a date; an API without traces is a black box on the day of the incident.
- There is no universal REST design. We saw it in
06-02: what was obvious for a store stopped being obvious for a social network. The rules are tools for thinking, not dogma.
Where to go next? Read the primary sources: the HTTP RFCs (9110 to 9114), the Problem Details one (9457), the OpenAPI specification, and OAuth 2.1 and OIDC. Study public APIs that take their contract seriously —Stripe is a masterclass in versioning and errors; GitHub, in pagination, conditionality and evolution over more than a decade. Take any API you work with and criticise it with what you now know: look at its status codes, its pagination, its caching headers, its deprecation policy. And if you can, contribute: review other people's contracts, open issues on your team's specifications, write the documentation you would have liked to find yourself.
You started this course with some knowledge of JavaScript. You finish it knowing how to design a contract before writing a single line, build a layered API with validation, transactional persistence and authentication, protect it, cache it, measure it, document it, test it, package it and deploy it, and —the hardest part— sustain it as it changes without leaving anyone who depends on it stranded. That is not a small thing: it is the complete job.
Now go and build Aroma Academy. And when you finish, read it as if you were the integrator who will arrive three years from now.
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
