Software Design Reference Master Designer · Complexity Trade-Offs

Lesson 0030 · Master Designer · Module 1

Generality vs Specificity

A general module is a bet on the future; a specific module is a fact about today. Ousterhout's law: generality is earned when it makes the specific case simpler — not when it adds cases.

Reading ≈ 8 minPractice ≈ 40 min

Mission tie-in: the last Complexity Trade-Off — and the one that decides how much machinery every module carries.

Knowledge: generality that pays vs generality that costs

The principle behind the principle

The two forces: reusable and future-proof (general) against simple and fit-for-purpose (specific). The unit of decision: the module — or more precisely one interface at a time, since generality is bought and paid for at interfaces.

The currency is parameters. Every parameter is a question the caller must answer and the reader must resolve, so the real axis is not general-versus-specific but interface width against implementation coverage. Ousterhout's test works because a deep module hides complexity: a general interface that is narrower than the specific one — fewer decisions pushed onto callers — is deeper, and therefore cheaper for everyone. A general interface that is wider has simply moved complexity out of the module and onto every call site.

Where "stay specific" stops applying. The rule assumes the decision is cheap to reverse, which is true of a function signature with one caller and false of a published boundary. A public API, a wire format, an on-disk schema, or a plugin contract for third parties cannot be narrowed later without versioning and migrating everybody, so a little generality there — a version field, a tolerant reader, an extension point — is cheap insurance rather than speculation. Two other exemptions: a second case that is known and dated ("the Germany launch is in the Q3 plan") is evidence, not a maybe; and specificity that bakes a false constraint into data — one currency in a schema, one address shape in a table — is not simplicity, it is a migration you have scheduled without telling anyone.

Two kinds of generality:

  1. Earned generality — the module solves today's case through a shape that naturally covers tomorrow's. It makes the current code simpler because the general interface is cleaner than the specific one.
  2. Speculative generality — parameters, hooks, and abstractions for cases that don't exist. It taxes every read of the current code to pay for futures that may never arrive.
# speculative: a "pipeline" framework for one transformation
class Transformer:
    def __init__(self, steps: list[Step], concurrency: int = 1,
                 retry: RetryPolicy | None = None, metrics: Metrics | None = None):
        ...

Transformer(steps=[normalize], concurrency=1, retry=None, metrics=None).run(data)

# earned: the general name covers the specific case, no extra machinery
def transform(data: list[dict]) -> list[dict]:
    return [normalize(row) for row in data]

The specific version is also the general one: any future step is a new function, composed (lesson 0016). The framework added configuration (lesson 0029), concurrency, and retries — none of which the one case needs.

The test, from Ousterhout: does the generality make the current problem simpler? If the general module is harder to use for today's case than the specific one would be, the generality is speculative. Generalize only when the second use case is real — then the general shape is usually visible (rule of three again).

Worked example: the export framework that was bypassed

Product asks for a CSV export of returns. The engineer proposes an Exporter framework: pluggable formats, streaming, configurable columns, so that "adding XLSX later is one class".

The senior designer's first move is to sort the decisions by reversibility, because that is the axis on which this trade actually turns. Three decisions are hiding in one ticket. The function signature has one caller and is free to change. The file format is written, downloaded and forgotten, so nobody is holding an old one. The stored per-tenant export configuration is neither: it is persisted, so every change to its shape is a migration across every tenant. The question they ask is "which of these can I not take back?" — and the answer is only the third.

So the framework is rejected, with a number attached. It is roughly 380 lines and five new concepts, read by everyone who touches export for as long as it exists. The second format arrived nine months later — and wanted a workbook rather than a stream of rows, so it did not fit the framework's row-at-a-time assumption anyway. The machinery was paid for over nine months and then bypassed. That is the characteristic failure of speculative generality: not that the future never came, but that it came in a shape the guess did not cover.

What shipped was one 30-line function. When XLSX arrived it became a second function and a two-line dispatch — about 75 lines in total, with the two formats' differences visible rather than dissolved into a shared shape they do not share (lesson 0028):

