RequirementsGENERALDOMAIN-SPECIFICCONTESTED

Functional and Non-Functional Requirements

"A user can create an order" fits inside almost any structure. "Order creation is idempotent and auditable" fits inside very few — which is why the second kind decides the design.

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 survives until the requirement changes.

The question

Both kinds of requirement are real, so why does one of them do most of the work in deciding the structure?

The requirement

"Users can place an order." Later, in a different meeting: "obviously placing an order twice must not charge twice, and finance need to be able to reconstruct any order's history."

The obvious build

Build the functional requirement first — it is the one that is written down and the one that demos. Idempotency, audit and latency are qualities you add afterwards, once the feature works.

Why it breaks

Idempotency is not a quality you add; it is a statement about the interface. Retrofitting it means the caller must now supply a key it never had, which is a breaking change to every client (Backward Compatibility as a Constraint).

How it breaks as requirements change
  • Idempotency is not a quality you add; it is a statement about the interface. Retrofitting it means the caller must now supply a key it never had, which is a breaking change to every client (Backward Compatibility as a Constraint).
  • Audit retrofitted means the history you need most — the six months before you added it — does not exist and cannot be reconstructed. No amount of code fixes a gap in recorded data.
  • The latency budget, added late, tends to be met by caching or by moving work to a background job. Both change who owns the state and when it becomes visible, which is a redesign wearing a performance hat (Designing for Cost).
  • Meanwhile the functional requirement genuinely is portable: "a user can place an order" is satisfiable by a transaction script, an aggregate, an event-sourced log or a stored procedure. It rules almost nothing out, which is exactly why starting there tells you so little.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The order table already exists and is written to by three services, one of which is a legacy import job nobody owns.
  • The payment provider retries webhooks on any non-2xx, so duplicate delivery is not a hypothetical.
  • There is a p99 latency budget on checkout of 400ms end to end, of which order creation may have 150ms.
Invariants
  • One customer intent produces at most one charge, regardless of how many times the request arrives.
  • Every state change to an order is recoverable after the fact: what changed, when, and what caused it.
  • An order that was accepted is never silently lost, even if the response never reached the client.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • One unit owns "has this intent already been processed" — and it must own it at the same boundary the effect happens, not one layer above it.
  • One unit owns writing the history, and it must be impossible to change an order without it running (Invariant Leaks).
  • The transport layer owns none of this. If the idempotency check lives in the HTTP handler, the background job that also creates orders bypasses it.
Boundaries
  • The idempotency boundary is the boundary of the effect: whatever performs the charge is what must be idempotent, and putting the check further out only protects the paths that go through that outer layer.
  • The audit boundary is the same one, for the same reason — a history written by callers is a history with holes.
  • The latency boundary is different, and that is worth noticing: it is drawn around what the caller is made to wait for, which is a decision about the *interface*, not about internals (Cost-Aware Interfaces).

Which kind of requirement rules out which structures

SIMPLIFIEDThe "designs it rules out" column is a teaching device rather than an exhaustive analysis — a determined engineer can satisfy any of these rows in any structure. The claim is about relative cost and the number of places that must cooperate, not about formal impossibility.

The useful test for a requirement is not whether it is functional or non-functional — it is how many designs it eliminates. A requirement that eliminates nothing is not shaping anything, whatever category it is filed under.

Run that test on a typical order feature and the asymmetry is stark. The behaviour is satisfiable by every structure on the table; two of the qualities are satisfiable by very few.

RequirementKindDesigns it rules outRetrofit cost
A user can place an orderFunctionalEssentially none — transaction script, aggregate, event log and stored procedure all do thisLow. It is a new code path in whatever structure exists.
An order has a delivery addressFunctionalNone. A field.Low, plus a migration.
Placing an order twice must not charge twiceNon-functionalEvery design where the effect has no stable identity supplied by the callerHigh: the caller's contract changes, so every client changes.
Finance can reconstruct any order's historyNon-functionalEvery design where state is updated in place with no record of the transitionPartly impossible. The code is fixable; the missing months are not.
Order creation within 150ms at p99Non-functionalDesigns that do synchronous fan-out to three services on the write pathMedium, and it usually changes when state becomes visible.
Tenant A never sees tenant B's ordersNon-functionalEvery design where tenancy is a filter applied by callers rather than a property of accessVery high: it is a change to every query and every stored row (Backend Engineering has the mechanics; here it is a boundary question).

