RequirementsGENERALDOMAIN-SPECIFICCONTESTED

The Requirements Nobody States

Timezones, concurrency, partial failure, retention and tenancy are almost never written in the ticket, are almost always real, and are structural — which is the worst combination available.

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

Which requirements will I discover late, and why is that specific set always the same one?

The requirement

"Send each customer a weekly summary email on Monday morning." Everyone agrees on what this means, and nobody in the room can say which Monday morning, in whose timezone, what happens if the job runs twice, how long the summaries are kept, or whether a customer belongs to one account or several.

The obvious build

It is a cron job and a query. Select customers, build a summary, send. The requirement is one sentence and the implementation should be about the same length.

Why it breaks

It ships in one timezone and works. Then the company opens in Australia and "Monday morning" is Sunday afternoon for the job, which means the summary covers the wrong week — and the fix is not a configuration change, because the *week boundary* is now per-customer and it is baked into the query (Units in Names and Types).

How it breaks as requirements change
  • It ships in one timezone and works. Then the company opens in Australia and "Monday morning" is Sunday afternoon for the job, which means the summary covers the wrong week — and the fix is not a configuration change, because the *week boundary* is now per-customer and it is baked into the query (Units in Names and Types).
  • A deploy restarts the job at 40% through. The scheduler retries, and 40% of customers get two emails. Nobody designed a resume point because nobody wrote down that the job could be interrupted (Idempotency by Design).
  • Support ask for last month's summary to check a complaint. It was never stored — only sent — and reconstructing it means re-running a query against data that has since changed. The retention requirement existed from day one and was never spoken.
  • A customer with access to two accounts gets one email containing both, or two emails containing everything. Either way the tenancy requirement was decided implicitly, by whoever wrote the join.
  • The characteristic property of all four: each is invisible until it is expensive, and each requires a structural change rather than a fix. That is why they are worth a lesson of their own.
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 scheduler already exists and fires jobs in UTC; it has an at-least-once guarantee that nobody has read closely.
  • Customers are in eleven countries, and the two largest markets observe daylight saving on different dates.
  • The email provider bills per send and deduplicates nothing.
Invariants
  • A customer receives at most one summary per week, even if the job runs twice or a redeploy interrupts it halfway.
  • "Monday morning" means Monday morning where the customer is, not where the server is.
  • A summary never contains data from an account the customer is not a member of.

Who owns what, and where the seams fall

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

Responsibilities
  • Something must own "which week is this, for this customer" — a single function taking a customer and an instant, not a WHERE created_at > now() - interval '7 days' scattered through queries.
  • Something must own "has this customer already been sent this week's summary", and it must own it durably, because the answer has to survive a restart.
  • Something must own what a summary *was* at the moment it was sent, separately from what the data says now.
  • Something must own which accounts a customer may see, and it must be the thing that builds the query rather than a filter applied afterwards (Backend Engineering has the enforcement patterns; here it is a question of which unit builds the query).
Boundaries
  • The timezone boundary is at the edge: instants inside, local calendar dates only where a human is involved. Every codebase that gets this wrong got it wrong by letting a local date in (Boundary Adapters).
  • The idempotency boundary is around the send, not around the job. A job that is idempotent as a whole but sends inside a loop is not idempotent for the customer in the middle of the loop.
  • The tenancy boundary belongs in whatever constructs a query, so that a query without a tenant is not expressible rather than merely discouraged.

The five, and why each is structural

What makes these different from ordinary missing requirements is the last column. A missing field is a migration; a missing tenancy model is a change to every query and every row, and possibly to data that no longer contains the answer.

Read the table as a prompt rather than a reference. The question to ask on your own feature is which of these five has an answer that nobody in the room can give.

Hidden requirementThe question nobody asksWhat the naive design assumesWhy the retrofit is structural
Timezone and calendar"Monday morning" where?One zone, and that a day is 24 hoursThe week boundary becomes per-customer, so every date comparison in code and SQL is now potentially wrong and each must be judged individually.
Concurrency and re-entryCan two of these run at once?One instance, one run, no overlapCorrectness now depends on a durable unit key and a lock or a claim, which is state that did not exist (Concurrency by Design).
Partial failureWhat if it stops halfway?Operations either happen or do notThe unit of work has to become independently completable, which usually means the loop and the effect swap places.
Data retentionHow long must we keep it, and must we delete it?Forever, implicitlyRequires knowing what a record *was* at a point in time, which an in-place-updated row cannot answer at any price.
Multi-tenancyWhose data is this?One organisation, one customer, one ownerEvery query needs a tenant, and existing rows may not record one — a data problem before it is a code problem (Security Engineering owns the isolation model; here it is a question about who owns a row).

