1. Courses
  2. Developer Foundations
  3. Building APIs with Node.js
  4. Stop 1

35 minIntermediate

Designing an API people can use

Before you write a line of NestJS, you write the contract for the partner ordering API: which resources exist, what each method does, which status codes the Partner app can branch on, one error shape, and pagination that stays correct while 1,800 shops keep ordering.

By the end you can

  • Model shops, price lists, orders, order lines and invoices as resources, and choose between a sub-resource and a filter
  • Pick methods by their meaning (safe, idempotent, PUT vs PATCH) and model "cancel" as a state change
  • Return status codes in a fixed order of checks, with one RFC 9457 problem details body
  • Build opaque cursor pagination with a tie-breaker, a limit cap and an index that serves it
  • Ship changes without breaking old app versions: money in cents, UTC timestamps, additive changes

The everyday picture

Think of the order desk at a wholesale market. Every trader fills in the same paper form: shop number, then one row per item. The clerk stamps each form with a ticket number, and from then on that ticket number is the only name the order has. When a form is wrong, the clerk does not shout "no". She hands back a rejection slip with fixed boxes: what kind of problem, which row, and what to fix. A trader who has seen one slip can read them all.

The desk also has rules about time. You can cancel an order until the porters start picking it, and after that the answer is always the same slip: "already being picked". When a trader asks to see his orders, the clerk does not count "page 37". She says "here are the next 25 after ticket 90412", so new orders arriving at the desk never shift what he sees. An API is that desk, written down so that a program can follow it.

Model the resources first

APIs done right showed the basics: nouns in paths, methods for verbs. The harder part is deciding which nouns exist and how they relate. For the Partner app you need five: shops, each shop's price list, orders, the lines inside an order, and invoices. Start from what the app needs to show and do, not from your database tables. Users are shop owners like Ravi, shop staff, Mangrove's sales reps who each look after about 40 shops, and admins.

Each resource gets one : /v1/orders/90412. Two tests decide whether something is nested under another resource or stands on its own.

  • Can it exist without the parent? An order line has no life outside its order, so it is a : /v1/orders/90412/lines. An invoice is created from an order, but then it has its own life: it is sent, paid or overdue, and accounts staff look it up by number. It gets its own top-level URL, /v1/invoices/7731, and the order links to it with an invoiceId field.
  • Do people ask for it across parents? A rep wants "all orders from my 40 shops placed today". If orders lived only under /v1/shops/1042/orders, that question would need 40 calls, and each order would have two URLs. So orders are a top-level collection, and the shop is a filter: GET /v1/orders?shopId=1042&status=PLACED.

For a shop user, the API takes the shop from the login, so Ravi's app calls GET /v1/orders and sees only his shop's orders. If the app also sends shopId, the server checks it against the login; it never trusts it. How that check works is the topic of Who are you and what may you do?. Keep nesting to one level below a top-level resource. Paths like /shops/1042/orders/90412/lines/3 repeat facts the server already knows and give clients two ways to spell one thing.

Shop app Rep app shop from login ?shopId=1042 /v1/orders one order /v1/orders/90412 lives inside links to …/90412/lines /v1/invoices/7731
One collection serves both apps: Ravi's shop comes from his login, a rep filters by shopId. Lines are a sub-resource because they cannot exist without their order. The invoice has its own URL, and the order only links to it. The price list follows the same rule: one per shop, so it lives at /v1/shops/1042/price-list.

Quick check

Accounts staff search unpaid invoices across all shops, and each invoice has its own due date and payment status. Where should invoices live?

Methods by meaning, and cancel as a state change

HTTP defines two properties that clients and proxies rely on. A method reads and changes nothing: GET, HEAD, OPTIONS and the rarely used TRACE. An idempotent method has the same effect whether it runs once or five times: all safe methods, plus PUT and DELETE. POST and PATCH promise neither. These are promises your code must keep. A GET that marks orders as "seen" breaks the first one, and a browser prefetch or a link checker will change data.

