EvolvabilityGENERALDOMAIN-SPECIFICCONTESTED

Extensibility

Extensibility is cheap along one axis and expensive along every other, so the only real question is which variation you have actually observed.

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

Should I build an extension point here, and if so, for which kind of variation?

The requirement

A product manager asks that the export feature be "extensible, so we can add new formats later". Today there is one format, CSV. Nobody can name a second one or a date.

The obvious build

Define an ExportFormat interface with header(), row() and footer(), register implementations in a map, and drive it from a config string. Then a new format is a new class.

Why it breaks

The interface has one implementation, so its shape is a guess. The first real second format is JSON, which has no concept of a row-by-row header and footer, and the interface has to be redesigned anyway — at which point it cost more than having no interface (Premature Abstraction).

How it breaks as requirements change
  • The interface has one implementation, so its shape is a guess. The first real second format is JSON, which has no concept of a row-by-row header and footer, and the interface has to be redesigned anyway — at which point it cost more than having no interface (Premature Abstraction).
  • The axis is wrong more often than it is right. The variation that actually shows up is usually "the same CSV, but only these columns, for this customer" — a filtering requirement that the format abstraction does nothing for and slightly obstructs.
  • Every reader now traverses a registry lookup and a virtual call to answer "what does the export produce", which is a permanent tax on the ninety percent of work that is not about formats (Local Reasoning).
  • Registered-by-config extension points invite behaviour to be added without any caller changing, so a reader cannot find all the formats by following calls — the price is paid in traceability as well as indirection.
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 team is five people; a structure only pays if all five find it obvious without being told about it.
  • Exports run in a background job with a strict memory ceiling, so any indirection that materialises a whole result set is not viable (Cost-Aware Interfaces).
  • The feature ships in two weeks, and the extensibility work comes out of that same two weeks.
Invariants
  • Whatever an export produces, it is a faithful serialisation of the same data the UI shows for the same filters — one query, one truth.
  • Adding a format never changes the meaning of an existing one.

Who owns what, and where the seams fall

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

Responsibilities
  • One module owns "what data an export contains", which is the part that recurs, and it should own it before anything owns formatting.
  • Serialisation owns turning that data into bytes and owns nothing about which rows were selected.
  • Whoever proposes the extension point owns naming the second case; if they cannot, the proposal is not ready (The Cost of Change).
Boundaries
  • Put the boundary where you have *seen* the variation. One observed axis beats three imagined ones, every time (Design for the Known, Name What You Assumed).
  • A boundary between "which rows and columns" and "how they are encoded" is defensible from the start, because those two genuinely have different reasons to change — that is a claim about the domain, not about future formats.
  • A boundary around "format" specifically is only defensible once two formats exist and their differences are visible (The Rule of Three).

One implementation is not evidence of anything

An interface with a single implementation records a belief about what will vary. It cannot record knowledge, because there is nothing to compare against — the shape is derived from the one case in front of you, which is exactly the case that would not have needed an interface.

The direct version below is not sloppy. It is honest: it says there is one format, and it will be trivial to change when that stops being true. The abstract version says there are many formats and quietly commits to a decomposition — header, row, footer — that the second real format will not share.

Export, before a second format exists
Extension point built on a guess
interface ExportFormat {
  header(cols: string[]): string
  row(values: string[]): string
  footer(): string
}

const FORMATS: Record<string, ExportFormat> = { csv: new CsvFormat() }

export function runExport(name: string, rows: Row[]) {
  const f = FORMATS[name] ?? fail(`unknown format ${name}`)
  // ... drive f.header / f.row / f.footer
}

// One implementation. The row-at-a-time shape is a guess,
// and JSON, XLSX and PDF each break it differently.
The split you can actually justify today
// export/select.ts  — what goes in the file
export function selectRows(f: Filters): Row[] { /* ... */ }

// export/csv.ts     — how it is encoded
export function toCsv(rows: Row[]): Buffer { /* ... */ }

// export/job.ts
const rows = selectRows(filters)
return toCsv(rows)

// No interface, no registry. Selection and encoding are
// separated because they have different reasons to change
// TODAY — not because a second format might appear.

Both versions cost about the same to write, and they differ in what they commit to. The second commits to a split justified by present reasons to change: the columns in an export move when the product moves, and the encoding moves when a format spec moves. The first additionally commits to a decomposition of formats derived from one sample, and that commitment has to be undone before the second format can be added — so the guess makes the change it was built for more expensive rather than less.

What to do while you wait

Waiting is not the same as doing nothing, and this is where the advice is usually misapplied. The parts you can justify now — separating selection from encoding, keeping the export out of the request path, giving the job an id you can trace — all get built now. What waits is specifically the machinery whose *shape* depends on a variation you have not seen.

