Right and wrong abstractions
The rule of three, the cost of the wrong abstraction, leaky abstractions and where isolation really pays off, at the boundaries with the outside world.
The problem
Section titled “The problem”In the beginning, Linkr sent two emails. Someone, or some AI, saw similar code in both and created a generic notify() function. A year later, it looks like this:
// ❌ The wrong abstraction, one parameter at a timeasync function notify( user: User, kind: string, opts: { channel?: 'email' | 'sms' | 'push'; skipIfUnsubscribed?: boolean; isBilling?: boolean; // billing ignores unsubscribes attachInvoice?: boolean; // only makes sense if isBilling delayMinutes?: number; legacyTemplate?: boolean; // older customers still get the v1 layout } = {},) { if (!opts.isBilling && opts.skipIfUnsubscribed !== false && user.unsubscribed) return; if (opts.channel === 'sms' && opts.attachInvoice) throw new Error('SMS does not support attachments'); const template = opts.legacyTemplate ? `v1/${kind}` : `v2/${kind}`; // ...80 more lines of ifs}Nobody dares touch it. Every call site uses a different combination of flags, and changing how one kind of email behaves can break another.
Duplication is cheaper than the wrong abstraction
Section titled “Duplication is cheaper than the wrong abstraction”Sandi Metz summed up the pattern that leads to code like this:
- A developer sees duplication and extracts an abstraction.
- A new requirement shows up that’s almost the same. Instead of questioning the abstraction, someone adds a parameter and an
if. - Step 2 repeats until the abstraction is a tangle of special cases.
- Everyone is afraid to touch it, because “the code is shared”.
Her conclusion: duplication is far cheaper than the wrong abstraction. Two similar, independent functions are easy to understand and to change. A “generic” function serving five callers with different rules has no clear owner.
The rule of three
Section titled “The rule of three”Wait for three real cases before abstracting:
- 1 case: write it directly.
- 2 cases: tolerate the duplication. You can’t tell yet what’s essential and what’s coincidence.
- 3 cases: now the pattern is visible. Extract what’s the same across all three, and leave out whatever you imagine a fourth case might need.
What decides it is the reason for change, more than how the code looks. Two identical snippets that change for different reasons (billing rules and marketing rules) shouldn’t be merged. They look alike today by accident.
Undoing the wrong abstraction
Section titled “Undoing the wrong abstraction”The way back is counterintuitive: reintroduce the duplication. Put the code back into each caller, delete what doesn’t apply to each one, and only then look for what’s truly common.
// ✅ Explicit functions, each with a clear business ruleexport async function sendInvoiceEmail(user: User, invoice: Invoice) { // Billing is transactional: it goes out even to people who unsubscribed from marketing. const html = await render('invoice', { user, invoice }); await email.send({ to: user.email, subject: `Invoice ${invoice.number}`, html, attachments: [{ filename: `invoice-${invoice.number}.pdf`, content: invoice.pdf }], });}
export async function sendWeeklyDigest(user: User, stats: WeeklyStats) { if (user.unsubscribed) return; const html = await render('weekly-digest', { user, stats }); await email.send({ to: user.email, subject: 'Your links this week', html });}What remains shared (render, email.send) is mechanism: how to render and how to deliver. The rules (who gets it, when, with what content) are now explicit in each function. That’s a good general pattern: share mechanism, not policy.
Abstractions leak
Section titled “Abstractions leak”Joel Spolsky stated the Law of Leaky Abstractions: all non-trivial abstractions, to some degree, are leaky, and what’s underneath shows through.
- The ORM hides SQL, until the page gets slow and you need to understand why it generated 300 queries.
- TCP hides the network, until the network gets slow and “reliable” turns into “waits forever”.
- Serverless hides servers, until cold starts show up in your latency.
The practical consequence: use abstractions to move faster, but learn the layer below. That’s why this guide teaches SQL (M11) even if you use an ORM, and networking (M14) even if you use fetch.
Where abstraction pays off: boundaries
Section titled “Where abstraction pays off: boundaries”There’s one place where an abstraction almost always pays for itself from day one: the boundary with something you don’t control, such as email, payment, storage or LLM providers.
// src/email/index.ts: the rest of the app only knows this interfaceexport interface EmailSender { send(msg: { to: string; subject: string; html: string; attachments?: { filename: string; content: Buffer }[] }): Promise<void>;}
// src/email/resend.ts: the only file that imports the provider's SDKimport { Resend } from 'resend';
export class ResendEmailSender implements EmailSender { constructor(private client = new Resend(config.RESEND_API_KEY)) {}
async send(msg: Parameters<EmailSender['send']>[0]) { const { error } = await this.client.emails.send({ from: 'Linkr <no-reply@linkr.app>', ...msg }); if (error) throw new Error(`Failed to send email: ${error.message}`); }}
export const email: EmailSender = new ResendEmailSender();It works because:
- The interface is defined by what your app needs (
send), not by what the SDK offers (dozens of methods). - Switching providers, or using a fake in tests, changes one file.
- The provider’s errors and odd formats get translated in one place.
Signs of a bad abstraction
Section titled “Signs of a bad abstraction”| Sign | What it indicates |
|---|---|
| Boolean parameters that change behavior | Two or more functions disguised as one |
| You have to read the implementation to use it | The interface doesn’t communicate what it does |
| Every new case requires changing the abstraction | It didn’t capture what’s actually common |
Generic name (Manager, Helper, process, handle) |
Nobody knows exactly what it’s responsible for |
| A single implementation “for now” of an internal interface | Indirection with no benefit (except at external boundaries) |
Pitfalls
Section titled “Pitfalls”- Abstracting at the first duplication. Wait for the third case.
- Inheritance to reuse code. A
BaseServicewhose methods half the subclasses override is coupling in disguise. Prefer composition: small functions you call. - An interface that mirrors the SDK. If your interface has the same 30 methods as the provider, it isolates nothing.
- What AI tends to generate:
BaseRepository<T>,GenericService, factories and abstraction layers before a second case exists. Ask explicitly for “the most direct solution, with no speculative abstractions”.
Go deeper
Materials in English unless noted.
- ArticleThe Wrong AbstractionSandi Metz
The article cited in this lesson. Five minutes that change how you refactor.
- ArticleThe Law of Leaky AbstractionsJoel Spolsky
The original statement of the law, with the TCP and SQL examples.
- Article
- ArticleHexagonal ArchitectureAlistair Cockburn
Ports and adapters: the theory behind isolating external boundaries.