The shape of the mistake, in eight lines

Every one of the five produces code that looks correct in review. This is the version that gets approved, and the reason it gets approved is that nothing in it is wrong — it is under-specified, which reviews are much worse at catching than incorrectness.

Four hidden requirements in six lines of plausible code
1// Approved without comment in more than one real code review.
2async function sendWeeklySummaries() {
3 const customers = await db.query('SELECT * FROM customers WHERE active')
4 for (const c of customers) {
5 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
6 const rows = await db.query('SELECT * FROM events WHERE customer_id = $1 AND at > $2', [c.id, since])
7 await email.send(c.email, render(rows))
8 }
9}
10
11// timezone: "7 days ago" from the server. Not this customer's week.
12// Also not 7*24h across a DST boundary.
13// concurrency: two schedulers, or one slow run overlapping the next.
14// partial: crash at customer 4,000 of 10,000 -> re-run sends 4,000 twice.
15// retention: what was sent is nowhere. Only what the data says now.
16// tenancy: events joined by customer_id. Which account? All of them?

The instructive part is that no line is a mistake. Each is the simplest correct expression of what the ticket said, which is why "review it carefully" does not catch this class — the reviewer would have to supply the missing requirement, and the reviewer read the same ticket.

A smell that means "a hidden requirement was answered by accident"

These requirements leave a signature in the code long before they cause an incident, and it is worth learning to see it: a value that carries no unit, no zone and no owner, used in a comparison that decides something.

The smell is a question, not a verdict, and the question is always the same one — is this the same kind of thing as the other side of the comparison?

smellBare primitive at a decision point

looks like A raw Date, string or number compared against another raw value to decide behaviour: if (event.at > since), if (row.tenant === user.tenant), if (amount > limit). No type distinguishes a UTC instant from a local date, an internal id from an external one, or cents from dollars.

suggests A hidden requirement has been answered implicitly. Somebody decided that these two values are comparable, and that decision is recorded nowhere — so the next person who supplies one of them from a different source will supply the wrong kind and nothing will object.

fix Give the value a type that carries the answer: Instant versus LocalDate, TenantId versus UserId, Cents versus Amount. Then the comparison either type-checks or it does not, and the hidden requirement has become a compile error instead of a support ticket (Units in Names and Types).

when this is fine It is genuinely fine when both sides provably come from the same source in the same function, and the whole comparison is local — a loop index against a length, a retry counter against a maximum. The smell is about values that travel: crossing a module boundary, a serialization boundary or a storage round-trip is what makes the missing type expensive, and a comparison that never leaves ten lines of code does not need a branded type to defend it.

How to build it

Most important first.

  • Ask the five questions explicitly, every time, as a checklist, because they are precisely the ones nobody volunteers: what time is it and whose; can this run twice; what if it stops halfway; how long must we keep it; who is allowed to see it.
  • Represent time as instants plus an explicit zone, never as a naive local timestamp, and put the zone in the type so a naive one cannot be passed by accident (Units in Names and Types).
  • Make the unit of work small enough to be individually idempotent, then make the outer loop resumable. "Per customer, per week" is a natural key and it is right there in the requirement.
  • Decide retention as a number, because "keep it" and "keep it for two years" have different storage designs and different privacy consequences.
  • Ask who the data belongs to before writing the first query, because retrofitting tenancy is a change to every query and every row (Consistency Boundaries).

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
  • Designed in: opening a new market costs one row in a country table. The weekly boundary is already per-customer, so nothing in the query changes. Adding a second summary type costs a new unit key and reuses the resume logic. Roughly a day each.
  • Retrofitted: making the week boundary per-customer means finding every date arithmetic in the codebase — typically a dozen sites, several of them in SQL and therefore invisible to the compiler — and deciding, per site, whether it was ever meant to be UTC. Two to four weeks, and the risk is that a site you miss produces subtly wrong summaries that nobody reports because they look plausible.
  • Retrofitting tenancy is the worst of the five, and it is worth being specific about why: it is not only a code change but a data change, because existing rows may not record which tenant they belonged to. That is not a migration, it is an archaeology project (Data Migration).
  • What designing all five in costs, honestly: the one-sentence feature becomes about two hundred lines with a keys table, a resume cursor and a zone-aware clock. For a company in one country with one account per customer and a job that has never been interrupted, that is over-design and this lesson is telling you to build it anyway. The defence is the asymmetry, not the probability.