PUT replaces the whole resource with the body you send. PATCH changes only the fields you send. The difference bites when Lagoon Digital's app wants to change only the delivery note on an order. If the API offers PUT and the app sends {"deliveryNote": "Back door"}, a server that keeps PUT's meaning replaces the whole order with that body, and every field the body left out is gone. For partial updates, use PATCH with : send the fields to change, and null removes a field. Use PUT when the client really owns the whole thing, for example PUT /v1/shops/1042/delivery-address.

Cancelling an order is not a field edit. It has rules: only an order in PLACED can be cancelled, a cancel releases reserved stock, and it notifies the distribution centre. A PATCH that sets status hides those rules behind a field anyone could set to anything. DELETE is wrong too, because a cancelled order must stay readable for history and audit. Model it as an action on the order: POST /v1/orders/90412/cancel. Then decide what a repeat means. A second cancel on an order that is already cancelled returns 200 with the order, so a retry on a weak connection is harmless. A cancel on an order the DC is picking returns 409 Conflict.

PLACED CANCELLED POST cancel 200 OK again: 200, same DC starts PICKING cancel 409 Conflict not cancellable truck leaves DISPATCHED shop signs DELIVERED invoice is created (its own resource)
The order's states decide which actions are allowed. Cancel works from PLACED and is safe to repeat. Once the distribution centre starts picking, cancel returns 409 with a problem type the app can recognise, instead of a vague error.

Quick check

The Partner app lets a shop change the delivery note while an order is still PLACED. Which request fits best?

Status codes in a fixed order of checks

Lagoon Digital's developers write code like "if 409, refresh the order; if 422, show the message; if 503, retry later". That only works if the same situation always gives the same code. The easiest way to get there is to run the checks in a fixed order, so that when several things are wrong at once, every endpoint reports the same one first.

Valid login? 401 Role may call this? 403 Input well formed? 400 Order visible to you? 404 Allowed in this state? 409 Business rules pass? 422 no 200 / 201 / 202 / 204 429 and 503 can come at any step
Each request runs the same checks top to bottom, and the first one that fails decides the code. So an invalid body for an order the caller cannot see gets 400 on every endpoint, never sometimes 404. Rate limits (429) and overload (503) can stop a request at any point.

What each answer tells the app:

  • 401: no valid login. The app refreshes its token or shows the login screen. 403: logged in, but this role may not call this endpoint, such as shop staff opening an admin report.
  • 400: the request is malformed or breaks the documented schema: a missing lines array, a quantity of -3, an unknown field. The app has a bug or a form check to add.
  • 404: the order does not exist for this caller. Another shop's order also gets 404, so an attacker cannot learn which order numbers exist (Who are you and what may you do?).
  • 409: the request is fine, but the resource's current state forbids it, such as cancelling an order being picked. The app reloads the order and shows its new state.
  • 422: well formed and allowed in this state, but it breaks a business rule: an article that is not on this shop's price list, or an order over the shop's credit limit. The app shows the message to the user.
  • Success: 201 Created with a Location header for a new order; 200 for reads, edits and actions; 204 No Content when there is nothing to return, such as removing a saved basket; 202 Accepted when work continues in the background, such as a rep's CSV export of her 40 shops (Background jobs and idempotency).

The line between 400 and 422 is a team convention, and teams draw it in different places. Ours is "400 for the shape, 422 for the rules". What matters is that you write it down and keep it everywhere. A 500 is never a choice. It means a bug, so it pages someone.

One error shape: problem details

RFC 9457 defines , a standard error body sent with the media type application/problem+json. It has five members, all optional, plus any extensions you add:

  • type: a URI that names the kind of problem, such as https://api.mangrove.example/problems/order-not-cancellable. This is what clients branch on. Point it at a docs page that explains the problem. If you leave it out, it means about:blank: "nothing beyond the status code".
  • title: a short summary of that type, the same every time it occurs.
  • status: the HTTP status code, repeated for convenience.
  • detail: an explanation of this occurrence, for a human: "Order 90412 is being picked at the North DC."
  • instance: a URI for this occurrence, often the request path or an error id.

Clients must never parse detail or title, because you will reword them. For validation errors, add an errors array with one entry per bad field. Each entry has a pointer, a such as #/lines/2/qty, so the app can put the message next to the right input. RFC 9457 uses this pattern in its own example. Add a requestId extension that support staff can search for in the logs. Clients must ignore extension members they do not know, which lets you add more later without breaking anyone.

