Skip to content

When a pattern is over-engineering

Keep a layer only for what it buys today; build nothing on a guess, extract at the third copy, and inline a wrong abstraction back.

Predict first

Before you read: predict

A payments ledger works out monthly interest for its one savings product. A teammate's MR adds an InterestStrategy interface, a FlatRate class (the only strategy) and an InterestStrategyFactory, "so tiered and promotional rates can plug in later". Product calls tiered rates likely next year. The tests and the one caller use FlatRate through the factory, and no test swaps in a strategy of its own.

Which review reply holds up?

Pick one

The 30-second version

An abstraction earns its place by what it buys today: a second implementation in use, a seam a test fills, or one home for a changing dependency. Built for a presumed need, it is yagni: it adds complexity now and may guess the wrong shape. Foresight that adds none is fine. Extract at the third copy; inline flag-ridden ones back.

Keep a layer only for what it buys today; build nothing on a guess, extract at the third copy, and inline a wrong abstraction back.

How it works

Step 1 of 15

Name what it buys. For each interface, factory or layer, say what it does for today's code; if nothing, inline its one implementation.
In the kitchen
  • ask each mount what it holds on this week's rides; a mount that holds nothing comes off → name what each layer buys today (step 1)

Analogy: A commuter's town bike and the mounts on its frame (cycling)

A commuter rides the same town bike every day, and over the years mounts have been bolted all over its frame.

On the bike In code
ask each mount what it holds on this week's rides; a mount that holds nothing comes off name what each layer buys today (step 1)
the rack takes the weekday pannier or the weekend basket, and both go on every week a second implementation in use (step 2)
the quick-release axle is what the mechanic uses to fit the wheel to her check stand at every service a seam a test fills (step 3)
an adapter plate takes whichever lock the maker sells this year, without new holes in the frame a changing dependency in one place (step 4)
a child-seat bracket and a trailer hitch bolted on "for later" ride along every day and catch the pannier; the seat bought years on needs another bracket yagni: the cost of carry and a wrong guess (steps 5–7)
a stem bracket shimmed to hold the phone, the light, the bell and the bottle comes off; each gets its own clamp, and only then is a shared mount chosen inline the wrong abstraction back, then re-extract (steps 13–15)

In real terms: every interface, factory and parameter is asked what it does for today's callers and tests; whatever has no answer is inlined and deleted.

Breaks down when: a bracket's weight is felt on every ride; code's cost of carry shows only when someone reads or changes the code around it.

Contrasting case: a hire bike handed back after one ride needs no mounts at all; a one-off script that runs once has no future to guess at.

Examples, run for real

4.1 ExamHall EXH-2580: one export format, then a second one for real

ExamHall exported each exam's results as CSV through one function, with no format interface (EXH-2580):

// export.ts
export interface Row { candidate: string; score: number }
export function exportCsv(rows: Row[]): string {
  return ['candidate,score', ...rows.map((r) => `${r.candidate},${r.score}`)].join('\n');
}

MR !2880 adds the JSON export the results portal now calls, so the route picks one of two implementations:

// format.ts
import type { Row } from './export';
export interface ExportFormat { contentType: string; render(rows: Row[]): string }

// csv.ts
import type { ExportFormat } from './format';
import { exportCsv } from './export';
export const csv: ExportFormat = { contentType: 'text/csv', render: exportCsv };

// json.ts
import type { ExportFormat } from './format';
export const json: ExportFormat = { contentType: 'application/json', render: (rows) => JSON.stringify(rows) };

// route.ts
import type { Row } from './export';
import { csv } from './csv';
import { json } from './json';
export const exportFor = (format: string, rows: Row[]) => (format === 'json' ? json : csv).render(rows);
4.1 EXH-2580 before MR !2880: exportCsv, 0 format interfaces · export tests 3 of 3
4.1 MR !2880: ExportFormat with 2 implementations (csv, json), both reached through route.ts · export tests 6 of 6

The interface arrived with its second implementation, not before it (step 2).

4.2 Payments ledger: what each caller of a shared function runs

A ledger's statement, receipt and refund email share one describe(), which grew a flag for each:

// describe.ts
export interface Entry { cents: number; currency: string; ref: string; refundOf?: string; rate?: number }
export function describe(e: Entry, opts: { refund?: boolean; fx?: boolean; short?: boolean } = {}): string {
  let text = `${(e.cents / 100).toFixed(2)} ${e.currency}`;
  if (opts.fx && e.rate) text += ` at ${e.rate}`;
  if (opts.refund) text = `Refund of ${e.refundOf}: ${text}`;
  if (opts.short) return text;
  return `${text} (ref ${e.ref})`;
}

// callers.ts
import { describe, type Entry } from './describe';
export const statementLine = (e: Entry) => describe(e);
export const receiptLine = (e: Entry) => describe(e, { fx: true, short: true });
export const refundLine = (e: Entry) => describe(e, { refund: true });
4.2 lines of describe() each caller runs: statementLine 3, 7 · receiptLine 3, 4, 6 · refundLine 3, 5, 7
4.2 lines all three callers run: 3 · lines only one caller runs: 4, 5, 6

Only the first line is shared by all three; each caller steers the rest with its flag (steps 13–14).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A take-home asks for one exam timer per attempt: start it, read the time left, say whether it has run out, run extra-time candidates' timers longer, and list when each attempt started. A candidate submitted this:

In review, each of the four layers (the clock, the countdown, the store, the factory) is deleted on its own: its file goes, the parameters that carried it go, and the code it runs by default moves to where it was used. timer.test.ts runs unchanged after each deletion. The vendor's next major version renames read to get.

Which deletions leave a test failing, or that rename to be made in more than one file?

Code for this question
// clock.tsexport interface Clock { now(): number }export const systemClock: Clock = { now: () => Date.now() }; // countdown.tsexport interface CountdownStrategy { remaining(startedAt: number, limitMs: number, now: number): number }export class LinearCountdown implements CountdownStrategy {  remaining(startedAt: number, limitMs: number, now: number) { return Math.max(0, startedAt + limitMs - now); }}export class ExtraTimeCountdown implements CountdownStrategy {  remaining(startedAt: number, limitMs: number, now: number) { return Math.max(0, startedAt + limitMs * 1.25 - now); }} // kv.ts (the vendor's key-value SDK, in the in-memory build it ships for tests)const buckets = new Map<string, Map<string, unknown>>();export class KvClient {  private rows: Map<string, unknown>;  constructor(bucket: string) { this.rows = buckets.get(bucket) ?? new Map(); buckets.set(bucket, this.rows); }  put(key: string, value: unknown) { this.rows.set(key, value); }  read(key: string) { return this.rows.get(key); }} // store.tsimport { KvClient } from './kv';export interface TimerStore {  save(id: string, startedAt: number, limitMs: number): void;  get(id: string): { startedAt: number; limitMs: number } | undefined;}export class KvStore implements TimerStore {  private kv = new KvClient('timers');  save(id: string, startedAt: number, limitMs: number) { this.kv.put(id, { startedAt, limitMs }); }  get(id: string) { return this.kv.read(id) as { startedAt: number; limitMs: number } | undefined; }} // timer.tsimport type { Clock } from './clock';import type { CountdownStrategy } from './countdown';import type { TimerStore } from './store';export class ExamTimer {  constructor(private clock: Clock, private countdown: CountdownStrategy, private store: TimerStore) {}  start(id: string, limitMs: number) { this.store.save(id, this.clock.now(), limitMs); }  remainingMs(id: string) {    const t = this.store.get(id);    return t ? this.countdown.remaining(t.startedAt, t.limitMs, this.clock.now()) : 0;  }  expired(id: string) { return this.remainingMs(id) === 0; }} // starts.ts (the invigilators' list)import type { TimerStore } from './store';export class StartLog {  constructor(private store: TimerStore) {}  startedAt(id: string) { return this.store.get(id)?.startedAt; }} // factory.tsimport { systemClock, type Clock } from './clock';import { LinearCountdown, type CountdownStrategy } from './countdown';import { KvStore } from './store';import { ExamTimer } from './timer';import { StartLog } from './starts';export class TimerFactory {  static create(clock: Clock = systemClock, countdown: CountdownStrategy = new LinearCountdown()) { return new ExamTimer(clock, countdown, new KvStore()); }  static startLog() { return new StartLog(new KvStore()); }} // index.tsimport type { Clock } from './clock';import { ExtraTimeCountdown } from './countdown';import { TimerFactory } from './factory';export const createTimer = (clock?: Clock) => TimerFactory.create(clock);export const createExtraTimeTimer = (clock?: Clock) => TimerFactory.create(clock, new ExtraTimeCountdown());export const startLog = () => TimerFactory.startLog(); // timer.test.tsimport { createTimer, createExtraTimeTimer, startLog } from './index';let now = 1_000_000;const clock = { now: () => now };const timer = createTimer(clock);const extra = createExtraTimeTimer(clock);const passed: string[] = [];const test = (name: string, ok: boolean) => { if (ok) passed.push(name); };timer.start('a-1', 60_000);extra.start('a-2', 60_000);test('starts full', timer.remainingMs('a-1') === 60_000);test('extra time runs longer', extra.remainingMs('a-2') === 75_000);test('start listed', startLog().startedAt('a-1') === 1_000_000);now += 59_000;test('counts down', timer.remainingMs('a-1') === 1_000);now += 2_000;test('runs out', timer.expired('a-1'));test('unknown attempt', timer.remainingMs('a-9') === 0);export { passed };
Pick one