def export_returns(rows: list[ReturnRecord], fmt: str) -> bytes:
    if fmt == "csv":
        return _to_csv(rows)          # a stream of rows
    if fmt == "xlsx":
        return _to_xlsx(rows)         # a workbook: genuinely a different shape
    raise UnsupportedFormat(fmt)

Two branches and two honest functions. When a third format arrives and all three share a real step — say, redacting personal fields before writing — extract that step. Not the framework.

The rule the team wrote down afterwards is the one worth stealing: generalise code on the second real case; design persisted shapes and public contracts for the third. The two halves differ only in what it costs to be wrong.

† AI's default is speculative generality: ask for "a function" and get "a configurable framework". The prompt discipline from lesson 0010 — "solve this case, no parameters beyond it" — is the countermeasure.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
a default that no caller ever overridesGenerality nobody asked for, read by everybody.Delete the parameter; hardcode the one value.
**kwargs forwarded “for flexibility”The contract is now unknowable without reading the callee.Name the arguments you actually accept.
an interface with one implementationIndirection with nothing on the other side to vary.Call the concrete thing until case two exists.
a plugin system with two in-tree pluginsMachinery for an ecosystem that does not exist.Two functions and a lookup.
What people get wrong after reading this
"Speculative generality means writing too much code."
It means interface width. A twenty-line function with five optional parameters is more speculative than a two-hundred-line one with two required arguments, because the twenty-line one made five decisions everybody else's problem.
"YAGNI means never plan ahead."
YAGNI is about machinery, not about reversibility. Plan ahead precisely where you cannot take it back: persisted schemas, public APIs, message formats, anything a third party has already integrated against.
"An interface with one implementation is always wrong."
The fake in your tests is the second implementation. A port justified by determinism (lesson 0023) is earned even when exactly one adapter ever reaches production.
"The rule of three applies to generality too."
It applies to extraction from evidence you already have. For a published boundary, waiting for the third case means three incompatible versions in the wild and a migration you have to run for other people.

When this is the wrong move

Pay for generality up front at published boundaries — public APIs, wire formats, persisted schemas, plugin contracts — where the cost of being wrong is everyone else's migration rather than your afternoon. Pay for it when a second consumer already exists and is dated. And accept that a library has a larger generality budget than an application, because serving callers you have not met is its whole job: the same optional parameter that is speculative in your service is the reason someone can adopt your package without forking it.

Skill: earned or speculative?

Generality is earned when it:

A one-use transformation wrapped in a "pipeline framework" is:

The honest trigger for generalizing is:

Practice on your own code

Find a module with parameters or machinery no caller uses (defaults never overridden, hooks never hooked, config from lesson 0029 that's always the same). Remove the machinery — make the specific case the whole module — and check that every caller got simpler.

In the lab. The phase-3 checkpoint gives you the returns-resolution service with a configurable "resolution strategy" registry holding exactly one strategy. You strip the machinery, then argue the counter-case: which one of its shapes is persisted, and therefore should keep its version field.

Reveal: a de-generalization

A PaymentProcessor accepted webhook_url: str | None and idempotency_store: ...; every caller passed the same values. De-generalized: the webhook and idempotency behaviors became hardwired parts of the processor's contract; the parameters vanished; the constructor shrank from six lines of options to two required arguments.

Teach it back
  1. Explain to a junior, in two sentences, why generality is measured at the interface rather than in lines of code.
  2. Find one parameter in your own code this week that no caller ever overrides. Name its cost: how many readers have resolved that question, and what did it buy them?
  3. Predict what happens in a year to a persisted shape you designed for exactly today's case: what migration are you scheduling for your future self?

Your win

You can distinguish earned from speculative generality with the simplification test, and strip unearned machinery without losing capability.

Read and watch deeper

Bring a never-overridden parameter to your agent-teacher and de-generalize it together.