OperationsGENERALDOMAIN-SPECIFICCONTESTEDILLUSTRATIVE

Pure Logic First

Cart state plus an operation gives a new cart state. Get that right as a plain function in memory — no database, no network, no UI — because every one of those wraps behaviour that must already work, and each one added first makes the behaviour harder to see and impossible to test alone.

The moveWorked exampleNext questions▶ Cart Lab

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 operations are analysed. Should the first implementation be an endpoint, a component, a table — or a function that takes a cart and returns a cart — and what does the order cost?

The situation

You have the six operations and you want to see the cart working. "Working" in your head means a page with an Add button that hits an endpoint that writes a row. So you start with the route: POST /cart/items, read the body, open a database connection, insert. Three hours in you are debugging a connection string and no cart logic exists yet.

The reflex

Build the whole path so it is real. A function nobody can click is not a cart; a route with a database behind it is. Start at the endpoint because that is where the request arrives, and put the logic inside the handler where the data is.

Why it stalls

The logic is inside a handler that needs an HTTP request and a database to run, so the first test of "add the same product twice" requires a server, a schema and a client. Nobody writes that test; the duplicate-row bug is found by a shopper.

What the reflex produces — and fails to produce
  • The logic is inside a handler that needs an HTTP request and a database to run, so the first test of "add the same product twice" requires a server, a schema and a client. Nobody writes that test; the duplicate-row bug is found by a shopper.
  • The rules are entangled with the transport. "Quantity must be positive" is checked by looking at req.body.quantity and answering res.status(400); the same rule cannot be reused by the CLI, the merge-at-login job or the unit test.
  • Three hours went into the connection string, the middleware and the schema — engineering — and the implementation, the thing that decides what a cart does, does not exist. It looks like most of the work is done because most of the files exist.
  • When the logic is finally written it is written in the database's terms (an UPSERT with ON CONFLICT) and the behaviour "increase the existing entry" is now a property of one storage engine.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

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

  • Write each operation as a pure function of its analysis: inputs and current state in, new state (or a refusal) out, nothing else touched. addItem(cart, productId, quantity) → cart. The state is a plain value; the catalog, if needed, is a parameter. No framework, no I/O, no global.
  • Prove it with the examples before anything wraps it. The record's tests run against the functions directly: add laptop twice, expect one entry with quantity 2. If the pure function is wrong, nothing built on it can be right; if it is right, everything built on it inherits that.
  • Then wrap, one layer at a time, and let each wrapper do only its own job: the route parses the request and calls addItem; the repository loads the cart, calls addItem, saves; the component holds the cart and calls addItem. The record says it directly: "the framework wraps behaviour that already works — which is why it comes last."
  • Keep the boundary visible: the pure core does not know a request, a row or a component exists. The design domain calls this a functional core with an imperative shell; here it is the reason the same five functions "survive every framework change."

A state change, with nothing else in the room

This is the whole of what the pure implementation has to get right: a state, an operation, a new state, and the exact difference between them. No request arrived and no row was written; if this is wrong, nothing that wraps it can fix it.

changeQuantity(cart, "laptop", 0) on [Laptop × 2, Mouse × 1]
before
[ Laptop × 2, Mouse × 1 ]
change Laptop to 0 →
after
[ Mouse × 1 ]
what changed The Laptop entry is removed — not set to quantity 0, because a cart never holds quantity 0 · items.length: 2 → 1 · The Mouse entry: untouched, same position

The operation as pure pseudocode

The record's addItem, language-neutral. Notice what is absent: nothing about a request, a response, a row or a component. The catalog is reached by name here and by parameter in the TypeScript, which is the only concession to where it will run.

addItem — behaviour only
1function addItem(cart, productId, quantity = 1):
2 if not catalog.has(productId): reject "unknown product"
3 if quantity <= 0: reject "quantity must be positive"
4 item = find entry in cart.items with item.productId == productId
5 if item exists:
6 item.quantity = item.quantity + quantity
7 else:
8 append { productId, quantity } to cart.items
9 return cart

Two rejections, one find, one branch, one return. Every line answers a column from the six-column analysis; none answers a question about HTTP or SQL. The same eight lines are the TypeScript, the Python and the C++ on the concept's Code tab.

Why not start with the database

The claim "we need the database first, or it is not real" is the endpoint-first reflex in its most reasonable form. The ladder finds what was actually wanted — seeing the cart survive — and the case in which starting with the database was right after all.

"Start with the table and the route"

The cart should be built from the database and the endpoint up, so it is real from the start.

  1. Why the database first? Because a cart that disappears when the process exits is not a real cart.
  2. Why does that matter now? Because the demo should show a cart that survives a reload.
  3. Does the survival change what add-item does? No. Add item finds, increases or appends whether the items are in memory or in rows; persistence changes where the cart is loaded from and saved to.
  4. What does starting there cost? The rules get written inside the handler or as an UPSERT; the repeat-add test needs a running database; the behaviour is tied to one storage engine.