The loop below is what "wait for the second case" actually means in practice. The step teams skip is the last one: having deliberately duplicated, they never come back, and the duplication becomes the thing they were warned about.

From one case to an extension point
  1. 1
    Build the first case directly

    One function, no interface, no registry. Separate the parts that have different reasons to change today.

    fails by Adding the interface here, where the only available evidence is the case that did not need one.

  2. 2
    Write down the axis you suspect

    A line in a decision record: "if a second export format appears, extract an encoder seam". Costs a minute and makes the guess reviewable.

    fails by Keeping it in your head, so the next person either rebuilds the reasoning or does the opposite.

  3. 3
    Wait for a real second case

    A requirement with a requester and a date, not a hypothetical.

    fails by Counting an imagined case as the second one, which is how the rule gets quietly cancelled.

  4. 4
    Implement the second case directly too

    Duplicate on purpose. Now both shapes are visible side by side and the differences are data rather than speculation.

    fails by Abstracting during the second implementation, before the differences have surfaced.

  5. 5
    Compare, then extract

    Extract the shape the two implementations actually share, and only that shape.

    fails by Extracting a superset "while we are in here", which reintroduces the guess at the point it could have been avoided.

  6. 6
    Delete the duplication

    Migrate both onto the extracted seam and remove the copies.

    fails by Stopping after the extraction, leaving the seam and both copies live — the most common way this loop fails (Incremental Migration).

The loop is asymmetric on purpose: building early costs an interface you must undo, while building late costs an extraction done with full information. Those are not the same size of mistake, which is the entire case for waiting.

Choosing the mechanism, once you have the evidence

SCALE-SPECIFICAt five engineers in one repository, a switch statement is a perfectly good extension mechanism because everyone who needs to add a case can edit the file and will see the others while doing it. At fifty engineers across several teams, editing a shared file becomes a merge-conflict and review-queue problem, and the registry starts to pay for itself on coordination grounds alone — the same code, the same variants, a different right answer.

When the variation is real, there is still a choice about how much machinery it deserves, and teams reach for the heaviest option far more often than the evidence supports. A switch statement is an extension mechanism. So is a parameter.

Order the options by how much they cost to remove, and prefer the cheapest one that covers the observed cases. The heavier options buy the ability for *someone else* to extend, which is a different requirement and should be argued for separately (Plugin Architecture).

The variation is real. How much machinery does it deserve?

Who needs to add the next variant, and how far do they live from this code?

A parameter or a data table

when The variants differ only in values — tax rates by country, retry limits by tier, column sets by plan.

cost Nearly free and nearly free to undo. The cost is that the data now needs its own validation and its own review path, because a wrong row is a production bug with no compiler between it and users.

A switch or a match on a closed set

when Two to five variants, all known, all in this codebase, and you want the compiler to tell you when one is missing.

cost Adding a variant edits an existing file, which offends open-closed as usually stated. In exchange you get exhaustiveness checking and a single place to read every case — usually the better trade below about five variants (Replace Conditional With Polymorphism describes when it stops being).

A strategy interface with a small registry

when Several variants that differ in behaviour, added by the same team, and the switch has started to appear in more than one place.

cost Indirection on every path and behaviour that can no longer be found by following calls. Justified by the second copy of the switch, not by the first (Strategy).

A published extension API

when People outside the team, or outside the company, must add variants without changing your code.

cost Permanent: versioning, lifecycle, isolation, documentation and compatibility. This is a product decision with an ongoing budget, not a refactor (Plugin Architecture).

Nothing yet

when One case, and no named second one.

cost You will occasionally pay an extraction under deadline pressure. That is the honest price of not guessing, and it is smaller than it feels (YAGNI, With Its Bill Attached).

How to build it

Most important first.

  • Write the first format directly, with no interface and no registry. It is a function from a query result to bytes.
  • Separate selection from serialisation immediately, because that split is justified by present-day reasons to change rather than by a guess.
  • When the second format is actually requested, write it directly too — duplicated, deliberately. Two concrete implementations show you which parts genuinely vary and which merely looked similar (The Rule of Three).
  • Extract the shared shape only after the second, and shape the interface from what the two implementations actually needed rather than from what seemed likely (What an Abstraction Actually Is).
  • If the second case is externally forced and near-certain — a regulator that publishes a fixed file layout, a partner integration in a signed contract — build for it now. The rule is about evidence, not about waiting on principle.

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
  • With no extension point: the second format costs one new function plus the extraction you deferred — roughly a day for an export, and it is done with both cases visible, which is when the extraction is most likely to be right.
  • With the guessed extension point: the second format costs redesigning the interface, migrating the one existing implementation onto it, and re-testing both — more than the direct route, because you are undoing a guess as well as adding a feature.
  • With the extension point after two real cases: the third format costs one file and one registration, and this is where the pattern genuinely pays.
  • The cost that never shows up on either side of the ledger: every unrelated change to exports pays the indirection tax from the day the extension point exists until the day it is removed.
