EngineerGENERALDOMAIN-SPECIFICILLUSTRATIVE

From Cart.addItem() to POST /cart/items

The cart lives on the server; the browser cannot call a function in another process. Each operation becomes a request: the operation's inputs are the body, its output is the response, its errors are status codes, and its rules are re-checked because the browser cannot be trusted. API Design is one link away.

The moveWorked exampleNext questions

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

The cart works as five functions on the server. How does a browser ask for addItem — and how do you derive the endpoints from the operations instead of inventing them?

The situation

You moved the cart to the server for checkout. Now the Add button in the page has nothing to call: addItem is a function in a process the browser cannot reach. You know the answer is "an API" and you have seen REST, and you start naming routes — /addToCart, /cart/add, /api/v1/carts/{id}/items — without being sure which is right or why it matters.

The reflex

Name a route per function. /addToCart, /removeFromCart, /changeQuantity, /getCart, /clearCart — one URL per operation, all POST, each taking whatever the function took. It maps directly and it works this afternoon.

Why it stalls

The routes work and encode nothing. /addToCart says what it does but not what it is; a client that wants to remove an item has to know the name you invented, and a second engineer invents /cart/delete beside it. Nothing about the URL says that an item is a thing inside a cart.

What the reflex produces — and fails to produce
  • The routes work and encode nothing. /addToCart says what it does but not what it is; a client that wants to remove an item has to know the name you invented, and a second engineer invents /cart/delete beside it. Nothing about the URL says that an item is a thing inside a cart.
  • Every response is 200 with a body that sometimes says error: true. The rule "quantity must be positive" that the function rejected with a thrown error is now a string the client has to parse, and the browser shows the cart as updated because the status said success.
  • The server trusts the body. The catalog check and the positive-quantity check happen in the browser because that is where the form is, and the endpoint writes whatever arrives — including a quantity of −3 from a request nobody typed into a form (Rules That Live Elsewhere).
  • The double-click adds two laptops. addItem was called twice because the request was sent twice, and the API has no way to know the second one was a repeat — a failure the in-process function never met, and the reflex never asks about.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • Derive the endpoint from the operation contract you already wrote. Every operation has inputs, a state it changes, an output and errors. The inputs become the request body or path; the thing whose state changes is the resource in the URL; the output is the response body; the errors are the status codes. There is nothing to invent, only to translate.
  • Name the resource, not the verb. The cart is a resource; an item inside it is a resource within it. "Add item" creates an item inside the cart, so it is POST /cart/items; "change quantity" modifies one item, so it is PATCH /cart/items/:productId; "remove" is DELETE on the same path. The HTTP verb carries the operation and the URL carries the thing — which is why a second engineer can guess the remove route without asking.
  • Move the rules to the boundary and keep them in the function. The server cannot see the form; it sees bytes. Every rule the function enforces — unknown product, quantity ≤ 0 — is enforced again at the endpoint, and its rejection is a status code with the rule named, not a 200 with a flag. The concept record says this for the catalog check: "the API layer repeats this check because it cannot trust the browser".
  • Ask the question the function never had to: what happens when the same request arrives twice? POST /cart/items twice for a laptop legitimately means two laptops — unless it was one click retried. Decide whether the add is repeatable by design or needs a key that makes the retry a no-op (Duplicate Requests).

The contract, translated row by row

Nothing in the table below was invented. Each row is one operation from the concept record with its contract read in the API's vocabulary: the thing whose state changes becomes the path, the inputs become the body, the output the response, the error list the status codes. The record's own api entries are quoted where they exist.

OperationResource → verb + pathBodyResponseErrors → status
Add iteman item within the cart → POST /cart/items{ productId, quantity }the cart, or 400 with the rule that rejected itunknown product → 400; quantity ≤ 0 → 400
Change quantityone item → PATCH /cart/items/:productId{ quantity }the cartnot in cart → 404; quantity < 0 → 400; 0 → the entry is removed
Remove itemone item → DELETE /cart/items/:productIdthe cart; success even when absent, matching the no-op choicenone
View items + totalthe cart → GET /cart{ items: [{ productId, quantity }], total }a product whose price is unknown → surfaced, not silently 0
Clearthe cart's items → DELETE /cart/itemsthe empty cartnone
The handler is a translation around the unchanged function
1on POST /cart/items with body:
2 productId, quantity = parse(body) -- bytes, not a form
3 if not catalog.has(productId): respond 400 "unknown product"
4 if quantity <= 0: respond 400 "quantity must be positive"
5 cart = load(owner of this request)
6 cart = addItem(cart, productId, quantity) -- the same function, rules included
7 save(cart)
8 respond 200 cart