Quick check

Lagoon Digital's app needs to show "Call your rep" whenever a cancel fails because picking has started. What should the app check?

Pagination, filters and sorting

APIs done right showed why offset pages drift when rows are added or removed, and why OFFSET 100000 is slow. Here we turn the cursor idea into a contract. A partner shop's order list is sorted newest first by placedAt. Many orders share a timestamp, especially on festival mornings, so the sort needs a : placedAt descending, then id descending. The cursor holds both values from the last row of the page, and the next page asks for rows that come strictly after that pair.

Make the cursor : encode it as base64url text that clients store and send back, and document that they must not build or read it. If Lagoon Digital's code starts building cursors from id values, you can never add a field to the cursor or change the sort without breaking the app. Opaque does not mean secret. Anyone can decode base64, so the server must validate every cursor it receives and answer 400 if it is not one it made, or was made for another sort.

The rest of the contract:

  • Limits: limit defaults to 25 and has a maximum of 100. A larger value is a 400, so a mistake shows up in testing instead of loading 50,000 rows on a phone.
  • Response: {"data": [...], "nextCursor": "..."}, with nextCursor: null on the last page. Leave out a total count by default. Counting every matching row on each page request means reading all of them, and an infinite list does not need it.
  • Filters: a fixed allowlist of query parameters. status=PLACED,PICKING means "either". Date ranges are half-open, placedFrom included and placedTo excluded, so consecutive days never overlap.
  • Sorting: sort=-placedAt, where a minus means descending. Allow only sorts that an index serves, and keep the sort and filters the same for every page of one list.

Formats and changes that do not break anyone

A few format rules prevent whole classes of bugs. Money is an integer number of cents plus a currency: "totalCents": 184250, "currency": "USD". JSON numbers become floating point in JavaScript, where 0.1 + 0.2 is 0.30000000000000004, and a wholesale invoice that is off by a cent will not reconcile. Timestamps are ISO 8601 in UTC with a Z, as profiled by : "placedAt": "2026-10-20T08:15:00Z". The app converts to the shop's local time for display. A delivery day is a date, not a time, so it is "deliveryDate": "2026-10-22". IDs are strings in JSON, even when the database uses bigint, because JavaScript numbers lose precision above about 9 quadrillion and because you may change the id format later.

The Partner app on a shop's phone may stay on an old version for months, so /v1 must keep working for every version still in use. Changes are safe when old clients cannot notice them: a new optional response field, a new endpoint, a new optional filter. Changes that break old clients need a new version or a long migration: removing or renaming a field, changing a type, making an optional input required, or tightening validation on input that used to pass. values are a trap. Adding ON_HOLD to status looks additive, but an app with a strict switch statement will crash on it. Write into the contract from day one that clients must treat unknown values as a generic state, such as "Processing", and test that they do. How to announce and retire old versions comes in Testing and documenting the API.

Build it

