ValidationGENERALSCALE-SPECIFICFRAMEWORK-SPECIFIC

Business Validation

Rules that need loaded state and an actor — and the fact that every answer they give is already stale.

What actually happensHow to build it

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.

The question

Where do rules that need to look something up belong, and what is the guarantee they give you?

The requirement

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.

The obvious build

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.

Why it breaks

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.

How it breaks in production
  • 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.

Rules you can list, with the write protected
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 null
17}
18
19async function cancelOrder(deps: Deps, actor: Actor, id: OrderId) {
20 const order = await deps.orders.findForActor(actor, id) // authz first: 404/403
21 if (!order) return { ok: false, reason: 'not_found' as const }
22
23 const account = await deps.accounts.find(order.accountId) // load once, pass in
24 const reason = canCancel(order, account, deps.now())
25 if (reason) return { ok: false, reason } // -> 409, stable code
26
27 // the check above is advisory; THIS is the enforcement
28 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 write
31 [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

GENERALThe distinction holds regardless of stack; only the mechanism for expressing the policy (RBAC, ABAC, row-level security) varies.

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.

AuthorizationBusiness rule
QuestionMay *this principal* act on *this object*?Is this action allowed given the object's state?
NeedsThe actor, the object, the policyThe object, related state, the clock
Answer changes whenRoles, membership or ownership changeThe entity moves through its lifecycle
Response403, or 404 to avoid confirming existence409 or 422 with a stable reason code
If it is missingAnyone authenticated acts on anyone's data (Object-Level Authorization)Illegal states get recorded as fact
EvaluateFirst — before revealing anything about the objectSecond — the caller has already been allowed to see it
Audit meaningA denied attempt is a security eventA 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.

Check-then-act, in production
TriggerSymptomCauseResponse
A warehouse webhook ships the order mid-requestA shipped order is marked cancelledThe status was read 40 ms before the updateUPDATE ... WHERE status = $expected, and treat 0 rows as a conflict
A user double-clicks "Cancel"Two refunds issuedBoth requests passed the same checkIdempotency key on the operation (Idempotency Keys, Duplicate Detection)
Two invites sent while 4 of 5 seats are usedSix seats on a five-seat planBoth read the same countConditional update on a counter, or a CHECK constraint on a denormalised column (Database Constraints)
Rules evaluated on a read replicaA cancellation allowed seconds after shippingReplication lag, so the state read was genuinely oldEvaluate write-path rules against the primary (Read Replicas From the Application)
A payment call sits between the check and the writeThe largest race window in the codebaseHundreds of milliseconds of external latency inside check-then-actRe-assert the precondition after the call, in the write (External Calls Inside a Transaction)
An admin force-cancel path added last quarterA rule bypassed entirelyThe rules live in the endpoint, not in the operationOne 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

Failure modes
  • 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.
What can race
  • 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).
Security
  • 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?).
Misreads
  • "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

How you see it in production
  • 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).
What changes at 10x and 100x
  • 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.
What this costs
  • 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 if statements, 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_clean put rules on the model and run them on save — convenient, and skipped entirely by update_all, update_columns, bulk_create and 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.