Business Validation
Rules that need loaded state and an actor — and the fact that every answer they give is already stale.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Where do rules that need to look something up belong, and what is the guarantee they give you?
A user may cancel an order, but only if it has not shipped, only within 30 days, only if they are on the account that owns it, and only if the account is not in dunning.
Add the checks to the cancel endpoint. Four if statements before the update, each returning a 400 with a message. It reads exactly like the requirement.
Support has a "force cancel" admin route and a nightly job cancels abandoned orders. Neither goes through the endpoint, so both skip the dunning rule that finance added last quarter.
- Support has a "force cancel" admin route and a nightly job cancels abandoned orders. Neither goes through the endpoint, so both skip the dunning rule that finance added last quarter.
- A fifth rule arrives, then a sixth. The handler is now twelve branches deep and one path reaches the update without passing the ownership check (Fat Controllers).
- Every rejection is a 400 with a human-readable string, so the mobile app string-matches on
"has already shipped"and breaks when someone improves the wording (Reporting Validation Failures). - The ownership check is a business rule in one place and an authorization check in another, and nobody can say which one is authoritative (Authentication vs Authorization).
- Between the "not shipped" check and the update, a warehouse webhook marks the order shipped. The cancellation is written against a state that no longer exists (Database Constraints).
- The rules cannot be listed. "What stops an order being cancelled?" requires reading a handler, and product cannot get an answer without an engineer.
What is actually happening
- A business rule is a predicate over loaded state plus the actor. That is what separates it from transport validation, and it is why it cannot run at the edge or in a gateway (Transport Validation).
- Its answer is a reading at a moment, not a guarantee. The state it read can change before the write lands, so the check is advisory unless the write is protected (Optimistic Concurrency).
- It belongs where every caller passes through — the application layer — because HTTP is usually one of several entry points (The Service Layer).
- Many business rules are best modelled as a state machine: an entity has a status, and each transition has a guard. That turns "twelve if statements" into an enumerable table (Resources Have State Machines).
- Authorization is a different question that lives next door: "may this principal act on this object" needs the actor and the object, while "may this action happen at all" needs the object and its state. They frequently need the same loaded row, which is why they get merged, and they must produce different responses — 403 versus 409 (Object-Level Authorization).
- Some rules are genuinely cross-entity ("at most five active projects per account"). Those cannot be enforced by reading and then writing, and need a lock, serializable isolation, or a counter with a constraint (Pessimistic Locking).
From twelve branches to an enumerable table
The reason state-machine modelling keeps reappearing for business rules is not elegance. It is that a table can be enumerated: product can read it, a test can iterate it, and a new transition is a row rather than a branch inserted somewhere in a 200-line function.
It also separates two things that look alike in an if chain: which transitions exist at all, and which guards apply to a transition that exists. Those have different answers — an unknown transition is a 422, a guarded one is a 409.
1const TRANSITIONS: Record<Status, Status[]> = {2 draft: ['placed', 'abandoned'],3 placed: ['paid', 'cancelled'],4 paid: ['shipped', 'cancelled', 'refunded'],5 shipped: ['delivered', 'returned'],6 delivered: ['returned'],7 cancelled: [], refunded: [], returned: [], abandoned: [],8}9 10type Reason = 'not_allowed_from_status' | 'window_expired' | 'account_in_dunning'11 12function canCancel(order: Order, account: Account, now: Date): Reason | null {13 if (!TRANSITIONS[order.status].includes('cancelled')) return 'not_allowed_from_status'14 if (daysBetween(order.placedAt, now) > 30) return 'window_expired'15 if (account.dunningState !== 'none') return 'account_in_dunning'16 return null17}18 19async function cancelOrder(deps: Deps, actor: Actor, id: OrderId) {20 const order = await deps.orders.findForActor(actor, id) // authz first: 404/40321 if (!order) return { ok: false, reason: 'not_found' as const }22 23 const account = await deps.accounts.find(order.accountId) // load once, pass in24 const reason = canCancel(order, account, deps.now())25 if (reason) return { ok: false, reason } // -> 409, stable code26 27 // the check above is advisory; THIS is the enforcement28 const { rowCount } = await deps.db.query(29 `UPDATE orders SET status = 'cancelled', cancelled_at = now()30 WHERE id = $1 AND status = $2`, // precondition in the write31 [id, order.status],32 )33 if (rowCount === 0) return { ok: false, reason: 'concurrently_modified' as const }34 return { ok: true }35}Three separable ideas: the transition table is enumerable, canCancel is a pure function of loaded state so it can be reused by a preview endpoint, and the WHERE status = $2 is what makes the rule true rather than probable. rowCount === 0 is the race actually happening, and it maps to 409 rather than 500.
Two questions that need the same row
Authorization and business rules get merged because they are answered at the same moment, from the same loaded entity, and both produce a refusal. They are still different questions, and the difference shows up in what the caller is told and what an auditor can reconstruct.
The ordering rule that follows: authorize first, then evaluate business rules. A state-based refusal for an object the caller may not see is an information disclosure with a helpful error message attached.
| Authorization | Business rule | |
|---|---|---|
| Question | May *this principal* act on *this object*? | Is this action allowed given the object's state? |
| Needs | The actor, the object, the policy | The object, related state, the clock |
| Answer changes when | Roles, membership or ownership change | The entity moves through its lifecycle |
| Response | 403, or 404 to avoid confirming existence | 409 or 422 with a stable reason code |
| If it is missing | Anyone authenticated acts on anyone's data (Object-Level Authorization) | Illegal states get recorded as fact |
| Evaluate | First — before revealing anything about the object | Second — the caller has already been allowed to see it |
| Audit meaning | A denied attempt is a security event | A rejection is a product event |
Where the rule was true and stopped being true
The uncomfortable property of this layer is that a passing check is a statement about the past. Everything below is a real interleaving, not a hypothetical, and none of them are fixed by adding another check.
The pattern in every response column is the same move: fold the precondition into the write, or make the operation keyed so a duplicate is recognised rather than prevented.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A warehouse webhook ships the order mid-request | A shipped order is marked cancelled | The status was read 40 ms before the update | UPDATE ... WHERE status = $expected, and treat 0 rows as a conflict |
| A user double-clicks "Cancel" | Two refunds issued | Both requests passed the same check | Idempotency key on the operation (Idempotency Keys, Duplicate Detection) |
| Two invites sent while 4 of 5 seats are used | Six seats on a five-seat plan | Both read the same count | Conditional update on a counter, or a CHECK constraint on a denormalised column (Database Constraints) |
| Rules evaluated on a read replica | A cancellation allowed seconds after shipping | Replication lag, so the state read was genuinely old | Evaluate write-path rules against the primary (Read Replicas From the Application) |
| A payment call sits between the check and the write | The largest race window in the codebase | Hundreds of milliseconds of external latency inside check-then-act | Re-assert the precondition after the call, in the write (External Calls Inside a Transaction) |
| An admin force-cancel path added last quarter | A rule bypassed entirely | The rules live in the endpoint, not in the operation | One application-layer function every caller uses (The Service Layer) |
How to build it
Most important first.
- Put the rules in the application layer, behind one function per operation, so every entry point evaluates the same set.
- Return a typed reason, not a message.
{ ok: false, reason: 'order_already_shipped' }is stable, testable and translatable; a sentence is none of those. - Model status transitions explicitly — a map from current status to allowed next statuses with a guard each — so the rules can be listed, tested and shown to product.
- Load the state once and pass it to the rules. Four rules each doing their own query is four round trips and four different snapshots (The N+1 Query Problem).
- Separate authorization from business rules even when they read the same row, because they have different failure responses and different audit meaning (Where the Check Belongs).
- Protect the write. Re-assert the precondition in the
UPDATE ... WHERE status = 'placed'and check the affected row count, so the check and the act are one statement (Atomic Operations). - Name each rule. A named rule can be counted, alerted on, feature-flagged and explained (Feature Flags: Rollout, Kill Switches and Debt).
What can go wrong
- Rules evaluated against a read replica, so a just-shipped order still looks cancellable for the length of the replication lag (Read Replicas From the Application).
- Rules that mutate — a "validation" function that also writes an audit row, so it cannot be called speculatively for a preview endpoint.
- The rule set diverging between the synchronous path and the background job that retries it (Job Idempotency).
- Rules expressed as exceptions thrown from deep inside the domain, so the transport layer has to catch and classify by type, and one new exception type produces a 500 (An Error Taxonomy That Maps Cause to Response).
- A rule that needs an external call — "is this VAT number valid" — placed inline, so an external outage blocks all writes (Circuit Breakers).
- Preview and commit paths using different rule sets, so the UI says the action is allowed and the API refuses it.
- Every rule is check-then-act. "Not shipped" is true when read and can be false when the update executes (Backend Races).
- Two concurrent cancellations both pass and both write, producing two refunds unless the write is conditional or the operation is keyed (Idempotency Keys).
- Rules over aggregates ("under the seat limit") race on the aggregate: two invites both see four seats used and both commit, giving six (Atomic Operations).
- Reading state, calling an external service, then writing widens the window to the external service's latency — the largest check-then-act gaps in most codebases live here (External Calls Inside a Transaction).
- Business validation is not authorization, and a system that only has business rules typically lets any authenticated user act on any object whose state permits the action (Object-Level Authorization).
- Order matters: authorize before you reveal. A 409 "order already shipped" for an order the caller does not own confirms that the order exists and discloses its state (Broken Access Control (IDOR / BOLA)).
- Rules that read the tenant from the request rather than the session evaluate the right rule against the wrong data (Tenant Isolation).
- Business rules are also a rate-limit surface: an endpoint that loads three aggregates before rejecting is an amplification target, so cheap checks and rate limits belong before expensive ones (Authenticate First, or Rate-Limit First?).
- "Business validation and authorization are the same check." They need different inputs and must give different answers. Merging them is how 403s become 409s and how ownership checks go missing (Authentication vs Authorization).
- "If the rule passed, the action is safe." It was true when you read it. Protect the write (Optimistic Concurrency).
- "Put the rules in the schema so they run early." Rules needing state cannot run early — a schema that queries has stopped being transport validation (Transport Validation).
- "Rules belong on the model." A defensible arrangement in Rails and Django. It fails for rules spanning several entities, and framework validation callbacks are routinely skipped by bulk operations (Alternatives to Layering).
- "A rejected request is a client error, so 400." 400 means malformed. A well-formed request that the current state forbids is 409 or 422 (Status Codes From the Server's Side).
Operating it
- Counter per named rule:
order.cancel.rejected{rule="already_shipped"}. This is the single highest-value metric in this lesson — it is a product signal and a bug signal at once. - A rejection rate that jumps for one rule usually means a client is out of date or a state machine changed underneath it (Deploys Are the First Suspect).
- Compare business rejections against constraint violations. Business rejections falling while violations rise means the pre-check is losing races rather than the clients improving (Backend Races).
- Log the entity id, the actor id and the rule name on every rejection, so a support question is a query rather than a reproduction attempt (Correlation Ids That Survive Every Hop).
- Cost scales with the state each rule loads, not with traffic shape. Four rules that each fetch an aggregate cost four queries on every request, including the ones that will be rejected.
- At 100x, the check-then-act window stops being theoretical. A rule that "has never been a problem" becomes a support queue when concurrent writes to the same entity become normal (Backend Races).
- Rule sets grow with the product, not with load. The scaling problem here is comprehension: a hundred rules nobody can enumerate is a different kind of outage.
- Moving rules out of the handler means the endpoint no longer reads like the requirement. You trade a readable narrative for a set that every caller shares.
- Explicit state machines are more code than four
ifstatements, and they are only worth it once transitions have guards or the status set is non-trivial. - Re-asserting the precondition in the write duplicates the rule in SQL. That duplication is what closes the race, and it is still duplication (Database Constraints).
- Typed reasons instead of messages mean the client owns the wording, which is better for i18n and worse when you want to change the explanation without a client release.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALRules needing loaded state must live where all callers pass, and their answers are point-in-time. True everywhere.
- SCALE-SPECIFICFlips on concurrent writes to the same entity, not on total traffic. With one writer per entity at a time — most B2B products, most of the time — read-then-check-then-write is correct in practice and stays correct for years. Once two actors can touch one entity within the check-to-write window (a shared team account, a webhook racing a user action, a double-submitted form), the same code produces double refunds, and the fix is a conditional write rather than another check.
- FRAMEWORK-SPECIFICRails validations and Django's
full_cleanput rules on the model and run them on save — convenient, and skipped entirely byupdate_all,update_columns,bulk_createand raw SQL, so a bulk path bypasses every rule. Frameworks with no model-validation hook (Express, FastAPI, Go) have no such trapdoor and no such convenience: the rules run only where you call them.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.