RequirementsGENERALLIFETIME-SPECIFICCONTESTED

Design for the Known, Name What You Assumed

You cannot design for requirements you do not have. You can design for the ones you do, and write down the assumptions you made — which is the difference between a decision and a habit.

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

If speculative design is a trap and unnamed assumptions are also a trap, what is left?

The requirement

"Import customer records from a CSV upload." Known: one file format, one source system, files under 5 MB, run by an internal operator. Unknown: everything after that.

The obvious build

Build it for one format now, but structure it so other formats will be easy: a parser interface, a format registry, a mapping configuration. It costs three extra days and saves weeks later.

Why it breaks

The second format, when it arrives two years later, is not a CSV at all — it is an API with pagination, so the parser interface abstracts over the wrong thing and has to be replaced anyway (Premature Abstraction).

How it breaks as requirements change
  • The second format, when it arrives two years later, is not a CSV at all — it is an API with pagination, so the parser interface abstracts over the wrong thing and has to be replaced anyway (Premature Abstraction).
  • In the meantime every reader has traced through a registry to find the one implementation, and every change to the single format has been made twice: once in the parser and once in the mapping config.
  • The registry also had to be tested, documented and maintained through two dependency upgrades, and none of that work was visible as cost because it was distributed across two years.
  • The opposite failure is equally real and worth stating: the version with no structure at all hard-codes the vendor's column names in the middle of a database write, so when the vendor renames a column the change is a three-day archaeology exercise instead of a one-line edit.
  • Both failures come from the same root — an assumption made silently. One assumed formats would vary; the other assumed they never would.
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
  • One engineer, two weeks, alongside other work.
  • The internal operator is a colleague who will tell you immediately if it breaks, which is a genuinely different situation from a customer-facing feature.
  • The source system is a vendor product whose export format is documented and has changed twice in five years.
Invariants
  • A partially-processed file never leaves the customer table in a state that is neither the old data nor the new — either the import applied or it did not.
  • An import can be re-run after a failure without duplicating records.
  • The operator can always find out what a given import did.

Who owns what, and where the seams fall

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

Responsibilities
  • Something owns parsing the vendor's file into your vocabulary, and that translation is the part that is genuinely known to be volatile — it has changed twice already (Anti-Corruption Layer).
  • Something owns applying records, and it should never see a column name.
  • Whoever writes the code owns naming the assumptions, in the code, where the next person will be standing when the assumption fails.
Boundaries
  • One boundary, in the place the evidence points: between the vendor's vocabulary and yours. That is not speculation — it is a response to two format changes in five years.
  • No boundary around "sources", because there is one source and no evidence of a second. The interface would have one implementation, which teaches nothing about what varies (The Rule of Three).
  • The assumption register is itself a boundary of a kind: it marks where the design's guarantees stop, which is exactly what a future reader needs to know (Architecture Decision Records).

The assumption register, and why it lives in the code

This is the whole practice, and it is smaller than it sounds. Each line states something the design depends on, in a form that can be observed to fail, together with what it costs when it does.

It goes in the module it constrains, not in a wiki, because the person who needs it is someone debugging at an inconvenient hour who will never think to search a wiki. That placement is the difference between this working and this being an artefact (Docs Close to Code).

  • Each line names an observation, not a feeling: "largest seen 1.9 MB" is checkable and "small files" is not.
  • Each names how it fails, because a failure you can recognise costs an hour and one you cannot costs a day.
  • Each names the fix and its size, which is what turns the register into an input to planning rather than a confession.
  • Line 2 is the important one: it records a boundary deliberately *not* built, with the reason. Without it, the next engineer cannot tell absence from oversight (Speculative Generality).
  • Line 4 is the one that will actually be hit, and it is the one where naming the assumption changes a decision rather than just a debugging session.
// import/README.md — kept next to the code it describes

ASSUMPTIONS. Each line: what we assumed, how it fails, what the fix costs.

1. Files fit in memory.
   Currently < 5 MB (largest seen: 1.9 MB, 2026-03).
   Fails as: OOM kill on the worker. Loud, which is deliberate.
   Fix: stream the parser. ~2 days, contained in parse.ts.

2. One source: Vendor X's documented CSV export.
   Fails as: someone asks for a second source.
   Fix: a second parser + a decision about whether it shares
        the applier. ~3 days. NOT pre-built: there is no
        second source and no evidence of one.

3. Vendor column names are stable within a major version.
   They have changed twice in five years (2022, 2025).
   Fails as: parse error naming the missing column.
   Fix: one edit in parse.ts. This is the one boundary we built.

4. The operator is trusted and internal.
   Fails as: this becomes a customer-facing upload.
   Fix: NOT small. Needs authz, per-tenant scoping, file
        validation, rate limits. ~2 weeks. If this is on the
        roadmap, come and talk before starting it.