The same requirement, arriving in two different orders

This is the argument in its concrete form. The functional requirement is identical in both columns; only the order of arrival differs, and the cost differs by more than an order of magnitude.

Make order creation idempotent and auditable
The change

Two identical order-creation requests must produce one order and one charge, and finance must be able to reconstruct every state change afterwards.

Functional first: `createOrder(payload)` writes a row and charges, audit added by whoever remembers
OrderControllerOrderServicePaymentClientImportJobAdminOrderToolMobileApiV1MobileApiV2PartnerWebhookHandlerschema migrationclient SDKs
testsorder_service_testcontroller_testimport_job_testadmin_testpartner_testsdk_contract_tests
10 modules · 6 test files

The signature changes, so every caller changes, including two mobile app versions still in the field and a partner integration on a contract. The rollout needs a compatibility window where the key is optional, which means a period where the invariant does not hold. And the eighteen months of history before the change simply do not exist.

Properties first: the effect boundary takes a caller-supplied key and emits a transition record; the functional path is written inside it
OrderPlacement (the effect boundary)
testsorder_placement_testcrash_between_key_and_effect_test
1 module · 2 test files

Idempotency and history are properties of the one place the effect happens, so the import job and the partner handler get them for free by going through it. Adding a new caller costs a key and nothing else.

what it cost Every caller carries a key, including the internal ones that could never retry, and every test has to produce one. The key store is a second piece of durable state with its own retention policy, its own growth curve and its own failure mode between the two writes. And the history table will be the largest table in the database within a year, which is a cost someone pays in storage and in every query that joins it.

Writing a quality down so it can be wrong

A non-functional requirement stated as an adjective cannot be designed against, cannot be tested, and cannot be traded off against anything. Stated as a contract it becomes an ordinary engineering constraint.

The version below is deliberately boring. That is the point: once the property is in the signature, satisfying it is a small local job rather than a cross-team programme.

The property in the type, not in the wiki
1// Vague: nothing here can be wrong, so nothing here constrains.
2// "Order creation should be idempotent and auditable,
3// and reasonably fast."
4
5// Precise: each line is falsifiable and each line rules something out.
6
7type IntentKey = string & { readonly __brand: 'IntentKey' }
8
9interface OrderPlacement {
10 /**
11 * Two calls with the same key return the same OrderPlaced,
12 * charge once, and emit exactly one transition record.
13 * Keys are retained 30 days; after that a replay is a new order.
14 * Budget: 150ms p99, measured at this boundary.
15 */
16 place(key: IntentKey, intent: OrderIntent): Promise<OrderPlaced>
17}
18
19// What the branded key buys: a caller cannot pass "the order id"
20// or an empty string by accident, and there is no overload
21// without a key for someone to reach for under deadline.

The 30-day retention line is the one that is usually missing, and it is a requirement in disguise: it is the answer to "how long must we remember?", which is a storage cost, a privacy question and a correctness boundary all at once. A contract that states the guarantee but not its expiry is not finished (The Requirements Nobody States).

How to build it

Most important first.

  • Write the non-functional requirements as statements about the interface, not as adjectives. "Idempotent" is vague; "createOrder takes a client-supplied key, and two calls with the same key return the same order" is a contract you can test (Idempotency by Design).
  • Decide which of them are structural — they change what the code looks like — and which are operational — they change how it is run. Idempotency and audit are structural. A retry policy usually is not.
  • Put the structural ones in the signature. A key that must be supplied is enforced by the type; a key that ought to be supplied is enforced by a wiki page (Making Illegal States Unrepresentable).
  • Give latency a number and an owner, or drop it. "Must be fast" constrains nothing and is therefore not a requirement (Designing for Cost).
  • Then write the functional path. It will fit, because it fits almost anywhere.

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Non-functional first: adding a second order type later costs one new case in the domain, reusing the same key handling and the same history writer. Roughly a day. Adding a *third* consumer — the import job — costs nothing extra, because the guarantee lives at the effect and not at the transport.
  • Functional first: adding idempotency later costs a schema change, a client contract change with a deprecation window, an audit of every existing caller, and a backfill decision about in-flight requests. Weeks, spread across teams, and unschedulable as a single unit (Expand and Contract).
  • Adding audit later costs the same plus something that cannot be bought: the missing history. That is the asymmetry that makes this lesson worth having — most retrofits are expensive, and this one is partly impossible.
  • What the non-functional-first design gave up: every caller now deals with keys, including the three internal ones that will never retry, and every test writes history it does not care about. That is a permanent tax on the simple cases to protect the ones that are not simple.
