Premature Abstraction
The wrong abstraction costs more than the duplication it replaced, because duplication is visible and a wrong shared unit is not. Its signature is callers that diverged and a parameter list that grew flags to hold them together.
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.
Why is a wrong abstraction more expensive than the duplication it removed, and what do I do once I have one?
A shared NotificationSender was extracted eighteen months ago from two email flows. It now serves six callers, takes nine parameters — four of them booleans — and every change to it requires regression-testing all six.
It is shared code with high coverage. Fixing it means either living with the flags or a rewrite, and neither is worth the risk — add the seventh parameter and move on. This is a reasonable position, which is why the flags reached nine.
Each flag multiplies the states the unit can be in. Nine parameters with four booleans is sixteen behavioural combinations, of which six are exercised and ten are reachable and untested (Boolean Flag Explosion).
- Each flag multiplies the states the unit can be in. Nine parameters with four booleans is sixteen behavioural combinations, of which six are exercised and ten are reachable and untested (Boolean Flag Explosion).
- Every change requires understanding all six callers, so the coupling the abstraction created is now the dominant cost of touching it (Change Amplification).
- The tests are written against the abstraction, so they encode its current shape and resist the change that would fix it (Testing as Design Feedback).
- The damage is asymmetric and this is the key point: duplication is visible in a diff and a wrong abstraction is not. Nobody reading caller three can see that their change will alter caller five, so the bug it produces is silent (Local Reasoning).
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.
- All six callers are in production and owned by three different teams.
- The unit has good test coverage, all of it written against the shared abstraction rather than against what each caller needs (Mocking).
- Removing it looks like regression to anyone who was not there when the flags were added.
- No caller's behaviour changes during the untangling — this is a refactor, and a behaviour change smuggled inside one is the failure mode to avoid (What Refactoring Actually Is).
- Transactional email must remain suppressible per user, whichever unit ends up owning that rule (Where Invariants Live).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Whoever notices the divergence owns saying so — the flags are evidence, and evidence is not self-reporting.
- Each caller should own its own specifics. The shared unit should own only what all callers genuinely share, which by now may be very little (Cohesion).
- Someone owns the un-abstraction, and it needs the same care as the original extraction — characterization tests first, one caller at a time (Characterization Tests).
- The boundary that should exist runs around the part that is genuinely one piece of knowledge: probably template rendering and suppression, almost certainly not "sending a notification" (Duplicate Knowledge).
- The boundary that does exist runs around "everything six callers happened to need", which is not a concept and therefore cannot be a stable boundary (Stable Boundaries).
- Inlining is the corrective move and it is legitimate. Extraction and inlining are symmetric operations, and treating only one of them as progress is what lets this compound (The Refactoring Loop).
What eighteen months of divergence looks like
Nobody wrote this on purpose. Each parameter was added by a competent engineer under deadline, as the smallest change that made their caller work, and every one of those decisions was locally correct.
The signature is a record of the disagreement. Read each flag as a sentence: "at this point, two callers wanted different behaviour, and rather than saying they were different things, we added a switch."
- A parameter that is ignored by most callers (
batchWindowMs) belongs to one caller and should live there (Long Parameter List). - A parameter that changes two unrelated behaviours is two parameters that were never separated, which is the clearest possible signal that the callers are not one thing.
skipSuppressionCheckis a compliance rule expressed as a boolean. Rules that matter should be types, not flags a caller can pass by accident (Making Illegal States Unrepresentable).
1export async function sendNotification(2 userId: string,3 template: string,4 data: Record<string, unknown>,5 opts: {6 skipSuppressionCheck?: boolean // password reset must always send7 inlineImages?: boolean // invoices only8 asTransactional?: boolean // affects the From: address AND retries9 batchWindowMs?: number // digest caller only; ignored elsewhere10 locale?: string // 3 callers pass it, 3 rely on a default11 replyTo?: string // support caller only12 } = {},13): Promise<void>14 15// Six callers. Four booleans -> 16 reachable combinations,16// 6 exercised. 'asTransactional' changes two unrelated things17// because two different callers needed one each.asTransactional is the load-bearing evidence. One flag controlling two unrelated behaviours means two callers each needed one of them and neither wanted the other, so the flag is not a concept — it is the scar left where two different requirements were forced through one parameter (Boolean Parameters).
The smell, and the case where the same code is right
The pattern has a recognisable shape, and — as with every smell — it has a legitimate form that looks almost identical from the outside. The discriminator is not the parameter count; it is whether the parameters describe a coherent configuration space or a list of caller names in disguise.
A parameter that any caller might plausibly set is configuration. A parameter that exists because caller four needed something is a caller identifier wearing a boolean costume.
looks like A widely-imported function or class whose options object has grown boolean by boolean; each flag is set by one or two callers; at least one flag controls two unrelated behaviours; and the tests enumerate flag combinations rather than describing outcomes.
suggests The abstraction was extracted before the callers' requirements were known and has been held together with switches ever since. Expect invisible coupling between callers, a combinatorial space of untested states, and a change cost dominated by regression rather than by the edit (Boolean Flag Explosion).
fix Do not tidy the flags. Inline the unit back into each caller, delete the branches that caller never takes, and then re-extract only what is provably identical across the concrete versions — which is usually much less than what was shared before (The Rule of Three).
Pricing the untangling
The comparison here is not between elegant and ugly. It is between a change that stays in one team's repository directory and a change that requires two other teams to regression-test something they did not ask to have changed.
The cost line is where this lesson has to be honest: inlining genuinely creates the risk that a real shared rule drifts, and that risk is not hypothetical.
Support notifications must thread onto an existing conversation, which means a new header, a different reply-to and no digest batching.
The edit is small and the process is not: the change lands in code that five other flows depend on, so it needs sign-off from two other teams and a full regression. Elapsed time is dominated by coordination, and the tenth parameter makes the eleventh cheaper to add than to question (Agreement Costs Round Trips).
One file, one team, one test file. The two genuinely shared pieces — how a template is rendered and whether a user has suppressed this notification class — stayed shared, because a change to either really must affect everyone.
How to build it
Most important first.
- Read the parameter list as a diagnosis. Every boolean is a place where two callers wanted different behaviour and nobody was allowed to say they were different things (Boolean Parameters).
- Inline it back into the callers. Copy the shared unit into each caller, delete the branches that caller does not use, and stop — you now have six honest functions and no lie (Extract Function).
- Then look again. With six concrete versions side by side, extract only what is provably identical, which is usually much smaller than the original abstraction (The Rule of Three).
- Do it one caller at a time, each as its own reviewable change, with the shared unit shrinking as callers leave (Incremental Migration).
- Write the tests at each caller's boundary before moving it, so behaviour preservation is asserted rather than assumed.
- Say out loud that duplication is the intended outcome for the parts that differ. Without that, review pressure will re-merge them within two quarters (Review as Design Feedback — and Why It Arrives Too Late).
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.
- Today: a change to one caller's notification behaviour costs a new parameter, a regression run across six callers, and a coordination conversation with two other teams. The estimate for a one-line behaviour change is a week, and everyone has stopped noticing that this is strange.
- After inlining: the same change is one function in one file, tested by that caller's tests, shipped by that caller's team. The next change to a genuinely shared rule — suppression, say — costs one edit in the small unit that survived.
- The cost of the untangling itself: roughly one focused day per caller, spread over a quarter, with no visible feature output. That is the honest price, and it is why this is best done while already working in each caller rather than as a project (The Refactoring Loop).
- Inlining produces real duplication, and if a genuinely shared rule later changes, it must be changed in several places — the risk this whole exercise creates.
- It costs review capital: six similar functions where there was one looks worse to a reviewer applying a rule rather than reading the callers.
- Doing it caller by caller means living with a half-untangled unit for a quarter, which is genuinely awkward to explain to anyone joining mid-way.
What can go wrong
- The untangling is attempted as one large change, conflicts with three teams' work, and is abandoned — after which nobody will propose it again for years.
- Callers are inlined but the tests are not, so the suite still asserts the old interactions and blocks the cleanup at the last step.
- A caller is inlined and quietly given a behaviour change at the same time, which is discovered in production and used as evidence that the refactor was a bad idea (What Refactoring Actually Is).
- The mitigation fails culturally: a reviewer sees six similar functions where there was one and blocks the change on DRY grounds, so the corrective move needs the change-cost argument attached to it (DRY: Knowledge, Not Lines).
- Six callers depend on one unit, and through it on each other — a dependency none of them declared and none of them can see (Fan-in and Fan-out).
- Three teams now share a release cadence for notification changes, which is an organisational dependency created by a code decision (Code Ownership).
- The tests depend on the abstraction's shape, so the test suite is a dependency on the mistake and has to be dismantled alongside it (Mocking).
- "So duplication is better than abstraction." Neither is better. Duplicated knowledge is a defect waiting for a rule change; a wrong abstraction is a defect waiting for a caller to diverge. The skill is telling them apart (Duplicate Knowledge).
- "Add a parameter now, clean up later." Later never has a trigger, and each parameter makes the cleanup more expensive. If a parameter is being added because callers differ, that is the moment to stop (Deliberate Debt).
- "Rewrite it properly." A rewrite of a shared unit with six live callers is a migration with no rollback story. Inlining is incremental, reversible and boring, which is why it works (The Risk in a Rewrite).
- "The tests prove it works." The tests prove the abstraction behaves as the abstraction behaves. They were written after it was wrong and they encode the mistake (Mocking).
- long-parameter-list
- speculative-generality
- divergent-change
Testing it, and how it ages
- Characterize each caller's current output before moving it — including the odd cases the flags produce, because some of those are load-bearing (Characterization Tests).
- Delete tests of the shared unit as callers leave, rather than maintaining them for a shrinking population (What a Unit Is).
- After inlining, each caller's tests should be simpler and need fewer doubles. If they did not get simpler, question whether the abstraction was wrong after all (Testing as Design Feedback).
- Wrong abstractions grow monotonically because adding a parameter is always cheaper for the individual than untangling is. Nothing about that pressure changes on its own.
- The parameter count over time is the cheapest available early warning, and it is worth glancing at during review of any shared unit (Long Parameter List).
- Codebases that never inline anything accumulate these permanently, which eventually shows up as "everything takes longer here" with no single cause anyone can point at (What Technical Debt Actually Is).
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 a shared unit couples its callers to each other, invisibly at each call site, follows from what sharing means, so it holds in any language; only the mechanism of the flag differs — a boolean, a strategy object, a config key, a subclass.
- PARADIGM-SPECIFICIn OO the divergence usually shows up as an inheritance hierarchy with overridden hooks rather than as boolean parameters, which hides the flag count and makes the problem harder to see: five subclasses each overriding two methods is the same divergence with better manners. In a functional codebase the flags stay visible in the signature, so the diagnosis is easier and the same untangling applies.
- CONTESTEDThe strongest opposing view is that "prefer duplication to the wrong abstraction" is advice that only works for engineers who will actually revisit the duplication — in most teams the copies drift silently, the drift is found when one is fixed and the others are not, and the resulting inconsistency is worse for users than an ugly parameterised function ever was. On that reading a flag-laden shared unit is at least a single place where all the behaviour is visible and testable. The counter offered here rests on where the invisibility falls: duplication is visible in a diff and its drift is discoverable by search, while the coupling a shared unit creates is invisible at every call site — but this is a judgement about which failure your team detects faster, not a universal ranking.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — tests written against a shared abstraction encode its shape, so the suite becomes a force resisting the correction; characterization tests at each caller are what make the untangling safe.