We will write the contract for partner orders: the endpoint table, example responses, the error bodies, the cursor helper and the query behind it. In Your first NestJS service you turn it into code.

  1. Write the endpoint table

    Every path, the method, what it does and the codes it can return. Review this with Lagoon Digital before anyone writes a controller.

    Output
    GET    /v1/orders                     list (filters, sort, cursor)    200 400
    POST   /v1/orders                     place an order                  201 400 422
    GET    /v1/orders/{orderId}           read one order                  200 404
    PATCH  /v1/orders/{orderId}           edit note or date while PLACED  200 400 404 409
    POST   /v1/orders/{orderId}/cancel    cancel while PLACED             200 404 409
    GET    /v1/orders/{orderId}/lines     lines of one order              200 404
    GET    /v1/shops/{shopId}/price-list  prices for one shop             200 404
    GET    /v1/invoices/{invoiceId}       read one invoice                200 404
    POST   /v1/order-exports              CSV of many shops' orders       202 400
    GET    /v1/order-exports/{exportId}   export status and download      200 404
    Every endpoint can also return 401, 403, 429 and 503.
    
  2. Show what success looks like

    A created order answers 201 with a Location header, so the client knows its URL without building it. The body is the order as a GET would return it.

    Output
    HTTP/1.1 201 Created
    Location: /v1/orders/90412
    Content-Type: application/json
    
    {
      "id": "90412",
      "shopId": "1042",
      "status": "PLACED",
      "placedAt": "2026-10-20T08:15:00Z",
      "deliveryDate": "2026-10-22",
      "totalCents": 184250,
      "currency": "USD",
      "lineCount": 14,
      "invoiceId": null
    }
    

    A list page carries the data and the cursor for the next page, nothing else.

    JSON
    {
      "data": [
        { "id": "90412", "status": "PLACED", "placedAt": "2026-10-20T08:15:00Z", "totalCents": 184250 },
        { "id": "90398", "status": "PICKING", "placedAt": "2026-10-20T07:58:00Z", "totalCents": 61200 }
      ],
      "nextCursor": "WyItcGxhY2VkQXQiLCIyMDI2LTEwLTIwVDA3OjU4OjAwWiIsIjkwMzk4Il0"
    }
    
  3. Write the two error bodies every client will meet

    A validation failure with field pointers, then a state conflict. Both use Content-Type: application/problem+json.

    JSON
    {
      "type": "https://api.mangrove.example/problems/validation",
      "title": "The request is not valid.",
      "status": 400,
      "detail": "2 fields need attention.",
      "instance": "/v1/orders",
      "requestId": "req_8f2c1e",
      "errors": [
        { "pointer": "#/lines/2/qty", "detail": "must be an integer from 1 to 999" },
        { "pointer": "#/deliveryDate", "detail": "must be a date like 2026-10-22" }
      ]
    }
    
    JSON
    {
      "type": "https://api.mangrove.example/problems/order-not-cancellable",
      "title": "This order can no longer be cancelled.",
      "status": 409,
      "detail": "Order 90412 has been picked at the North DC since 10:42 UTC.",
      "instance": "/v1/orders/90412/cancel",
      "requestId": "req_91aa07",
      "orderStatus": "PICKING"
    }
    
  4. Encode and decode the cursor

    The cursor is a small JSON array, encoded as base64url so it is safe in a query string. Decoding checks everything, because the client could send anything. base64url needs Node 16 or later.

    TypeScript
    // src/common/cursor.ts
    export interface OrderCursor {
      sort: string;      // the sort it was made for, e.g. '-placedAt'
      placedAt: string;  // sort key of the last row, ISO 8601 UTC
      id: string;        // tie-breaker: the last row's id
    }
    
    export class BadCursorError extends Error {}   // the API maps this to a 400
    
    export function encodeCursor(c: OrderCursor): string {
      const json = JSON.stringify([c.sort, c.placedAt, c.id]);
      return Buffer.from(json, 'utf8').toString('base64url');
    }
    
    export function decodeCursor(raw: string, sort: string): OrderCursor {
      let parts: unknown;
      try {
        parts = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
      } catch {
        throw new BadCursorError('The cursor is not one this API made.');
      }
      if (!Array.isArray(parts) || parts.length !== 3 ||
          !parts.every((p) => typeof p === 'string')) {
        throw new BadCursorError('The cursor is not one this API made.');
      }
      const [s, placedAt, id] = parts as string[];
      if (s !== sort) {
        throw new BadCursorError(`The cursor was made for sort=${s}, not sort=${sort}.`);
      }
      if (Number.isNaN(Date.parse(placedAt)) || !/^\d{1,19}$/.test(id)) {
        throw new BadCursorError('The cursor is not one this API made.');
      }
      return { sort: s, placedAt, id };
    }
    

    A quick try in Node shows the round trip and both kinds of rejection:

    TypeScript
    const next = encodeCursor({ sort: '-placedAt', placedAt: '2026-10-20T08:15:00.000Z', id: '90412' });
    console.log(next);
    console.log(decodeCursor(next, '-placedAt'));
    for (const [raw, sort] of [[next, 'deliveryDate'], ['hello', '-placedAt']]) {
      try { decodeCursor(raw, sort); } catch (e) { console.log(`${e.constructor.name}: ${(e as Error).message}`); }
    }
    
    Output
    WyItcGxhY2VkQXQiLCIyMDI2LTEwLTIwVDA4OjE1OjAwLjAwMFoiLCI5MDQxMiJd
    {
      sort: '-placedAt',
      placedAt: '2026-10-20T08:15:00.000Z',
      id: '90412'
    }
    BadCursorError: The cursor was made for sort=-placedAt, not sort=deliveryDate.
    BadCursorError: The cursor is not one this API made.
    
  5. Write the query and the index that serve it

    The row comparison (placed_at, id) < (…, …) means "strictly after this pair" in a newest-first list. The index has the same columns in the same direction, so Postgres reads exactly the rows of one page. Ask for limit + 1 rows: if the extra row comes back, there is a next page.

    SQL
    CREATE INDEX partner_order_shop_placed_idx
      ON partner_order (shop_id, placed_at DESC, id DESC);
    
    -- page after the cursor, limit 25 (+1 to know if there is more)
    SELECT id, status, placed_at, total_cents
    FROM partner_order
    WHERE shop_id = $1
      AND (placed_at, id) < ($2::timestamptz, $3::bigint)
    ORDER BY placed_at DESC, id DESC
    LIMIT 26;
    
    Output
    Limit
      ->  Index Scan using partner_order_shop_placed_idx on partner_order
            Index Cond: ((shop_id = 1042) AND (ROW(placed_at, id) < ROW(...)))
    

    The plan shows the whole condition used as an index condition, so page 500 costs the same as page 1. Prisma has no row comparison in where; the same filter written as placedAt < X OR (placedAt = X AND id < Y) gives the same rows, so check its plan with EXPLAIN on your data (Reading query plans).