The two checks appear here and inside addItem. That is not a mistake to refactor away: the endpoint cannot trust the request, and the function cannot trust that every caller is the endpoint.

Why the resource beats the verb

The reflex's routes and the derived ones both work. The difference is what a reader can infer. Below is the same pair of operations both ways, and the reason the second is better is not taste — it is that the URL now carries information the client would otherwise have to be told.

Add and remove, two ways
A route per function
POST /addToCart      { productId, quantity }
POST /removeFromCart { productId }
POST /getCart
every response: 200 { ok: true | false, message }
A resource per thing
POST   /cart/items             { productId, quantity } → 200 cart | 400 rule
DELETE /cart/items/:productId                       → 200 cart
GET    /cart                                        → 200 { items, total }

In the second form the path says what exists (a cart, items within it) and the verb says what happens to it, so the remove route is guessable from the add route and the status code is machine-readable without parsing a message. The first form encodes the same operations as opaque names, which is why every client needs a document and every error needs a parser.

The question the function never faced

In-process, addItem was called exactly as many times as the code called it. Over a network, a request can be sent, lost, retried and delivered twice. The trace below follows one retried add through the five stops and shows where the API must decide something the function could not.

POST /cart/items { laptop, 1 } — sent twice by one click
  1. inputTwo identical requests, the second a retry after a timeout; the cart holds [ ].
  2. lookupRequest one: no entry for laptop. Request two: the entry from request one, quantity 1.
  3. branchRequest one takes the "append" branch. Request two takes the "increase" branch — the rule "one entry per product" is doing its job, and it cannot tell a second intended add from a retry.
  4. mutationAfter both: [ Laptop × 1 ] → [ Laptop × 2 ].
  5. outputTwo 200 responses, both the cart; the shopper sees quantity 2 for one click. The decision this forces: accept and display, or attach a client request id so the second request is recognised and returns the cart unchanged — the record lists this as a later modification, and API Design teaches the key.

The implementation ladder

Concept, examples, pseudocode, code, tests, production — for the concept this lesson is about. Code is the fourth tab, not the first.

concept Shopping Cart beginner
Build it step by step →

Shopping Cart = A temporary collection of products the user intends to purchase, held between browsing and checkout.

Identity, ownership, lifetime
  • Does a cart have identity? Yes, weakly. Two carts with the same items are still two carts, because each belongs to someone and will become a different order. It needs an id once it leaves memory; in memory the variable is the identity.
  • Who owns it? A shopper — a logged-in user or an anonymous session. The owner is part of the state because "my cart" has to be findable again.
  • How long does it exist? From the first add until checkout or abandonment. Whether it survives a reload, a closed browser or a login is not a property of the concept; it is a persistence decision made later, and each answer changes where the cart lives.
  • Should it survive reload? Usually yes for a store, usually no for a demo. V1 in memory says no; V2 browser storage says yes on one device; V3 server storage says yes everywhere the user is logged in.
  • Should it survive login? Only if an anonymous cart and a logged-in cart are merged — a rule that does not exist in V1 and appears as a modification later.
State it must remember
  • itemscollection of CartItemkeepThe cart is its items; without them nothing else means anything.
  • items[].productIdidkeepThe reference to what is being bought. The catalog owns the product; the cart only points at it.
  • items[].quantityinteger > 0keepTwo laptops is one entry with quantity 2, not two entries — the rule "one entry per product" needs a quantity to hold.
  • owneruser id or session iddependsSo the cart can be found again by the person it belongs to.
  • items[].productNamestringderiveIt would be convenient to render the cart without a catalog lookup.
  • items[].pricemoneydependsThe total needs a price per item.
  • totalmoneydropEvery screen shows the total.
  • currencycodedependsPrices need a currency to be added.
  • createdAttimestampdropAbandoned carts might be expired or emailed about.
Operations
  • update Add item the updated cart
  • delete Remove item the updated cart
  • update Change quantity the updated cart
  • read View items the list of entries — product id and quantity — for rendering
  • domain Calculate total the sum of price × quantity over the entries
  • delete Clear cart the empty cart
Rules that must always hold
  • Every quantity is greater than zero.
  • One logical entry per product.
  • The total is never negative.
  • An unknown product cannot be added.
  • Quantity cannot exceed available stock — if inventory is enforced here.

How to do it