What the recommended approach costs
  • Designing for the non-functional first makes the simple case more complex than it needs to be, permanently, and the people paying that cost are usually not the people who benefited.
  • It also biases toward doing this for every non-functional requirement, including the ones that are genuinely operational and would have been cheaper as configuration.
  • And it slows the first delivery, which is the version stakeholders judge. That is a real organisational cost, not just an engineering one.

What can go wrong

Failure modes
  • Idempotency implemented at the wrong grain: the key covers the HTTP request but the handler performs two effects, so a partial replay produces one of them twice.
  • The idempotency store and the order store are separate, so a crash between them leaves a key recorded for an order that does not exist — the mitigation acquires its own failure mode (Partial Failure).
  • Audit records written on a best-effort basis, in a catch that swallows. Now the history is silently incomplete, which is worse than absent because it is trusted (Swallowed Errors).
  • The latency budget is met by making the write asynchronous, and nobody notices that "order placed" is now eventually true, which breaks a read-your-writes assumption three screens away.
Dependencies, and their direction
  • Idempotency introduces a dependency on durable storage for keys, with its own retention question — which is a hidden requirement arriving on the back of a stated one (The Requirements Nobody States).
  • Audit introduces a dependency on an append-only store and on stable identifiers, because a history that refers to ids that were reused is not a history (Stable Identifiers).
  • The functional path depends on both, and never the other way around.
Misreads
  • "Non-functional means less important." It is a terrible name for "properties of the whole rather than of a behaviour", and the properties are usually the expensive part (Requirements Before Design).
  • "So design for every quality attribute up front." No — the split that matters is structural versus operational. Retry counts, timeouts, pool sizes and log levels are all non-functional and all belong in configuration, not in structure.
  • "Idempotency is a backend concern." It is a statement about a contract, which means it constrains the caller too. A design that makes it the server's secret cannot actually deliver it (API Design owns the wire contract; here it owns the shape of your code — see Idempotency by Design).
  • "Performance requirements are premature optimisation." A stated budget is a requirement; guessing at hot paths with no budget is the thing that phrase was coined for (Premature Optimization, Reclaimed).
Smells this explains
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • The idempotency contract as a test at the effect boundary: call twice with the same key, assert one charge and identical responses (Contract Tests).
  • A crash test, not just a duplicate test — kill between key write and effect, restart, assert the invariant still holds. The naive duplicate test passes under both correct and broken designs.
  • Assert that the history exists for every transition by generating transitions rather than by listing them, so a new state added later fails the test rather than quietly skipping it (Property-Based Testing).
  • Latency as a test at the interface boundary only. Asserting internal timings makes the tests brittle and tells you nothing about the budget that was promised.
How this design ages
  • Non-functional requirements tend to get stricter and never looser. A latency budget of 400ms becomes 200ms; an audit requirement becomes an audit requirement with a seven-year retention.
  • The functional surface, by contrast, churns constantly and cheaply — order types, fields, statuses — which is a reason to keep it thin and a reason not to build structure around it.
  • The design stops fitting when one non-functional requirement starts contradicting another: an audit trail that must be immutable meets a deletion requirement from privacy law, and something has to give (Consistency Boundaries).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat properties of the whole constrain structure more than individual behaviours do follows from the fact that a behaviour can be relocated and a property cannot; it holds regardless of language or architecture style.
  • DOMAIN-SPECIFICIn domains where a duplicate action is harmless and nobody audits anything — an internal dashboard, a search UI, a content site — the functional requirement really is the whole requirement, and this lesson's advice is overhead. It bites hardest where an action moves money, entitlement or personal data.
  • CONTESTEDThe strongest opposing case: teams routinely gold-plate non-functional requirements nobody asked for, building idempotency keys and audit tables for features that get deleted in a year, and shipping the functional slice first is what produces the information needed to know which properties are genuinely required. That is correct often enough that "design for the properties first" should be applied to the small number that are expensive or impossible to retrofit — audit history, identifiers, tenancy — and not to the rest.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Securityaudit-logs
Performancelatency-budget
Domains that do not exist yet
  • Testing & Reliability Engineering — how you gain confidence that an idempotency guarantee actually holds under crash and retry, which needs fault injection rather than the example-based tests this domain reaches for.