Bring it to your team

Ask these while you review the endpoint table with Lagoon Digital, before any code is written.

  • "Which resources can exist without their parent? Is anything nested that people will search across parents?"
  • "For each endpoint, which status codes can it return, and what should the app do for each one?"
  • "Which type URIs will the app branch on, and is each one documented?"
  • "What makes this list's sort unique? Which index serves it, and what is the maximum page size?"
  • "If we add a status value next month, what does the current app show?"
  • "Are all money fields integer cents and all timestamps UTC with a Z?"

Red flags

  • 200 OK with "success": false, or a 500 for something the client did.
  • App code that compares error messages as text.
  • A status field anyone can set with PATCH, instead of actions with rules.
  • Cursors the client builds itself, or a sort with no unique tie-breaker.
  • Prices as floats in dollars, or timestamps with no time zone.

Practice

Page through orders without losing ties

Write nextPage(orders, limit, after). orders is a shop's order list, already sorted newest first: by placedAt descending, then id descending. after is null for the first page, or the cursor { placedAt, id } of the last order the app showed. Return { data, nextCursor }: up to limit orders that come strictly after the cursor, and the cursor of the last returned order, or null when no orders are left after this page. If limit is not an integer from 1 to 100, throw an error whose message contains limit. All placedAt values are UTC strings in the same format, so you can compare them with < and ===.

JavaScript exerciseRuns in your browser with tests
function nextPage(orders, limit, after) {
  // orders: newest first (placedAt desc, then id desc)
  const rest = after ? orders.filter((o) => o.placedAt < after.placedAt) : orders;
  const data = rest.slice(0, limit);
  const last = data[data.length - 1];
  return { data, nextCursor: { placedAt: last.placedAt, id: last.id } };
}
Ctrl + Enter runs
Show a solution
JavaScript
function nextPage(orders, limit, after) {
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
    throw new Error('limit must be an integer from 1 to 100');
  }
  // strictly after the cursor in a newest-first list: older time,
  // or the same time with a smaller id (the tie-breaker)
  const isAfter = (o) =>
    o.placedAt < after.placedAt ||
    (o.placedAt === after.placedAt && o.id < after.id);
  const rest = after ? orders.filter(isAfter) : orders;
  const data = rest.slice(0, limit);
  const hasMore = rest.length > limit;
  const last = data[data.length - 1];
  return { data, nextCursor: hasMore ? { placedAt: last.placedAt, id: last.id } : null };
}
Pass the exercise to finish this task.

Answer five partner-order situations