What the recommended approach costs
  • Waiting for the second case means occasionally paying an extraction under deadline pressure. That is a genuine cost and the strongest argument against this advice.
  • Two concrete implementations mean real duplication in the interim, which will be flagged in review by anyone applying DRY as a line-count rule (DRY: Knowledge, Not Lines).
  • Demanding a named second case slows down engineers whose instincts are usually good, and some of that friction is pure waste.

What can go wrong

Failure modes
  • The extension point is built, no second format ever arrives, and it is never removed because removing it is also work and nobody is rewarded for deletions.
  • The extension point is built on the wrong axis, and the real requirement is forced through it, producing something worse than either the abstraction or the direct code.
  • A second implementation arrives and only *nearly* fits, so the interface grows an optional flag; a third arrives and it grows another. The interface becomes a union of every implementation's needs (Interface Segregation, Critically).
  • The mitigation fails too: a team that has been burned adopts "never abstract before three cases" as a rule, and then does not abstract the tax-jurisdiction case where the second, third and fourth were externally guaranteed from day one.
Dependencies, and their direction
  • A concrete export depends on the query layer and on nothing else, so it is trivially testable and trivially deletable.
  • An extension point creates a dependency in the awkward direction: existing code now depends on a shape defined for hypothetical callers (Dependency Direction).
  • A registry adds a lifecycle dependency — something must register before something else runs — which is temporal coupling the type system will not catch (Temporal Coupling).
Misreads
  • "So never build extension points." Sometimes the second case is contractually guaranteed, and sometimes retrofitting is not merely expensive but structurally impossible. The test is evidence, not a prohibition.
  • "Extensible means configurable." Configuration moves a decision from code to data and takes the compiler and the tests with it. That is a real trade and it is frequently the worse half (Explicit State).
  • "Open-closed means adding an interface." OCP describes a property some designs have for some axes of change; it is not an instruction to place an interface at every point where change is conceivable (Open/Closed, Critically).
  • "We can always remove it later." Extension points are among the hardest structures to remove, because every consumer is a reason to keep it and consumers accumulate quietly (Reversible and Irreversible Decisions).
Smells this explains
  • long-parameter-list

Testing it, and how it ages

What to test, and at which boundary
  • Test each concrete export end to end against a golden file. Format output is exactly the case where an exact-match test is cheap and precise (Characterization Tests).
  • Test the selection logic separately from serialisation, because that is the boundary you actually believe in (What a Unit Is).
  • If an extension point exists, test that an unregistered format fails loudly at startup rather than at 3am inside a background job (Validate at Startup, Fail Loudly).
How this design ages
  • The most common trajectory is that a second format never arrives and the export instead grows filters, column selection and scheduling — variation on a different axis entirely.
  • When a second format does arrive, the direct implementations make the extraction easy, so nothing was lost by waiting; that asymmetry is the whole argument.
  • Once three or more formats exist and are maintained by different people, the extension point stops being an internal convenience and starts being a contract, with the obligations that implies (Plugin Architecture).

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 an extension point buys flexibility on exactly one axis and charges indirection on all of them is a structural fact, not a stylistic preference, and holds in every language that has a way to vary behaviour at all.
  • DOMAIN-SPECIFICWhere variation is externally imposed and enumerable — payment methods per market, statutory report layouts, carrier integrations — the second case is guaranteed and building early is correct. In a product where variation comes from your own roadmap, the same reasoning produces machinery for formats nobody requests, because your roadmap is a preference and a regulator is not.
  • CONTESTEDThe strongest opposing view is that the rule-of-three discipline systematically under-builds: by the time the second and third cases arrive, the direct code has been copied into places you have forgotten, the extraction now spans several teams, and the "cheap later extraction" was only cheap in the example. Practitioners who hold this view point out that the cost of a small unnecessary interface is bounded and visible, while the cost of a missing one compounds silently — which is a fair description of the asymmetry, and it argues for building the cheap version of the seam early rather than for building the full extension point.

Where the depth lives

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

Domains that do not exist yet
  • Programming Languages & Runtime Internals — how much an extension mechanism costs at runtime, and what a compiler can still check across it, differs enormously between a virtual call, a sum type match and a dynamically loaded module.