5. Re-running an import is safe (upsert by vendor_id).
   Fails as: vendor reuses an id across customers.
   Fix: composite natural key. ~1 day + a backfill.

One unit, and the assumptions it is quietly holding

A responsibility map is the fastest way to find unnamed assumptions, because every "depends on" and every "changes when" is one. Run it on the module you are about to write, before you write it.

The verdict below is the interesting part: this unit is fine. Two reasons to change is not a finding, and the register is what keeps it at two rather than at six.

responsibilitiesCustomerImport — parse.ts plus apply.ts, roughly 200 linesThe import module as designed for the known
Knows
  • Vendor X's CSV column names and their meanings
  • How a vendor record maps to a Customer
  • That a re-run is an upsert keyed on vendor_id
Does
  • Reads a file into vendor records
  • Translates vendor records into Customers
  • Applies them in one transaction
  • Writes an import summary the operator can read
Depends on
  • The customer repository
  • The vendor schema (in one file, deliberately)
  • A clock, injected, for the summary timestamp
Changes when — 2 distinct reasons
  • The vendor changes their export format
  • The meaning of a Customer field changes

Two reasons to change, and they are the two the evidence supports — the vendor has changed their format twice, and the customer model changes with the product. Everything else that could have been a reason to change was kept out by not building it: no source registry, no mapping DSL, no plugin points. The register is what makes that defensible rather than lazy, because it records the absences as decisions with a price attached.

Two ways to be wrong, and the version that is neither

CONTESTEDA serious counter-argument holds that the registry version is cheap insurance and that critics only ever cite the cases where it was wrong — nobody writes a blog post about the plugin point that quietly paid off. That is a fair complaint about the evidence base on both sides. What tips it here is specific rather than general: a plugin point built before the second case is a guess about the *axis* of variation, and the failure mode is not that it goes unused but that the real variation lands on a different axis and the structure becomes an obstacle. Insurance that is void in the case you claim on is not cheap.

The speculative version and the assumption-free version fail differently and are usually written by different people, which is why the argument between them is so persistent. The third version is not a compromise; it is a different move — build the smaller thing and record what you are betting on.

Structure for imagined variation vs structure for observed variation
Built for a second source that did not come
// A registry, an interface, and exactly one implementation.

interface SourceParser<T> {
  supports(mime: string): boolean
  parse(input: Readable): AsyncIterable<T>
}

const registry = new ParserRegistry()
registry.register(new VendorXCsvParser())   // the only one, for 2 years

// mapping.yaml — so mappings can change "without a deploy"
//   customer_ref: id
//   cust_name:    name

// Cost paid every day: a reader tracing a column name goes
// through a registry, an interface and a YAML file to reach
// one function. Cost paid once: the real second source was
// a paginated API, so none of this fitted it anyway.
Built for the variation that has actually happened
// One boundary, where the evidence points: vendor vocabulary
// on one side, ours on the other.

// parse.ts — the ONLY file that knows vendor column names.
// See README assumption 3: format changed 2022, 2025.
export function parseVendorCsv(text: string): VendorRecord[] { /* ... */ }

// apply.ts — never sees a column name.
export function toCustomer(r: VendorRecord): Customer { /* ... */ }

// No registry. No mapping file. A second source, if it ever
// arrives, is a second parse function and a decision about
// whether it shares apply.ts — recorded as assumption 2,
// costed at ~3 days, deliberately not pre-built.

Both versions isolate the vendor's vocabulary, which is the change that has actually occurred twice. Only the first one also pays for a plugin point, a configuration format and a level of indirection, for a variation with no evidence behind it — and when the real second source arrived it was the wrong shape, so that structure had to be removed before the change could be made. The second version is not smaller because it is lazier; it is smaller because the register carries what the registry was pretending to carry, at a fraction of the cost and without lying about which boundaries exist (Over-Design and Under-Design).

How to build it

Most important first.

  • Enumerate what is actually known: one format, one source, small files, trusted operator, documented schema that has changed twice. That list is short and it is the whole design input.
  • Build precisely for that, with the boundary the evidence supports and no others (YAGNI, With Its Bill Attached).
  • Write down each assumption next to the code that depends on it, phrased so it can be observed to fail: "assumes files fit in memory — they are currently under 5 MB", not "small files".
  • For each assumption, note what breaks and what the fix costs, roughly. That is the sentence that turns a comment into a decision (The Cost of Change).
  • Prefer assumptions that fail loudly. A file-size assumption that produces an out-of-memory crash is better than one that silently truncates, and choosing which is often free at design time.

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
  • A vendor column rename costs one edit in the parser and one test. Under the no-boundary version it costs finding the column name in however many places it reached — typically the parser, a validation rule and a SQL statement — plus the discovery time to be sure.
  • A genuinely new source — the API with pagination — costs a new module and a decision about whether it shares the applier. Under the speculative registry design it costs the same, *plus* removing the registry that abstracted the wrong axis. Speculative structure does not merely fail to help; it is an obstacle to the change that arrives.
  • Files growing past memory costs a rewrite of the parser into a stream. The assumption register makes that a scoped, estimable change instead of an investigation, which is most of the value the register provides.
  • What this design gives up: when a second CSV-shaped source does arrive, the team pays the extraction they could have done up front, under whatever deadline that source came with. That is a real cost and it is the strongest argument against this entire lesson.