For each situation, write the status code, a type name if it is an error (such as order-not-cancellable), and one sentence on what the Partner app should do. (1) A shop places an order with a line for an article that is not on its price list. (2) Ravi cancels order 90412 a second time after the first cancel succeeded but the reply was lost. (3) Shop staff open /v1/orders/90377, which belongs to another shop. (4) A rep asks for a CSV of last month's orders for her 40 shops. (5) The app sends limit=500.

  • Situation 1 is a business rule on well-formed input (422), not a schema error
  • Situation 2 returns 200 with the cancelled order, because cancel is safe to repeat
  • Situation 3 returns 404, so the other shop's order numbers do not leak
  • Situation 4 returns 202 with a Location for the export, and 5 returns 400 with a pointer to the limit
  • Each error names a type the app can branch on, never a message to parse
Show a model answer

(1) 422, type article-not-on-price-list, with an errors entry pointing at #/lines/3/articleId; the app marks that line and suggests removing it. (2) 200 with the order in CANCELLED; the app shows it as cancelled, exactly as after the first reply. (3) 404, type not-found; the app shows "Order not found". (4) 202 Accepted with Location: /v1/order-exports/77; the app polls that URL until the status is READY and then offers the download. (5) 400, type validation, pointer limit with "must be at most 100"; this is an app bug, so it shows a generic error and logs the requestId.

Check yourself

A rep's app lists orders for one shop with sort=-placedAt. The rep switches the sort to deliveryDate but the app keeps sending the old cursor. What should the API do?

Lagoon Digital asks for an endpoint that marks an order as "seen" by the shop. A developer proposes doing it inside GET /v1/orders/{orderId}. What is the main problem?

Which planned change to the v1 order response can ship without risk to Partner app versions already on shop phones?

Flashcards

Open my review

Key takeaways

  • Give each resource one URL. Nest only what cannot exist without its parent; use filters for everything people search across.
  • Methods are promises: GET is safe, PUT and DELETE are idempotent, PATCH is partial, and state changes with rules are explicit actions.
  • Run checks in a fixed order so the same situation always gets the same code, and answer every error with one problem details shape whose type the app branches on.
  • Cursors are opaque, include a unique tie-breaker, are validated on the way in, and come with a limit cap and a matching index.
  • Integer cents, UTC timestamps, string ids and tolerant enums keep old app versions working while v1 grows.

Sources