Most important first.

  • For each operation in the concept record, fill one row: resource, verb, path, body, response, error codes. If a row has no resource, you have found an action (Resource or Action? in API Design decides what to do with it).
  • Write the server handler as: parse → re-check the rules → call the pure function → save → respond. The pure function is unchanged; the rule checks appear twice on purpose (The Three Validations).
  • Map each error in the operation's error list to a status code, and put the rule that rejected it in the response body.
  • Decide the retry behaviour of every state-changing endpoint before the frontend sends its first request; a double-click is the cheapest failure injection there is.
  • Keep the API out of the cart's tests. The examples became function tests; the endpoint tests check translation — body in, status out — not the rules again.

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • Add item, translated from its contract. Inputs: productId, quantity (default 1) → body { productId, quantity }. Changes: items → the resource is an item within the cart → POST /cart/items. Output: the updated cart → response the cart. Errors: unknown product, quantity ≤ 0 → 400 with the rule that rejected it; the record's own row says exactly this: "the cart, or 400 with the rule that rejected it".
  • Remove item: DELETE /cart/items/laptop. The function chose no-op when the product is absent; the API keeps the same choice and returns success for removing what is not there, because the caller wanted an absent item and has one — the record notes this matches the domain's no-op choice. An API that returned 404 here would be encoding a different rule than the function.
  • View: GET /cart, responding { items: [{ productId, quantity }], total }. Total is derived, not stored, in the function — and the response computes it too, because the browser needs it and should not recompute prices it does not own (Derived vs Stored).
  • The double-click. Two POST /cart/items { laptop, 1 } within a second. Under the record's rules, the second one increases the quantity to 2, which is correct for two intended adds and wrong for one retried click. The decision recorded: V3 accepts the duplicate and shows the quantity so the shopper can fix it; a client-generated request id that makes the retry a no-op is the modification for later, and Idempotency is where it is learnt.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • Every endpoint can be traced back to a row of the operation contract — you can say which input became which field and which error became which status.
  • A colleague who knows the cart's operations can guess the route for one they have not seen.
  • The pure functions are unchanged and the rule checks appear at the boundary as well; a request with quantity −3 is refused with the rule named.
  • You know what your state-changing endpoints do when called twice, and it was a decision.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?For this operation, what is the resource whose state changes, and what are the inputs, output and errors I already wrote down?
  • ?Which rules does the server have to re-check because it cannot see where the request came from?
  • ?What should this endpoint do when the same request arrives twice?
  • ?Which operations are actions that do not fit a resource verb, and how does the API domain model those?

What can go wrong

How the move itself fails
  • The API is designed before the operations are. Routes are invented from imagined screens, and the cart grows an endpoint for "move to wishlist" that no operation supports; the API becomes the specification instead of the translation.
  • The rules move to the endpoint and leave the function. addItem now trusts its caller, the tests from the examples still pass, and the next caller — a background job merging carts — inserts a zero quantity because only the HTTP layer checked.
  • Resource purity becomes a religion. "Merge the anonymous cart at login" does not fit POST/PATCH/DELETE on an item, and an afternoon is spent contorting it; the API domain's action pattern exists for exactly this.
  • Every response is the whole cart because the first one was. A remove returns the cart; fine. A GET on a cart with a hundred items returns every price, computed each time, and nobody measured whether the client needed it.
What the move costs
  • Resource-shaped endpoints take a translation step that the route-per-function reflex skips; on a two-endpoint internal tool the reflex is cheaper and no worse.
  • Re-checking the rules at the boundary duplicates logic on purpose, and the two copies can drift; the shared-library option in Where Should This Code Live? is the usual answer and has its own cost.
  • Deciding retry behaviour for every mutating endpoint is real work before the first request is sent, and most endpoints on most stores never see a meaningful retry.
Misreads
  • "REST means one URL per table." It means the URL names the thing and the verb names the operation; the cart's item resource has no table on the browser-storage rung and is still the right shape.
  • "The frontend validates, so the server needn't." The server sees requests, not forms. Anything a form checks can be sent without the form; the server's check is the one that counts (Parse, Validate, Authorize, Process in Security).
  • "An API is where the cart's logic lives now." The logic lives in the five functions; the API translates. When the API framework changes, the functions and their tests do not.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • GENERALTranslating an operation contract — inputs, state changed, output, errors — into a request, resource, response and status applies to any concept exposed over a network, HTTP or otherwise.
  • DOMAIN-SPECIFICA public API consumed by strangers pays for resource shape and strict status codes; an internal tool with one client owned by the same team can use route-per-function and lose little — the translation discipline matters in proportion to the number of callers.
  • ILLUSTRATIVEThe endpoints, bodies and the double-clicked laptop are illustrative renderings of the cart record's api entries; no real service is described.

Where the depth lives

This domain asks the question and hands the answer off by name.