real requirement Correct cart behaviour, and — by the demo — a cart that survives a reload.
simpler Pure functions over a plain value, tested with the examples, then a repository that loads, calls and saves. Survival is one wrapper, added when the behaviour is right (The Persistence Ladder).

the claim was right when The unknown being tested is the storage itself — a new database, an unfamiliar ORM, a constraint whose behaviour you need to see — in which case a spike against the database first is an experiment, and the pure core still follows it.

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.

  • Write the state as a plain value ({ items: [] }) and the operations as functions that take it and return it. If you reach for a class, keep the methods free of I/O; if you reach for a database, stop — that is the next stage.
  • Pass dependencies in. The catalog is a parameter or a small object; total takes a priceOf function. The unit test passes a set of three ids; the server passes the real catalog.
  • Run the record's examples as tests against the pure functions before writing any route. Three tests — repeat add, zero quantity rejected, change to zero removes — cover the rules.
  • When wrapping, give each layer one verb: parse, load, call, save, render. A handler that validates, computes and writes is the entangled version with more indentation.
  • Use the Input → Lookup → Branch → Mutation → Output to run the same script against the pure array cart and the pure map cart; both agree on every example because the behaviour lives in the functions, not the storage.

Worked on a concrete problem

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

  • The record's pure addItem, as pseudocode: check the catalog; check quantity > 0; find the entry with this productId; if it exists, increase its quantity; otherwise append { productId, quantity }; return the cart. No request, no row. The TypeScript version is the same six lines with types.
  • The wrappers the record describes, none of which contain the rule: "A React hook would hold cart in state and call addItem; an Express route would call it and return JSON; a repository would load the cart, call it, and save." Three wrappers, one function, one place where "one entry per product" is enforced.
  • The test that only the pure version makes cheap, from the record: addItem(addItem(createCart(), 'laptop'), 'laptop') and assert one entry with quantity 2. It runs in a millisecond with no server; the entangled version needs a running database to ask the same question.

How you know it worked

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

  • Every operation is a function you can call in a test with a literal cart and no setup.
  • The three examples pass against the functions before any endpoint exists.
  • The route handler is a few lines and contains no if about quantities.
  • Swapping the array cart for the map cart, or Express for something else, changes files that contain no cart rules.

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
  • ?Can I call this operation in a test with a literal state and no setup?
  • ?Which dependency is hiding inside — and can it be a parameter?
  • ?Does the wrapper contain any rule, or only parse / load / call / save / render?
  • ?What is the first layer that needs I/O, and what is its one job?

What can go wrong

How the move itself fails
  • Purity becomes a religion: the cart's functions are pure, and so are the repository's, the handler's and the logger's, via a monad stack nobody on the team can read. The move is "logic without I/O"; the shell is allowed to be imperative.
  • The pure core is written and then bypassed: the route calls the repository's UPSERT directly "for performance" and the rule now lives in two places, one of them SQL.
  • The examples are run only against the wrapped version, so the pure core is never actually tested alone and its purity is decorative.
  • The order is applied where it does not fit: a concept that *is* its I/O — a file upload — has very little pure logic, and insisting on a pure core first produces a function that renames a variable.
What the move costs
  • A pure core is invisible: nobody can click it, and a stakeholder who wants to see the cart sees a test output. The demo arrives one layer later than the endpoint-first order would have given it.
  • Passing dependencies in makes signatures longer — total takes a priceOf — and a codebase full of parameters can look more complicated than one with globals until the first test is written.
  • The layering is an extra hop per request (parse → load → call → save) and a place for a lazy shortcut that entangles them again; the discipline has to be kept.
Misreads
  • "Pure means no mutation." The record's addItem mutates the cart it is given and returns it; it is pure in the sense that matters here — no I/O, no hidden state, same input same output. Immutability is a separate, optional discipline.
  • "So the database comes last." The database comes after the behaviour is right, which for a cart is an afternoon; "last" does not mean "never" or "late". The Cart Disappears is the very next stage.
  • "Don't over-engineer" argues against this. The slogan is falsifiable here: a pure function plus a thin handler is fewer lines than a handler with the logic inside once the second caller exists; it only looks like more while there is one.

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.

  • GENERALAny concept whose behaviour is a transformation of state — cart, counter, rate limiter, queue — can be written and tested as pure functions first; the engineering stage wraps them.
  • DOMAIN-SPECIFICConcepts that are mostly I/O — a file upload, a webhook receiver — have a thin pure core (validate, name, decide) and the move shrinks to extracting that; the rest is honestly the shell and starting there is not a mistake.
  • CONTESTEDThe end-to-end-first position, at its strongest: a vertical slice through UI, API and database proves the layers connect and surfaces integration unknowns early, and a pure core can be right about behaviour nobody can reach; teams under a deadline argue the slice is the real risk and the pure function a comfort. The track's answer is that both orders exist — a walking skeleton first, then the pure core inside it — and the mistake is only putting the rules in the handler.
  • ILLUSTRATIVEThe three hours on the connection string and the millisecond test are invented for the shape of the argument; the functions and tests are the shopping-cart record's.

Where the depth lives

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