Invariants in an Online Store
The running example, worked fully: an order total is never negative, every order item references a product, a payment never happens twice, inventory is never negative. For each — who can violate it, where it is held, and what happens when it is not. The invariants decide the schema and the transaction boundaries before either exists.
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.
For the store you are building, what are the properties that must hold across every feature, and how do they decide where state lives and what a transaction has to cover?
I have the entities — Product, Cart, Order, OrderItem, Payment, Inventory — and I am about to write the schema. I know there are rules like "stock can't go negative" but they feel like things the code will handle later. Something tells me the schema is where they belong and I do not know which ones.
Write the schema from the entities and add the rules as they come up in code. Each table gets its obvious columns; a NOT NULL here, a foreign key there. The rules that need logic — "don't pay twice" — go in the service layer when that endpoint is written, because that is where they are needed.
The schema is written before the invariants are known, so it makes the wrong things easy. Order items reference the product's price by foreign key because that is what "references a product" suggests, and the "captured price never changes" invariant is now impossible to hold without a migration.
- The schema is written before the invariants are known, so it makes the wrong things easy. Order items reference the product's price by foreign key because that is what "references a product" suggests, and the "captured price never changes" invariant is now impossible to hold without a migration.
- "Don't pay twice" is put in the checkout endpoint and holds there. The provider's confirmation handler, written later by someone else, does not know the rule exists; the double payment arrives through the door nobody guarded.
- The rules that ended up in code are invisible to the next engineer, who sees a stock column and decrements it in the new refund-restock feature without the check that checkout had. Nothing in the schema says the invariant exists; the code that held it was one function among many.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- Take the invariants the earlier lessons produced and, for each, answer three questions before any schema: which actions can violate it, where is the single place all of those actions pass through, and can the database express it? The answers sort the invariants into homes: a column or table constraint, a transaction boundary that must contain two operations together, an application rule at one chokepoint, or a decision that has not been made (Where Invariants Live).
- Let the invariants shape the schema rather than the other way round. "Captured price never changes" means order items carry copied values, not references. "Payment at most once per order" means a payment row has an idempotency key and a unique constraint that makes the second success impossible to insert, not a check in one handler. "Stock never negative" means the check and the decrement are one statement or one transaction, and the schema may add a constraint as the last line of defence (Database Constraints).
- For every invariant that lives in application code, name the chokepoint — the one function or module through which every violating action passes — and make it the only way to perform those actions. An invariant enforced in two places is enforced in neither, because the third place will be written without it (Enforcing Invariants).
- Write down, for each invariant, what happens when it is violated anyway: which is detectable, which is repairable, which is neither. That list is the order in which to defend them. A double payment is neither cheaply detectable nor repairable without a refund; a negative total is both.
The invariants, decomposed into what would show they hold
The tree below is the store's invariant list arranged by the state each one protects. Every leaf carries the observation that would show it holds — which is also the test that will eventually guard it, and the disturbance that revealed it in the previous lesson.
A leaf without a testable observation would be a heading; here, each is a concrete scenario with an expected outcome, and several of them name the schema shape or the chokepoint that makes the outcome possible.
- ├Orders— the record of what the customer agreed to
- └Total is never negativetestable After checkout, a full refund and an admin line edit, the recomputed total is zero or more and the CHECK constraint has never fired.
- └Every item references an existing producttestable Deleting a product that appears on an order is refused; retiring it hides it from the catalog and leaves the order intact.
- └Captured price never changestestable Editing a product's price after checkout leaves the order's item prices and total unchanged.
- ├Payments— money moved outside the system
- └At most one success per ordertestable Sending the provider's confirmation twice, and clicking Pay twice, each yields exactly one succeeded payment row and one charge.
- └Every payment belongs to an ordertestable A confirmation for an unknown order reference is recorded as an anomaly, not as a payment.
- ├Inventory— a unit promised twice is a broken promise
- └Stock is never negativetestable Two checkouts for the last units, run concurrently, produce one order and one refusal; stock reads zero, never below.
- └Promised units never exceed existing unitstestable Summing units on paid orders never exceeds initial stock plus restocks — checked by a reconciliation query, not by a form.
Cart is absent on purpose: a cart is a draft, and its only invariant ("items reference existing products") is inherited from the catalog. Deciding that the cart has no money invariants is itself a decision worth writing down.
Where each one is held
The decision below is the sorting move. It is a decision, not a lookup: the same invariant can be held in different places, and the criteria — can the database say it, how many actions violate it, can they run concurrently, what does violation cost — decide which. The store's four invariants land in different rows.
Given an invariant and the actions that can violate it, where is it held so that no future endpoint can bypass it?
when The property is about one row or one reference and the database can state it directly — item references product, total ≥ 0, one succeeded payment per order.
cost Changing the rule is a migration; some policies (oversell during a sale) become harder to allow.
when The property spans a check and a change that must not be separated — read stock and decrement it; create order and record payment attempt.
cost The transaction is a coupling between operations that might otherwise live in different modules; isolation level matters (Isolation Levels).
when The property needs logic the database cannot express — the refund policy, the reservation timing — and every violating action can be routed through one function.
cost It is only a chokepoint if it is the only way; scripts and admin tools are the usual bypass.
when The property is global (promised units vs existing units) and cannot be held per-operation without serialising everything; a periodic query detects drift.
cost Violations are detected, not prevented; acceptable only where repair is cheap.
The flows the invariants guard
The diagram shows checkout as the invariants see it: the two points where the outside world can repeat itself (the customer's click, the provider's confirmation) and the one atomic step that stock depends on. Every guard in the picture exists because an invariant demanded it, and the picture is what the transaction boundaries look like drawn.
How to do it
Most important first.
- For each invariant, list the violating actions including the ones that do not exist yet but obviously will — refunds, restocks, admin corrections, provider retries.
- Ask whether the database can express it. Foreign keys, NOT NULL, CHECK and UNIQUE hold a surprising share; what they hold is held against every future endpoint for free.
- For the rest, name the chokepoint and make the invariant impossible to bypass — a single function that checks and decrements, a payment table whose unique constraint makes the second success fail.
- Decide the transaction boundaries from the invariants that span two operations: checking stock and decrementing it; creating the order and recording the payment attempt. Each "these must happen together or not at all" is a boundary (Transactions and ACID).
- Rank by what happens when the invariant fails anyway, and defend the unrepairable ones first.
Worked on a concrete problem
The move has to produce something. This is what it produced.
- "Every order item references an existing product." Violators: checkout (fine), product deletion (the problem). Home: a foreign key holds the reference, and the deletion question becomes explicit — hard delete is now impossible while orders exist, so products get a
retiredflag and the catalog hides them. The invariant decided a feature. - "A payment succeeds at most once per order." Violators: checkout, the customer clicking twice, the provider retrying its confirmation, a support agent re-running a stuck order. Chokepoint: a single
recordPayment(orderId, providerRef)that every path calls. Schema: a unique constraint on the provider's reference, and a partial unique on (order, status = succeeded). The second confirmation now fails to insert rather than silently succeeding, and the handler treats that failure as "already done" (Webhook Idempotency). - "Order total is never negative." Violators: checkout, refunds, admin line edits. Home: totals are computed in one place from items, never stored as an editable field; a CHECK on the stored total is the last line. Detectable and repairable, so it is defended last — but the chokepoint decision means the refund feature, when written, cannot avoid it.
- "Inventory is never negative." Violators: two checkouts at once, admin edit during checkout, restock racing a checkout. Home: check-and-decrement in one atomic statement, a CHECK constraint as backstop, and a decision the store had not made — whether stock is reserved at cart, at checkout or at payment. The invariant forced the question; answering it is Invariants Under Concurrency and The Order Lifecycle, Built.
How you know it worked
What now exists that did not before, and what question you can now ask.
- Each invariant has a named home, and for the ones in code the chokepoint is a single function that every violating action calls.
- The schema has at least one shape it would not have had otherwise — a copied price, a unique constraint on a provider reference, a retired flag instead of a delete.
- The transaction boundaries are listed and each one is justified by an invariant that spans two operations.
- At least one feature the requirements did not mention — soft delete, stock reservation timing — was forced into the open by an invariant.
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.
- ?Which actions, including the ones not yet written, can violate this invariant?
- ?Can the database express it, and if so what shape does that force on the schema?
- ?What is the single place every violating action passes through — and is it really the only way?
- ?Which two operations must happen together or not at all, and is that a transaction boundary I have named?
- ?If this invariant is violated anyway, will I know, and can I repair it?
What can go wrong
- Every invariant is pushed into the database until the schema contains business logic nobody can change — triggers computing totals, constraints that encode the refund policy. The database holds what it can express simply; the rest has a chokepoint in code.
- The chokepoint is designed and then bypassed by the first script that "just fixes the data". A chokepoint is only one if it is the only way; scripts go through it too, or the invariant is decorative.
- The move is done for a prototype that exists to test whether customers want the store at all. If the data will be deleted next month, the double-payment invariant still matters — money is real — and the negative-total one does not.
- Constraints in the database make some later changes — allowing oversell for a flash sale, keeping deleted products — into migrations rather than code edits. That is the point, and it is also a cost.
- A chokepoint is a place every action must go, which is a small coupling paid on every feature that touches that state.
- Deciding stock reservation timing now, because an invariant forced it, is a product decision taken earlier than the product team might have wanted.
- "Put every invariant in the database and the application cannot break it." The database holds what a row, a key or a check can say. "Sum of successful payments never exceeds order total" across retries and partial captures is held by a design — idempotency plus a constraint plus a chokepoint — not by a constraint alone.
- "The service layer is the chokepoint." Only if every path — the confirmation handler, the admin tool, the support script — goes through the same function. A layer is a location; a chokepoint is a guarantee, and it is worth checking that it actually is one.
- "These invariants are the store's final list." They are V1's. Multiple warehouses split "stock never negative" per location; multiple currencies change what "total" means. The list is revisited whenever an assumption changes (When Assumptions Change).
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.
- DOMAIN-SPECIFICThe four invariants are specific to a store with payments and inventory. A chat app's equivalents are "a message belongs to exactly one conversation" and "read state never moves backwards"; the sorting into database, transaction and chokepoint is the same, the properties are not.
- STAGE-SPECIFICGreenfield: the invariants shape the schema before it exists. Existing store: the schema is fixed, and the move becomes finding which invariants it already holds, which are held in code and where, and which are held nowhere — the last list is the risk register.
- ILLUSTRATIVEThe store, the retired flag, the partial unique constraint and the support agent re-running an order are invented to show each invariant finding a home; a real store's payment table will look different.
Where the depth lives
This domain asks the question and hands the answer off by name.