What the recommended approach costs
  • Designing only for the known means occasionally paying an extraction under deadline pressure, which is worse than having done it calmly. There is no version of this advice where that cost is zero.
  • Writing assumptions down is a discipline with no enforcement, and disciplines with no enforcement decay. The honest expectation is that half of them will be stale in eighteen months.
  • And "the known" is not a fixed set — it depends on how hard you looked. A team that asks nobody knows very little and can call that minimalism.

What can go wrong

Failure modes
  • The assumptions are written and never checked, so the register becomes a list of things that used to be true — the standard failure of any documentation not exercised by the build.
  • An assumption is stated too vaguely to fail: "assumes reasonable input" cannot be observed to be violated and therefore records nothing.
  • The register is used to justify shortcuts. "Assumes single tenant" written next to code that will obviously be multi-tenant is not a design decision, it is a note about a bug someone chose (Accidental Debt).
  • The assumption fails at 3am to somebody who has never read the file it is written in. Naming an assumption reduces the cost of the failure; it does not prevent it, and a design that relies on the note being read is relying on the weakest link available.
Dependencies, and their direction
  • The applier depends on your domain vocabulary and nothing else, which is what makes a format change a change to one file.
  • The parser depends on the vendor's schema — deliberately, and in one place, so the dependency is visible rather than distributed (Dependency Direction).
  • Nothing depends on the assumption register, which is why it decays. That is its known weakness and the reason it has to live in the code rather than in a wiki (Documentation Decay).
Misreads
  • "This is just YAGNI." YAGNI says do not build it. This lesson adds the second half: do not build it *and* say what you assumed, because the unstated assumption is what makes the eventual change expensive (YAGNI, With Its Bill Attached).
  • "So never build for the future." Some assumptions are cheap to make safe now and impossible later — an identifier scheme, a retention decision, keeping an effective date. Those are worth building even without a named requirement (The Cost of Change).
  • "A comment is not a design decision." A comment stating what breaks and what the fix costs is a decision record that happens to live in a file. The format is not the point (Docs Close to Code).
  • "Naming the assumption makes it safe." It does not. It converts an unbounded investigation into a scoped change, which is worth a great deal and is not the same as safety (Interest: Why Debt Compounds).
Smells this explains
  • speculative-generality

Testing it, and how it ages

What to test, and at which boundary
  • Test the parser against real vendor files, including the two historical format versions, because the documented schema and the emitted files differ (Characterization Tests).
  • Test the applier with no file involved at all — it should take records, not bytes (What a Unit Is).
  • Write one test per stated assumption where it is cheap: a file over the size limit should fail with a recognisable error rather than an out-of-memory kill. An assumption with a test is no longer an assumption.
  • Do not test the abstraction you did not build. This sounds obvious and is the most common way speculative structure sneaks in through the test suite.
How this design ages
  • The design ages exactly as well as its assumptions hold. That is not a weakness — it is true of every design, and the only variable is whether anyone can tell which assumption failed.
  • Assumptions expire in a predictable order: scale first, then format, then trust. Files get bigger before the vendor changes anything, and the operator becomes a customer-facing feature last.
  • The register should shrink over time. An assumption that has been tested for two years is a property; one that has been silently violated is a bug; and either way it should stop being listed as an assumption (Documentation Decay).

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.

  • GENERALEvery design rests on assumptions whether or not they are stated; the only variable is whether a future engineer can identify which one failed, which is a documentation property rather than a language or paradigm one.
  • LIFETIME-SPECIFICFor code with a known short life the register is waste, because nobody will be standing there when the assumption fails. It earns its keep from roughly the point where the author will not be the person who next changes the code — which is usually much sooner than authors expect.
  • CONTESTEDThe strongest opposing view: a small number of structural decisions — persistence seams, effect boundaries, identifier schemes, tenancy — are so much cheaper to make now than to retrofit that expected-value reasoning systematically underrates them, because retrofitting them is not merely expensive but politically impossible once a codebase is large and a roadmap is committed. That is sound for a genuinely short list, and the difficulty is that the same argument is used to justify a much longer one. The test worth applying is whether the retrofit requires data that was never recorded; if it does, build it now, and if it does not, wait.

Where the depth lives

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

Domains that do not exist yet
  • Testing & Reliability Engineering — turning an assumption into a test is how a register stops decaying, and which assumptions are worth that investment is a coverage question this domain does not answer.