Episode transcriptThe rejection slip everyone can read · about 13 minutes · tap any line to play from there
  1. NadiaImran, picture Ravi in his corner shop at nine in the morning. He taps cancel on an order in the Partner app. What does he see?
  2. ImranToday? Something went wrong. Then he taps again, gets the same message, and phones his sales rep.
  3. NadiaAnd the order had already been picked at the North DC, so the cancel could never work. The app had no way of knowing that.
  4. ImranSo this episode is about the API telling the app what actually happened.
  5. NadiaIt's about designing the contract before anyone writes code. Resources, methods, status codes, one error shape, and pagination. Lagoon Digital builds the Partner app against it, so it has to be predictable.
  6. NadiaStart with a picture. Think of the order desk at a wholesale market. Every trader fills in the same form: shop number, then one row per item. The clerk stamps it with a ticket number, and from then on that number is the order's only name.
  7. ImranAnd when the form is wrong?
  8. NadiaShe doesn't shout no. She hands back a rejection slip with fixed boxes. What kind of problem, which row, what to fix. Once you've seen one slip, you can read them all.
  9. ImranAnd I'm guessing the slip is the error format, and the ticket number is the order's URL.
  10. NadiaExactly. One more detail. When a trader asks for his orders, the clerk says here are the next twenty-five after ticket nine zero four one two. She never says page thirty-seven. We'll come back to why.
  11. NadiaFirst, the nouns. For the Partner app we need shops, each shop's price list, orders, the lines inside an order, and invoices. The question is which ones sit inside which.
  12. ImranI'd put everything under the shop. Shop ten forty-two, its orders, its invoices. That's how Ravi thinks about it.
  13. NadiaA reasonable guess, and it breaks for your sales reps. Each rep looks after about forty shops and wants every order those shops placed today. If orders only live under a shop, that's forty calls for one screen.
  14. ImranAnd the same order would have two addresses, one under the shop and one somewhere else.
  15. NadiaRight. So there are two tests. Can the thing exist without its parent? And do people search it across parents? An order line can't exist without its order, so it's a sub-resource, inside the order. Orders are searched across shops, so they're a top-level collection, and the shop becomes a filter.
  16. NadiaIf you can see your screen, the first diagram shows this. If not, picture two phones at the top, the shop app and the rep app, both calling the same orders collection. Ravi's shop comes from his login. The rep adds a shop ID filter.
  17. ImranAnd below that, one order with its lines inside it.
  18. NadiaThe invoice sits off to the side with its own address, because it has its own life. It's sent, paid or overdue, and accounts staff look it up by number. The order just links to it.
  19. ImranNow the verbs. What matters beyond GET reads and POST creates?
  20. NadiaTwo promises that HTTP makes. A safe method changes nothing, like GET. An idempotent method has the same effect if it runs once or five times. GET, PUT and DELETE are idempotent. POST and PATCH promise neither.
  21. ImranAnd PUT versus PATCH? My team mixes them up all the time.
  22. NadiaPUT replaces the whole thing with the body you send. PATCH changes only the fields you send. If the app wants to change just the delivery note and uses PUT, a server that keeps PUT's meaning wipes every other field.
  23. ImranSo for small edits, PATCH. Now the big one: cancel. Isn't that just setting the status field to cancelled?
  24. NadiaThat's the trap. Cancel has rules. Only a placed order can be cancelled. It releases reserved stock and tells the distribution centre. If status is a field anyone can patch, those rules hide behind it.
  25. NadiaDeleting is wrong too, because a cancelled order must stay readable for history and audit. So cancel is an action. You post to the order's cancel address.
  26. NadiaThe second diagram is the order's life as a set of states. Placed, then picking, then dispatched, then delivered. Cancel is an arrow from placed to cancelled. From picking, the cancel arrow ends in a box that says four oh nine, conflict.
  27. ImranWhat if Ravi cancels, the reply gets lost on his weak signal, and he cancels again?
  28. NadiaWe decide that up front. A second cancel on an order that's already cancelled returns two hundred with the cancelled order. So a retry is harmless.
  29. ImranGood. Status codes next. Lagoon's developers want to write code like, if four oh nine, reload the order. If four twenty-two, show the message.
  30. NadiaThat only works if the same situation always gives the same code. The trick is to run the checks in a fixed order, so the first failed check decides.
  31. NadiaThe third diagram is a ladder. Valid login? If not, four oh one. Is your role allowed to call this? If not, four oh three. Is the input well formed? If not, four hundred. Is this order visible to you? If not, four oh four.
  32. NadiaThen two more. Is the action allowed in the order's current state? If not, four oh nine. Do the business rules pass? If not, four twenty-two. Only then do you get a success code.
  33. ImranWait, why four oh four for the order? If it belongs to another shop, isn't that four oh three, forbidden?
  34. NadiaFour oh three would tell an attacker that the order exists. So another shop's order is reported as not found. The next lessons on login and permissions go deeper on that.
  35. ImranAnd four hundred versus four twenty-two? I've seen teams argue about that for an hour.
  36. NadiaIt's a convention, and teams draw the line in different places. Ours is four hundred for the shape, four twenty-two for the rules. A quantity of minus three is shape. An article that isn't on this shop's price list is a rule. Write it down and keep it everywhere.
  37. ImranAnd when it works?
  38. NadiaTwo oh one for a new order, with a location header giving its address. Two oh two when work continues in the background, like a rep's export. A five hundred is never a choice. It means a bug.
  39. ImranNow the rejection slip. What goes in the error body?
  40. NadiaWe use a standard called problem details, RFC nine four five seven. It has a type, a title, a status, a detail and an instance. The type is a link that names the kind of problem, like order not cancellable.
  41. ImranAnd the app reads the detail text to decide what to show?
  42. NadiaNever. The detail is for humans, and you'll reword it next month. The app branches on the type. For validation errors you add a list of errors, each with a pointer to the exact field, like the quantity of the third line.
  43. NadiaThat's the Mangrove example in the lesson. The old API answered most failures with two hundred and a success flag set to false. So the app showed something went wrong for everything, and when Ravi's cancel hit an order being picked, the server crashed with a five hundred.
  44. ImranAnd the app retried four times, and Ravi phoned his rep. Roughly sixty-two percent of partner orders go through the app today, and I want seventy-five.
  45. NadiaWith the new contract, that moment returns four oh nine with the order not cancellable type. The app shows: picking has started, your rep can still change the delivery. No retries, no guessing.
  46. ImranBack to the clerk who says next twenty-five after ticket nine zero four one two. That's cursor pagination, from the APIs done right lesson.
  47. NadiaYes, and here we turn it into a contract. A shop's list is newest first by the time it was placed. But on a festival morning, many orders share the same timestamp. So the sort needs a tie-breaker, the order ID.
  48. ImranWhat breaks without it?
  49. NadiaIf the cursor only says after eight fifteen, it can't say where to resume inside a group of orders that all say eight fifteen. Some of them fall between pages and Ravi never sees them.
  50. ImranSo the cursor holds the time and the ID.
  51. NadiaPlus the sort it was made for. And we encode it, and the contract says clients must not read or build it. That's what opaque means. If Lagoon builds cursors from IDs, we can never change the sort without breaking the app.
  52. ImranOpaque sounds like secret.
  53. NadiaIt isn't. Anyone can decode it. So the server checks every cursor it gets and answers four hundred if it isn't one it made, or if it was made for a different sort.
  54. NadiaThe page size defaults to twenty-five, with a maximum of a hundred. And there's no total count by default, because counting every matching row on every page is expensive.
  55. ImranThen the formats.
  56. NadiaMoney is a whole number of cents plus a currency. JavaScript numbers are floating point, so dollars with decimals drift. Timestamps are in UTC with a Z at the end, and the app shows local time. A delivery day is just a date. IDs travel as text, because very large numbers lose precision in JavaScript.
  57. ImranAnd changes? Ravi updates his app maybe twice a year.
  58. NadiaAdding things is safe: a new optional field, a new endpoint, a new optional filter. Removing, renaming, changing a type or making an input required breaks old apps. And watch enum values. Adding an on hold status looks harmless, but an app with a strict switch crashes on it.
  59. ImranSo the contract has to say from day one: if you see a status you don't know, show it as processing.
  60. NadiaAnd test that the app does.
  61. NadiaIn the Build it section, you write the contract down. First an endpoint table: every path, its method, what it does and which codes it can return. Then example bodies for a created order and a page of orders.
  62. NadiaThen the two error bodies every client will meet: a validation error with field pointers, and the cancel conflict. Then a small TypeScript helper that encodes and decodes the cursor and rejects anything it didn't make.
  63. NadiaAn index on shop, then placed time, then ID, both newest first. The query asks for rows strictly after the pair in the cursor, and the plan shows Postgres reading only that page. Page five hundred costs the same as page one.
  64. ImranHere's what I'll ask in our next review with Lagoon. For each endpoint, which codes can it return and what does the app do for each? Which problem types does the app branch on, and are they documented?
  65. ImranAnd what makes each list's sort unique, and which index serves it?
  66. NadiaNow a question for you, Imran. A rep switches her list from newest first to delivery date, but the app keeps sending the old cursor. What should the API do? Take a second.
  67. ImranReturn the first page of the new sort?
  68. NadiaThat hides an app bug, and the rep sees repeated orders. The cursor only means something in the sort it was made for. So it's invalid input, a four hundred, and the app starts again from the first page.
  69. ImranLet me sum up. One, give each thing one address, nest only what can't live alone, and use filters for what people search across.
  70. ImranTwo, check things in a fixed order so the same situation always gets the same code, and send one error shape whose type the app reads.
  71. ImranThree, cursors with a tie-breaker, a page size cap, and an index behind them.
  72. NadiaGood. I'd add one nuance to the first point. Cancel and other changes with rules are actions, never a status field anyone can patch.
  73. NadiaNext time, we open an editor and turn this contract into a real NestJS service, with validation on every input.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

API client with retries

A fetch wrapper with a timeout, retries with growing waits, and errors that say what failed.

Open in the Code Lab
Next stopYour first NestJS service
Was this lesson helpful?
Questions and discussion

Loading the discussion…