Escena Viva is fast and measured. What it has left is not a performance problem but a design problem. Its API grew by accumulation, module after module: first a hand-made server in M4, then Express routes in M6, then authentication in M8. Along the way, verbs appeared in URLs, status codes were chosen by eye, listings shipped without pagination, there was no versioning policy, and one doubt we explicitly left open in Module 4: what happens when Marc sends the same POST /api/orders twice because he lost coverage on the Festival de Jazz opening night. This lesson turns that API into a well-designed API, with justified criteria and not out of aesthetic taste: an API is a contract that other people program against and that you cannot break whenever you feel like it.
Contents
- What REST really is
- The Richardson maturity model and HATEOAS
- Designing resources and non-CRUD actions
- HTTP methods and their guarantees
- The idempotency key
- Status codes used with judgment
- Response and error format
- Pagination, filtering, sorting and field selection
- Versioning and deprecation policy
- HTTP caching and conditional requests
- Documentation with OpenAPI
- Redesigning the Escena Viva API
- What REST really is
REST (Representational State Transfer) is an architectural style described by Roy Fielding in his year-2000 doctoral thesis. It is not a format, it is not JSON and it is not "using HTTP verbs": it is six constraints.
| Constraint | What it requires | How Escena Viva meets it |
|---|---|---|
| Client-server | Separation of responsibilities and interfaces | The API knows nothing about the web interface |
| Stateless | Every request carries everything it needs | JWT access tokens (M8); the Redis session is a conscious violation |
| Cacheable | Responses are labeled as cacheable or not | Cache-Control and ETag on reads |
| Uniform interface | URIs, representations, self-descriptive messages, hypermedia | The constraint we fall shortest on |
| Layered system | The client cannot tell whether it is talking to the origin server or a proxy | A reverse proxy and a cache in between (M11) |
| Code on demand (optional) | The server may send executable code | Not used, and almost nobody uses it |
The stateless constraint is the one with the most practical consequences and the one most often violated. It is literally what makes the whole of Module 10 possible: if every request stands on its own, any of the seven workers from lesson 10-01 can serve it, and that is why SCHED_RR distribution works. When in 10-03 we moved sessions to Redis, what we did was externalize the state so the server would remain interchangeable. A server with state in memory does not scale horizontally: it is the same lesson seen from the other side.
- The Richardson maturity model and HATEOAS
Leonard Richardson proposed a four-level scale to place yourself honestly:
| Level | What characterizes it | Example |
|---|---|---|
| 0 / 1 | A single endpoint as a tunnel; or resources with URIs but everything over POST |
POST /api with { "action": ... }; POST /events/evt-003/buy |
| 2 | HTTP methods and status codes with their semantics | GET /events/evt-003, DELETE /orders/ord-77 → 204 |
| 3 | HATEOAS: the response includes links to the possible actions | The order response carries the cancel and tickets links |
Almost no API called REST gets past level 2, and it is worth saying so without drama: level 2, done well, is an excellent design and it is the realistic goal. HATEOAS (Hypermedia as the Engine of Application State) means the client discovers what it can do from the response itself, instead of having URLs hard-coded:
{
"id": "ord-77", "status": "paid", "totalCents": 9000,
"_links": {
"self": { "href": "/api/v1/orders/ord-77" },
"cancel": { "href": "/api/v1/orders/ord-77/cancellation", "method": "POST" }
}
}Its real value is that the links express the state: if the order is already cancelled, the cancel link does not appear, and the client does not have to replicate the business rules to know which button to show. The reason almost nobody reaches level 3 is that real clients (a React application, a mobile app) do not navigate hypermedia: they have the routes written in their code and gain nothing. A practical recommendation for Escena Viva: a solid level 2, with links where they pay off (state-conditioned actions and page navigation), without faking level 3 with self links nobody uses and without giving up links when they remove duplicated logic from the client.
- Designing resources and non-CRUD actions
| Rule | Yes | No |
|---|---|---|
| Plural nouns, not verbs | /events/evt-003 |
/getEvent/evt-003 |
| Lower case and hyphens | /sold-out-sessions |
/soldOutSessions |
| No format extension, no trailing slash | /events + Accept |
/events.json, /events/ |
| Hierarchy only where there is real ownership | /events/evt-003/sessions |
/venues/ribera/events/evt-003/sessions/ses-003-1 |
On hierarchy, the criterion is existence dependency. A session does not exist without its event, so /events/evt-003/sessions is correct for listing them; but a specific session has an identity of its own (ses-003-1) and must also be reachable at /sessions/ses-003-1. The usual rule: nest at most one level for the listing inside the parent, and expose the child resource at its own root for direct access, because hierarchies of three or more levels force the client to know the whole chain of identifiers for nothing. And now the hard question: how do you model cancelling an order or publishing an event? They are neither creations nor deletions, they are state transitions, and there are three legitimate options plus one that is not:
| Option | Example | When |
|---|---|---|
| A sub-resource representing the action | POST /orders/ord-77/cancellation |
The action has data of its own or leaves a record you can query |
PATCH on the status field |
PATCH /events/evt-003 → { "status": "published" } |
The transition is simple and has no side effects |
| A collection of transitions | POST /orders/ord-77/transitions → { "type": "cancel" } |
Many transitions on the same entity |
| ~~A verb in the URL~~ | ~~POST /orders/ord-77/cancel~~ |
Never: it breaks the uniform interface |
In Escena Viva we choose the first one for cancellation, and the reason is concrete: a cancellation is a business entity, with a reason, a date, a refunded amount and who requested it, which the back office will want to consult. GET /api/v1/orders/ord-77/cancellation returns that record, and that would not be possible with a PATCH. To publish an event, by contrast, we use PATCH, because it is a simple field change with no associated data.
- HTTP methods and their guarantees
Every method has three properties that the standard defines and that internet infrastructure takes at face value: proxies, browsers and client libraries retry idempotent methods automatically.
| Method | Safe | Idempotent | Cacheable | Use in Escena Viva |
|---|---|---|---|---|
GET / HEAD |
Yes | Yes | Yes | Read the catalog, an event, an order; check the ETag |
OPTIONS |
Yes | Yes | No | CORS (M6) |
POST |
No | No | Rarely | Create an order, cancel, authenticate |
PUT / DELETE |
No | Yes | No | Replace an event; delete a draft |
PATCH |
No | No by default | No | Partial modification |
The exact definitions matter. Safe means it does not modify server state: a GET that deletes something is a serious bug, because any crawler or browser prefetcher will trigger it. Idempotent means running it N times leaves the system as it would be after running it once: DELETE /events/evt-009 twice leaves the event deleted and the second one returns 404, but the state is the same; idempotent does not mean "returns the same thing". And cacheable means the response may be stored and reused. As for PUT versus PATCH, PUT replaces the whole resource (whatever you do not send is deleted) and PATCH modifies partially; the problem is that PATCH does not define its own format, so you have to say which one you use:
PATCH /api/v1/events/evt-003 HTTP/1.1
Content-Type: application/merge-patch+json
{ "title": "Festival de Jazz de Primavera 2026", "shortDescription": null }application/merge-patch+json (RFC 7386) is the reasonable format: the fields present are assigned, and null means "delete this field". Its limit is that it cannot genuinely set a field to null nor modify a specific element of an array; that is what application/json-patch+json (RFC 6902) is for, with explicit operations, far more powerful and far more awkward. Recommendation: merge-patch unless you need the other one. And a note on PATCH and idempotency: { "title": "X" } is idempotent in practice; what is not are relative operations such as { "increaseCapacity": 10 }, which are best avoided.
- The idempotency key
Here we resolve the Module 4 doubt. A real scenario: on the Festival de Jazz opening night, Marc taps "Buy 2 tickets for ses-003-1". The request reaches the server, is processed, order ord-77 is created... and the response is lost because his phone switched cell towers. The client retries. Two orders are created and Marc pays twice. Since POST is not idempotent by definition, the infrastructure cannot help us, and the industry-standard solution (Stripe, PayPal, and now an IETF draft) is a header:
POST /api/v1/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8f14e45f-ea0c-4f2e-9b3d-2c1a7e5d0b91
{ "sessionId": "ses-003-1", "quantity": 2 }The client generates the key (a UUID) before the first attempt and reuses it on every retry of that same operation. The server uses it like this:
'use strict';
const crypto = require('node:crypto');
const { getRedisClient } = require('../db/redis.js');
const { ApiError } = require('../errors.js');
const TTL = 24 * 3600;
// Idempotency middleware for non-idempotent POST operations.
const createIdempotency = ({ redis = getRedisClient() } = {}) =>
async function idempotency(req, res, next) {
const key = req.get('Idempotency-Key');
if (!key) return next(); // optional; it can be required on purchases
// The body fingerprint stops the same key being reused for a different
// request, which would be a client bug.
const fingerprint = crypto.createHash('sha256')
.update(JSON.stringify(req.body || {})).digest('hex');
const redisKey = `idem:${req.user?.id || 'anonymous'}:${key}`;
const inProgress = JSON.stringify({ status: 'in-progress', fingerprint });
// Atomic SET NX: only the first one to arrive reserves the operation.
if (!(await redis.set(redisKey, inProgress, 'EX', TTL, 'NX'))) {
const stored = JSON.parse(await redis.get(redisKey));
if (stored.fingerprint !== fingerprint) {
return next(new ApiError('IDEMPOTENCY_KEY_REUSED', 422,
[{ field: 'Idempotency-Key', detail: 'The key was already used with another body.' }]));
}
if (stored.status === 'in-progress') {
// The first attempt is still running: 409, and let it retry.
res.set('Retry-After', '2');
return next(new ApiError('OPERATION_IN_PROGRESS', 409, []));
}
// We replay the original response as it was, with no second charge.
res.set('Idempotent-Replay', 'true');
return res.status(stored.status).json(stored.body);
}
// We are the first one. We intercept json() to store the response.
// 5xx are not memoized: the retry must be able to run.
const originalJson = res.json.bind(res);
res.json = (body) => {
const record = JSON.stringify({ status: res.statusCode, body, fingerprint });
(res.statusCode < 500
? redis.set(redisKey, record, 'EX', TTL)
: redis.del(redisKey)).catch(() => {});
return originalJson(body);
};
return next();
};
module.exports = { createIdempotency };Four decisions worth justifying. The body fingerprint prevents a client with a programming bug from reusing the key for another purchase. The in-progress state covers the race between two simultaneous requests. 5xx errors are not memoized, because a transient failure must be retryable. And the 24-hour TTL bounds the growth without shutting out reasonable retries. All of this combines with the consumer idempotency from lesson 10-03: the key protects the entry to the API, and jobId protects the execution of the job.
- Status codes used with judgment
| Code | When | In Escena Viva |
|---|---|---|
| 200 OK | A read, or a modification with a body | GET /events |
| 201 Created | A resource was created; the Location header is mandatory |
POST /orders |
| 202 Accepted | Accepted for later processing | POST /orders/ord-77/tickets (queue, 10-03) |
| 204 No Content | Success with no body | DELETE /events/evt-009 |
| 207 Multi-Status | A batch operation with mixed results | POST /orders/batch |
| 304 Not Modified | If-None-Match matches |
An unchanged catalog (M4) |
| 400 / 401 / 403 | Malformed / no credentials / no permission | Broken JSON; expired token; Bóveda touching evt-003 |
| 404 Not Found | It does not exist, or it must not be known to exist | Another user's order |
| 409 Conflict | A conflict with the current state | Insufficient capacity; cancelling an already cancelled order |
| 412 Precondition Failed | If-Match does not match |
A lost update avoided |
| 422 Unprocessable Content | Valid syntax, invalid semantics | quantity: 0, a date in the past |
| 429 / 500 / 503 | Limit exceeded; internal error; unavailable | purchaseLimit; an unexpected failure; the database is down |
The antipattern to eradicate is answering 200 OK with { "error": ... } inside. It breaks everything between the client and you: proxies cache an error as though it were a valid response, client libraries do not throw, monitoring counts 0% errors while the system burns, and automatic retries never trigger. The status code is part of the message, not decoration.
The 400 / 422 distinction is the one that raises the most doubts: 400 if I could not understand the request (broken JSON, a missing header); 422 if I understood it perfectly but cannot accept it (quantity: -3 is a valid number and an impossible quantity), so almost every zod validation error (M6) is a 422. The 409, in turn, has a very specific use: trying to buy 5 tickets when 3 are left is not a validation error —5 is a perfectly valid quantity— but a conflict with the resource's current state, and moreover an error that can disappear if somebody cancels their order, which the client needs to know.
- Response and error format
Envelope or not? Returning the resource directly ({ "id": "evt-003", ... }) is clean and it is what most people do; an envelope ({ "data": ..., "meta": ... }) lets you add metadata without touching the resource. Inconsistency is the only unacceptable option. Escena Viva's decision: the bare resource on detail endpoints, an envelope on collections, because a collection unavoidably needs pagination metadata.
{
"data": [{ "id": "evt-003", "title": "Festival de Jazz de Primavera" }],
"meta": { "total": 3, "limit": 20, "nextCursor": "ZXZ0LTAwMw==" },
"links": { "next": "/api/v1/events?cursor=ZXZ0LTAwMw==&limit=20" }
}Errors. RFC 9457 (Problem Details for HTTP APIs, which supersedes 7807) defines a standard format, served with Content-Type: application/problem+json:
{
"type": "https://escenaviva.test/errors/insufficient-capacity",
"title": "Insufficient capacity",
"status": 409,
"detail": "You requested 5 tickets and 3 are left for session ses-003-1.",
"instance": "/api/v1/orders",
"available": 3
}Compared with Escena Viva's own format:
| Aspect | Escena Viva's format | RFC 9457 |
|---|---|---|
| Shape | { error: { code, message, status, details } } |
A flat object with type, title, status, detail |
| Stable identifier | code (INSUFFICIENT_CAPACITY) |
type (a URI) |
| Field errors | details: [{ field, detail }] |
A custom extension (errors) |
| Tooling | None | Recognized by libraries and gateways |
| Documentation | Separate | The type is a URL with the explanation |
The honest decision: we keep our format, because there are clients that already depend on it and breaking it would be a major contract break, but we add Content-Type: application/problem+json with the type, title and status aliases as additional fields, so standard tooling understands the response and our clients keep working. It is a progressive convergence, not a big-bang migration; if you were starting from scratch today, use RFC 9457 directly. And a security rule inherited from M8: the error never leaks internal details —no stack traces, no table names, no SQL— but it does include the requestId (from the request-id.js middleware) so the user can quote it to support and you can correlate it with the log.
- Pagination, filtering, sorting and field selection
For pagination there are two schools:
| Aspect | Offset (?page=3&limit=20) |
Cursor (?cursor=ZXZ0...&limit=20) |
|---|---|---|
| Jump to page 47 / total | Yes / easy with COUNT |
No, sequential only / expensive or nonexistent |
| Cost in the database | OFFSET 10000 reads and discards 10,000 rows |
An index + WHERE id > cursor: constant |
| Changing data | Items are skipped and repeated | Stable |
| Client comprehension | Immediate | Requires explanation |
The decisive argument is the changing-data one: if Lucía looks at page 1 of the catalog sorted by sales, tickets get sold and then she asks for page 2, with offset she will see repeated items and skip others, because the order changed under her feet. With a cursor that does not happen, because it encodes a stable position in the ordering.
'use strict';
// The cursor encodes the sort fields, not a row number.
const encodeCursor = ({ startsAt, id }) =>
Buffer.from(JSON.stringify({ startsAt, id })).toString('base64url');
// An invalid cursor is answered with a 400 (it returns undefined).
const decodeCursor = (cursor) => {
if (!cursor) return null;
try { return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); }
catch { return undefined; }
};
// The id tie-breaker is mandatory: without it, two events with the same
// date can appear twice or vanish between pages.
const buildClause = (cursor) => (!cursor ? {} : {
where: { [Op.or]: [
{ startsAt: { [Op.gt]: cursor.startsAt } },
{ startsAt: cursor.startsAt, id: { [Op.gt]: cursor.id } },
] },
});
module.exports = { encodeCursor, decodeCursor, buildClause };A practical recommendation: cursor for the public catalog (large, changing, sequential navigation) and offset for the admin panel (where the Sala Bóveda organizer does want to jump to page 7 and see the total). And in both cases, a maximum limit enforced by the server: limit=100000 cannot be a way of taking the API down. With the same consistency you define the query grammar, a single set of conventions for the whole API:
| Need | Convention | Example |
|---|---|---|
| Equality filter | field=value |
?venueId=org-ribera |
| Multiple filter (OR) | A comma-separated list | ?status=published,soldOut |
| Range | _from / _to suffixes |
?startsAt_from=2026-04-01 |
| Text search | q |
?q=jazz |
| Sorting | sort, with - for descending |
?sort=-startsAt,title |
| Fields and relations | fields, include |
?fields=id,title&include=sessions |
All of this is validated with zod (M6), with an allowlist of sortable and expandable fields: without it, ?sort=secretColumn is an information leak and ?include=all is a denial of service. Field selection is also a real optimization, because it shrinks the serialized JSON that lesson 10-04 identified as a significant CPU consumer.
- Versioning and deprecation policy
Three approaches, with their trade-offs:
| Strategy | Example | For | Against |
|---|---|---|---|
| In the path | /api/v1/events |
Visible, trivial to route and cache | "Not very RESTful": the resource does not change identity when the version changes |
| In a header | X-API-Version: 2 |
Stable URLs | Invisible; requires Vary; forgotten while debugging |
| By media type | Accept: application/vnd.escenaviva.v2+json |
The most faithful to REST | Verbose; poorly supported by tooling and caches |
Recommendation: in the path. It is what Stripe, GitHub and practically everyone else use, for a pragmatic reason: it is the only one a developer understands without reading documentation, and the only one that works well with proxies and caches. And a warning: versioning is expensive, because every live version is code you must maintain and test. Change inside v1 whenever the change is additive (new optional fields, new endpoints) and reserve v2 for real breakage: removing a field, changing a type, changing the meaning of something. When it is time to deprecate, there are standard headers:
HTTP/1.1 200 OK
Deprecation: Sun, 01 Nov 2026 00:00:00 GMT
Sunset: Sun, 01 May 2027 00:00:00 GMT
Link: <https://escenaviva.test/docs/v2-migration>; rel="deprecation"
Warning: 299 - "GET /api/v1/events is deprecated. Migrate to /api/v2/events before 2027-05-01."Deprecation states since when it has been obsolete; Sunset (RFC 8594) states when it will stop working. A reasonable calendar: the announcement, six months of coexistence with warnings, a trial "blackout day" (a few hours of 410 responses so laggard clients find out), and removal. And something that gets forgotten: measure the usage of each version per client, because without that data switching v1 off is a leap into the void.
- HTTP caching and conditional requests
In Module 4 we implemented ETag and 304 by hand on top of node:http; now we apply them with judgment and combine them with the Redis cache from lesson 10-03.
'use strict';
const crypto = require('node:crypto');
// The catalog is public and the same for everyone: it can be cached in
// intermediate proxies as well as in the browser.
const createCatalogController = ({ catalog }) =>
async function listEvents(req, res, next) {
try {
const { data } = await catalog.getCatalog(req.validatedData.query);
const body = JSON.stringify(data);
const etag = `"${crypto.createHash('sha1').update(body).digest('base64url')}"`;
res.set('ETag', etag);
// max-age: freshness in the client. s-maxage: in proxies.
// stale-while-revalidate: serves a stale copy while refreshing.
res.set('Cache-Control', 'public, max-age=30, s-maxage=60, stale-while-revalidate=120');
// No body: zero bytes transferred.
if (req.get('If-None-Match') === etag) return res.status(304).end();
return res.type('application/json').send(body);
} catch (error) {
return next(error);
}
};
module.exports = { createCatalogController };We now have three chained cache layers: Cache-Control in the browser (the request never even leaves), ETag/304 in the server (the request leaves but no body is transferred) and Redis (10-03), which avoids recomputing and querying PostgreSQL.
A critical security rule: private data carries Cache-Control: private, no-store. A public on the response to GET /api/v1/orders/ord-77 would let a shared proxy store Lucía's orders and serve them to another user. And always Vary: Authorization on responses that depend on the user. Conditional requests to avoid the lost update. The scenario: two Auditorio Ribera organizers edit evt-003 at the same time. Both read the current version, both write; the second one stomps on the first one's changes and nobody notices. That is the lost update, and HTTP solves it with optimistic concurrency control:
GET /api/v1/events/evt-003 → 200 OK, ETag: "v7-a3f2c1"
PATCH /api/v1/events/evt-003
If-Match: "v7-a3f2c1"
Content-Type: application/merge-patch+json
{ "totalCapacity": 520 }
→ 412 if somebody already changed it to "v8-..."A createRequireIfMatch({ getEtag }) middleware implements it in three checks: if the If-Match header is missing, it answers 428 (Precondition Required), which tells the client its request is well formed but that the API demands a condition; if the resource does not exist, 404; and if the tag received does not match the current one (and is not *), it throws ApiError('VERSION_CONFLICT', 412).
- Documentation with OpenAPI
An API with no documentation cannot be used; an API with hand-written documentation lies, because nobody updates it when the code changes. The solution is for the contract and the code to share a single source, and in Escena Viva we already have that source: the zod schemas in src/schemas/ that validate every request. With zod-to-openapi they become the OpenAPI document, with the guarantee that what is documented is exactly what is validated.
'use strict';
const { OpenApiGeneratorV31, extendZodWithOpenApi } = require('@asteasolutions/zod-to-openapi');
const { z } = require('zod');
const swaggerUi = require('swagger-ui-express');
const { schemaRegistry } = require('../schemas/registry.js');
extendZodWithOpenApi(z);
const generateDocument = () =>
new OpenApiGeneratorV31(schemaRegistry.definitions).generateDocument({
openapi: '3.1.0',
info: { title: 'Escena Viva API', version: '1.4.0' },
servers: [{ url: 'https://api.escenaviva.test/api/v1' }],
});
// Served alongside the API: the documentation travels with the code.
function mountDocumentation(app) {
const document = generateDocument();
app.get('/api/v1/openapi.json', (req, res) => res.json(document));
app.use('/api/v1/docs', swaggerUi.serve, swaggerUi.setup(document));
}
module.exports = { generateDocument, mountDocumentation };This way the contract becomes verifiable instead of a promise: in the Module 9 integration tests you can validate every response against the OpenAPI schema, so a response that drifts from the contract breaks the build. That is the difference between documentation and a contract.
- Redesigning the Escena Viva API
| Before | After | Decision |
|---|---|---|
GET /api/events |
GET /api/v1/events?cursor=&limit=20 |
Version in the path; cursor pagination mandatory |
GET /api/event/:id |
GET /api/v1/events/:id |
Consistent plural across the whole API |
GET /api/events/:id/getSessions |
GET /api/v1/events/:id/sessions |
No verbs; hierarchy by real ownership |
| — | GET /api/v1/sessions/:id |
The session has an identity of its own: direct access |
POST /api/buy with 200 |
POST /api/v1/orders + Idempotency-Key → 201 + Location |
A resource, not an action; the code expresses the creation; no duplicate charges |
POST /api/orders/:id/cancel |
POST /api/v1/orders/:id/cancellation |
A cancellation is a queryable entity |
POST /api/orders/:id/generatePdf |
POST /api/v1/orders/:id/tickets → 202, and GET /api/v1/jobs/:id |
A queued job (10-03) with a status resource |
POST /api/events/:id/publish |
PATCH /api/v1/events/:id (merge-patch) |
A simple transition with no data of its own |
GET /api/events?all=true |
GET /api/v1/events?status=draft,published |
A consistent filter grammar |
200 with { error: ... } |
Real 4xx/5xx + application/problem+json |
The status is part of the message |
No ETag on the detail |
ETag + If-None-Match + If-Match |
Bandwidth savings and optimistic concurrency control |
| No documentation | GET /api/v1/openapi.json and /api/v1/docs |
A contract generated from the zod schemas |
Common Mistakes and Tips
- Verbs in URLs (
/getEvents,/buyTickets): the verb goes in the HTTP method. - 200 with an error inside. It breaks proxies, clients, retries and monitoring.
- A
GETthat modifies, or listings with no maximum limit: in the first case a prefetcher can delete data for you, in the second?limit=1000000is a denial of service with parameters. - Confusing 401 with 403. 401 = I do not know who you are. 403 = I know who you are and you cannot.
Cache-Control: publicon private data. A shared proxy can serve Lucía's orders to another user.- Versioning out of habit, or writing the documentation by hand: the first is avoidable maintenance, the second goes out of sync within weeks.
- Tip: on
POSTrequests that charge money, requireIdempotency-Keyand answer 400 if it is missing. It is safer than making it optional. - Tip: add one integration test per documented status code. If you document a 409, prove it.
Exercises
Exercise 1: idempotency under retry
Apply the idempotency middleware to POST /api/v1/orders. Write an integration test (M9) that sends the same request twice with the same Idempotency-Key and verifies: a single order created, the same response and Idempotent-Replay: true on the second one. Add a case with the same key and a different body.
Exercise 2: stable cursor pagination
Implement GET /api/v1/events with a cursor over (startsAt, id). Test it: ask for page 1 with limit=2, insert a new event with an earlier date, ask for page 2 and check that no item is repeated or lost. Repeat with offset and compare.
Exercise 3: avoid the lost update
Add an ETag to GET /api/v1/events/:id and require If-Match on PATCH. Simulate two organizers editing evt-003: both read, the first writes successfully, the second writes with the old tag. Verify the 412 and design the error response so the client knows what to do.
Solutions
Exercise 1. The first request returns 201 with Location: /api/v1/orders/ord-77. The second returns exactly the same body and the same 201, with Idempotent-Replay: true, and Order.count() is still 1. The subtle point is that the replayed response is a 201, not a 200: the original response is replayed as it was, because the client must not be able to tell a retry from a first attempt. With the same Idempotency-Key and a different body, the fingerprint does not match and a 422 with IDEMPOTENCY_KEY_REUSED is returned: it is a client bug, not a new purchase. For the race between simultaneous requests, SET NX guarantees only one wins and the other gets a 409 with Retry-After: 2.
Exercise 2. With a cursor, page 2 continues exactly where page 1 ended: the new event with the earlier date does not appear (it falls "behind" the cursor) and no item is duplicated. With offset, the new event shifts everything forward and the last item of page 1 reappears as the first of page 2. It is a silent failure that in the Festival de Jazz catalog would mean showing the same session twice and hiding another one. An indispensable detail: the cursor must include the id tie-breaker; with startsAt alone, two events on the same date cause exactly the problem you were trying to avoid.
Exercise 3. The first PATCH with If-Match: "v7-a3f2c1" matches and returns 200 with a new ETag. The second, with the old tag, gets a 412. The error must be actionable:
{
"error": {
"code": "VERSION_CONFLICT",
"message": "The event was modified by another user since your last read.",
"status": 412,
"details": [{ "field": "If-Match", "detail": "Read it again and retry." }]
}
}Without If-Match, the second organizer would have stomped on the first one's change: evt-003's capacity would be left at the wrong value and nobody would know until opening night. An implementation note: the tag must derive from the resource's version (a version field incremented on every write, or updatedAt), not from the serialized body, because the body can vary with field selection or formatting.
Conclusion
The Escena Viva API has moved from "it works" to "it is well designed", and every decision has its reason. We know what REST really requires —and that the stateless constraint is exactly what makes the scaling from lesson 10-01 possible—, where Richardson's model places us and why a solid level 2 with selective links is the realistic goal. We model resources with plural nouns and justified hierarchies, and non-CRUD actions as sub-resources with an entity of their own. We know each HTTP method's guarantees, the difference between PUT and PATCH with merge-patch, and we have finally closed the Module 4 doubt with the idempotency key, which stops Marc paying twice when he loses coverage. We use status codes with judgment (201 with Location, 202 for the queued job, 409 for capacity, 422 for validation) and we have banished the 200 with an error inside. We converge towards problem+json without breaking our clients. We paginate by cursor where the data changes, with a consistent query grammar and server-enforced limits. We version in the path with a deprecation policy with dates. We chain three cache layers and avoid the lost update with If-Match and 412. And we generate the documentation from the zod schemas, so the contract cannot lie.
With all of that, one question remains that none of these improvements answers: an event's screen in the Escena Viva app still needs three calls (the event, its sessions, the venue), or a single response with many fields that client does not use. REST design does not remove that dilemma, it only manages it. In the next lesson, GraphQL with Node.js, we will see an alternative where the client asks for exactly what it needs, what price is paid for it —caching, complexity, query limits— and why the right answer is almost never to replace REST, but to let the two coexist.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