Question 2 of 5

A chat service's deliver() is one 160-line function. Before read receipts are built next sprint, a developer opens an MR that splits deliver() into four named functions and adds tests for them; no behaviour changes and no feature is added.

A reviewer comments: "YAGNI: nobody asked for this." Which reply holds up?

Pick one

Question 3 of 5

A video service's titleCard(video, opts) was extracted from two similar screens three years ago. Six screens call it today, and it has grown compact, live, kids and rtl options, each read by an if inside it. A seventh screen needs a title card that differs again. The team lead points out how many fixes went into the function.

Which plan does Metz's article support?

Pick one

Question 4 of 5

A box office prints stalls tickets with stallsLine. Last week a developer copied it to print circle tickets:

Private boxes go on sale next month, and nobody has seen the box ticket's wording yet. The team will print every ticket line through one shared ticketLine.

Once the box line is written, which plan needs the fewest later changes to ticketLine (a parameter added, a call site edited or a branch added)?

Code for this question
// stalls.tsexport const stallsLine = (seat: string, cents: number): string =>  `Stalls · seat ${seat} · £${(cents / 100).toFixed(2)} · booking fee included`; // circle.ts (copied from stalls.ts)export const circleLine = (seat: string, cents: number): string =>  `Circle · seat ${seat} · £${(cents / 100).toFixed(2)} · booking fee included`;
Pick one

Question 5 of 5

While building a shop's product page, a developer reads its 20 visible labels from one labels object (labels.addToBasket, and so on) rather than writing them inline. It took about the same time, it adds no other code, and it was done because the shop may translate the site one day; nothing else is planned for that.

Which design for the page's labels does Fowler's account of yagni support?

Pick one

Go deeper

Pro tips
  1. For every abstraction in a take-home, be ready to say in one sentence what it buys today: a second implementation, a seam a test fills, or a changing dependency kept in one place. If you cannot, delete it before you submit. When: the curriculum row's take-home question, "In this take-home, what would you deliberately not abstract, and why?" Verify: nothing to run; this is interview advice, and it keys nothing. Status: ADVICE.
Source

A web page can change, so every claim from these pages is labelled as of 2026-10-02, the date read.

  • Martin Fowler, Yagni (26 May 2015; as of 2026-10-02), the curriculum row's second source: https://martinfowler.com/bliki/Yagni.html (fowler-2015-yagni.txt, the article from its first sentence to "Further Reading", with its footnotes). Steps 5–11 rest on it, and the opening question, the alternate probe, P2 and D2 quote it, including the Jeremy Miller remark it quotes.
  • Sandi Metz, The Wrong Abstraction (20 January 2016; as of 2026-10-02), the curriculum row's first source: https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction (metz-2016-wrong-abstraction.txt, the article from its first sentence to the book notice after it). Steps 13–15 rest on it, and P3 quotes it.
  • The rule of three (step 12) is taught as a heuristic from Martin Fowler's book Refactoring, which cannot be fetched as text.
  • Steps 1–4 are this lesson's own framing of the curriculum row's one-liner; they key nothing cited. P1 executes steps 2, 3 and 4 on the item's own files (a second implementation a route builds, a seam the test fills, and a wrapper round a vendor whose rename is replayed), and step 4 links arch-layered-hexagonal, which owns ports.

Checked against the tools and docs as of 2026-10-02.