What the recommended approach costs
  • Designing for all five up front is genuinely more code for a feature that may never need it, and a reviewer comparing it against the one-sentence version is right that it is bigger.
  • The zone-aware clock and the branded date types add friction to every piece of ordinary date handling, most of which never cared.
  • And the checklist can crowd out thought: five questions asked reliably is better than none, but it is a floor and it is easy to mistake for a ceiling — the sixth hidden requirement in your domain is not on it.

What can go wrong

Failure modes
  • Timezone handled by storing local times "because that is what users see", which loses the instant and cannot be recovered.
  • Idempotency handled by a "sent" flag written after the send, so a crash between the two produces a duplicate — the flag protects the wrong interval (Partial Failure).
  • Retention handled by a delete job that nobody tests, so it either does not run or deletes the wrong thing, and both are discovered by an auditor.
  • Tenancy handled by a filter in a base class that a new query bypasses, which is the classic case of an invariant enforced in a place that can be avoided (Invariant Leaks).
  • The checklist itself becomes the failure: five questions asked mechanically, answered "n/a", and the "n/a" is never revisited when the answer changes.
Dependencies, and their direction
  • A timezone-correct design depends on a timezone database, which updates several times a year — a dependency with a release cadence you do not control (Dependency Management).
  • An idempotent job depends on durable per-unit state, which is a table someone must own and clean up.
  • A tenancy-aware design depends on tenant identity being available everywhere the data is, which is a context-propagation problem more than a filtering problem (Capability Passing).
Misreads
  • "These are edge cases." An edge case happens rarely. A customer in another timezone is not rare; they are simply not present yet, which is a different thing entirely.
  • "We will add tenancy when we get an enterprise customer." Tenancy retrofits are data migrations, and the enterprise customer arrives with a deadline. This is the one on the list where "later" is most often a decision to never (The Cost of Change).
  • "The scheduler guarantees exactly-once." No scheduler does. Read the guarantee; it is at-least-once with a nice interface, and your job is where the difference is absorbed (Backend Engineering covers the delivery semantics; the design point is that your job absorbs the difference).
  • "Storing local time is fine because that is what the user sees." Display is a rendering decision at the edge. Storage is a fact about when something happened, and a local time without a zone is not that fact (Optional Values and Absence).
Smells this explains
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • Test the week boundary at DST transitions specifically, in both hemispheres. A test that only exercises January passes under a broken design.
  • Test the job with an injected failure partway through, then re-run, and assert the send count. This is the only test that distinguishes a correct idempotency design from a plausible one (Designing for Failure).
  • Test that a query without a tenant does not compile or does not run. A test that a query *with* a tenant works tells you nothing about the query someone writes next year.
  • Test retention by asserting the delete actually deletes, on a fixture older than the window. Retention code is the least-run code in most systems.
How this design ages
  • These requirements do not appear, they surface. The timezone requirement existed on day one; what changed was that someone finally noticed. That is why "we will add it when we need it" mis-describes the situation.
  • They also arrive in a predictable order, roughly: concurrency first (the first retry), then timezone (the first market), then tenancy (the first enterprise customer), then retention (the first auditor or the first deletion request).
  • The design ages well if each is isolated behind something with a name, and badly if each is a convention. Conventions do not survive team turnover, which is the mechanism by which a correct system slowly stops being correct.

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.

  • GENERALThe five recur across languages, industries and decades because each is a property of the world rather than of the software — clocks are local, machines restart, data outlives its purpose, and customers belong to organisations. What differs is which arrives first for you.
  • DOMAIN-SPECIFICThe list is not universal. A single-tenant scientific pipeline has no tenancy requirement and a genuinely enormous retention one; a real-time trading system's hidden requirement is ordering, which is not on this list at all. Take the five as the common core and go find your domain's sixth.
  • CONTESTEDThe strongest opposing view: building for all five on every feature is exactly the speculative generality this domain warns about elsewhere, and a team that ships the simple version and fixes it when the second market arrives will have shipped ten more features in the meantime. That argument is strong for the ones that are cheap to retrofit — a resume cursor can be added to a job in an afternoon — and weak for the two that involve stored data, tenancy and retention, where the retrofit requires history that was never recorded. The honest position splits the list rather than defending all of it.

Where the depth lives

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

Domains that do not exist yet
  • System Design — how the same five surface at the system grain: clock skew between nodes, at-least-once delivery between services, and retention as a storage-tier decision rather than a table.