Skip to content
M03Technical choices and abstractionsLesson 3 of 4

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.

Goal
Tell an abstraction that lowers maintenance cost from one that raises it, and know how to undo the wrong one.

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 time
async 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:

  1. A developer sees duplication and extracts an abstraction.
  2. A new requirement shows up that’s almost the same. Instead of questioning the abstraction, someone adds a parameter and an if.
  3. Step 2 repeats until the abstraction is a tangle of special cases.
  4. 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.

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.

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 rule
export 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.

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.

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 interface
export 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 SDK
import { 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.
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)
  • Abstracting at the first duplication. Wait for the third case.
  • Inheritance to reuse code. A BaseService whose 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.

  • Article
    The Wrong AbstractionSandi Metz

    The article cited in this lesson. Five minutes that change how you refactor.

  • Article
    The Law of Leaky AbstractionsJoel Spolsky

    The original statement of the law, with the TCP and SQL examples.

  • Article
    AHA ProgrammingKent C. Dodds

    Avoid Hasty Abstractions: the same idea applied to everyday JavaScript.

  • Article
    Hexagonal ArchitectureAlistair Cockburn

    Ports and adapters: the theory behind isolating external boundaries.