Skip to content

9.2.3 — Abstraction

When an order is placed, the delivery app tells the customer. The first version sends an email, and the code says so:

typescript
class OrderPlacer {
  async place(order: Order): Promise<void> {
    await this.orders.save(order);

    const smtp = new SmtpClient({                     // (1)
      host: process.env.SMTP_HOST!,
      port: 587,
      auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
    });
    await smtp.sendMail({                             // (2)
      from: "orders@delivery.example",
      to: order.customerEmail,
      subject: `Order ${order.id} placed`,
      html: renderOrderEmail(order),
    });
  }
}

Line (1) creates an SMTP client inside the method, reading four environment variables. Line (2) sends the mail with an email-shaped payload: a from address, a subject, and HTML.

Six things are now true, and only the first one is good.

It works. But OrderPlacer — a class about placing orders — now knows what SMTP is, knows the port number, knows four environment variable names, and knows how to render HTML. You cannot test placing an order without either sending real email or installing a mail-server stub. When the company adds SMS, someone will paste a second block right below the first. When the marketing team switches to a transactional email vendor with a completely different API, they have to edit the order code. And the class has grown two reasons to change: how orders are placed, and how mail is delivered.

Every one of those problems has the same cause. OrderPlacer depends on a specific thing when it only needs a capability.

1. Abstraction is depending on a role instead of a thing

Abstraction means callers depend on what they need done, described as a role, rather than on which concrete thing does it.

That is the whole idea, and it is worth being careful about the difference between this and the previous page. Encapsulation hides how one object works from everyone outside it. Abstraction goes further: the caller does not even know which object it is talking to, only which role it plays.

Here is the role that OrderPlacer actually needs:

typescript
interface Notifier {                                                  // (1)
  notify(to: CustomerId, message: NotificationMessage): Promise<void>;
}

class OrderPlacer {
  constructor(private readonly notifier: Notifier) {}                 // (2)

  async place(order: Order): Promise<void> {
    await this.orders.save(order);
    await this.notifier.notify(order.customerId, {                    // (3)
      kind: "order_placed",
      orderId: order.id,
      total: order.total,
    });
  }
}

Line (1) declares the role. It is three lines long, and those three lines are the entire contract: give me a customer and a message, and I will get it to them. There is no port number, no vendor, no HTML.

Line (2) receives whoever plays the role, as a constructor parameter. OrderPlacer never creates it, which is what stops it from having to know which one it is.

Line (3) hands over a message described in business terms rather than in email terms. kind: "order_placed" with the data the message needs. Deciding whether that becomes an email subject line, an SMS body or a push notification title is the job of whoever implements the role, and that is precisely the knowledge OrderPlacer should not have.

Now write the implementations:

typescript
class EmailNotifier implements Notifier {                      // (1)
  constructor(private readonly smtp: SmtpClient, private readonly people: CustomerLookup) {}

  async notify(to: CustomerId, message: NotificationMessage): Promise<void> {
    const customer = await this.people.byId(to);
    await this.smtp.sendMail({
      to: customer.email,
      subject: subjectFor(message),                            // (2) email-specific choices
      html: renderEmail(message),                              //     live here and nowhere else
    });
  }
}

class SmsNotifier implements Notifier {                        // (3)
  constructor(private readonly twilio: TwilioClient, private readonly people: CustomerLookup) {}

  async notify(to: CustomerId, message: NotificationMessage): Promise<void> {
    const customer = await this.people.byId(to);
    await this.twilio.messages.create({
      to: customer.phone,
      body: shortTextFor(message),                             // (4) 160 chars, no HTML
    });
  }
}

class FakeNotifier implements Notifier {                       // (5)
  readonly sent: Array<{ to: CustomerId; message: NotificationMessage }> = [];

  async notify(to: CustomerId, message: NotificationMessage): Promise<void> {
    this.sent.push({ to, message });
  }
}

Line (1) declares that EmailNotifier plays the role. Line (2) is where all the email knowledge went: subject lines and HTML rendering live in the email implementation, which is the only place that should care.

Line (3) is a second player, and line (4) shows why the message had to be described in business terms. SMS has a length limit and no HTML, so it makes different formatting decisions from the same input. If OrderPlacer had passed an HTML string, the SMS implementation would have had to strip tags out of it, which is the shape of code that tells you an abstraction was drawn in the wrong place.

Line (5) is the one that changes your daily life the most. FakeNotifier plays the role by remembering what it was asked to send. A test can now do this:

typescript
const notifier = new FakeNotifier();
const placer = new OrderPlacer(orderRepo, notifier);

await placer.place(order);

expect(notifier.sent).toHaveLength(1);
expect(notifier.sent[0].message.kind).toBe("order_placed");

No network, no mail server, no mocking framework, no waiting. The test runs in a millisecond and it fails only when the behaviour it describes actually breaks. Chapter 9.8 goes into why a hand-written fake usually beats a generated mock, but the enabling move is right here: the role is small enough to implement by hand.

2. The three payoffs, named precisely

It is worth stating what you bought, because each payoff is one of the properties from 9.1.

Coupling dropped from volatile to stable. OrderPlacer used to depend on an SMTP client, which changes when your mail vendor changes, when their SDK has a major version, when the port or auth scheme changes. Now it depends on a three-line interface you own, which changes only when the meaning of notifying somebody changes, which is roughly never. You do not get to remove dependencies, but you do get to choose what you depend on, so point your dependencies at the things that hold still.

Change became addition instead of modification. Adding push notifications is a new class. Nothing in OrderPlacer is edited, so nothing in OrderPlacer can break. That is the Open/Closed Principle from 9.3.6, and this is the mechanism underneath it.

Testing stopped requiring the world. This is not a small benefit. In practice, a codebase where the domain logic can be tested without infrastructure gets tested, and one where it cannot does not.

3. The part everyone gets wrong: what to name the role after

Both of these compile. Only one is an abstraction.

typescript
// ❌ named after the implementation
interface SmtpGateway {
  connect(host: string, port: number): Promise<void>;      // (1)
  sendMail(msg: { from: string; to: string; subject: string; html: string }): Promise<void>;
  quit(): Promise<void>;
}

// ✅ named after what the caller needs
interface Notifier {
  notify(to: CustomerId, message: NotificationMessage): Promise<void>;
}

SmtpGateway is an interface, and it has abstracted nothing at all. Line (1) already leaks: the caller has to know about hosts and ports, has to know to connect first and quit after (the temporal coupling from 9.1), and the message shape is an email. Try implementing SmtpGateway with Twilio and you will be writing connect() methods that do nothing and inventing a subject for a text message.

The rule that prevents this:

Name the role after the caller's need, in the caller's vocabulary. If you cannot implement it a second way, it is not an abstraction — it is a rename.

Apply the test out loud. Can a completely different technology implement Notifier? Yes: email, SMS, push, a webhook, a websocket, or a queue that hands off to somebody else. Can a different technology implement SmtpGateway? Only another SMTP server. That single question catches most bad interfaces before they ship.

There is a second failure that looks like the opposite but comes from the same mistake, which is defining the interface by looking at the implementation and listing its methods. That is how you end up with a UserRepository carrying nineteen methods because the ORM offers nineteen methods, when the application uses four of them. The interface should be a list of what callers ask for, and 9.3.8's Interface Segregation Principle is this idea taken to its conclusion.

4. Deep and shallow: the measure of a good abstraction

John Ousterhout's framing is the most useful single tool for judging an interface, and it takes one figure to explain.

deep: small interface, big bodyshallow: big interface, thin bodynotify()channel choice · retries · rate limitstemplating · localisation · opt-outsvendor SDK · auth · error mappingeverything the caller is sparedconnect() send() quit() setFrom() …passes calls straight throughcaller still knows hosts, ports, order of callsso the caller carries the complexity anyway
The value of a module is how much complexity it removes from its callers. A wide interface over a thin body has moved code without removing anything, which is why adding layers can make a system harder rather than easier.

A deep module has a small interface and does a lot behind it. Notifier is deep: one method, and behind it live channel selection, retries, rate limits, templates, localisation and unsubscribe rules. Every one of those is a thing the caller never has to think about. That is the payoff, and it is measurable — count what the caller no longer needs to know.

A shallow module has a wide interface and does little behind it. The classic example is a class that wraps another class and forwards each method one for one, adding nothing. It has doubled the amount of code you must read to follow a call, and removed no complexity, because everything the wrapped thing demanded is still demanded through the wrapper.

This gives you a sharper way to argue about layers in a review than "too many layers" or "not enough separation":

A layer must pay for itself by removing something. If your UserService methods each call exactly one UserRepository method and do nothing else, that layer is a toll booth. Either give it a job — validation, authorisation, combining several sources, mapping errors into your own vocabulary — or delete it.

The same yardstick explains why fetch is a good abstraction and a hand-rolled HttpClientWrapper with fourteen configuration methods usually is not. fetch has one function and hides DNS, TCP, TLS, connection pooling, redirects and chunked decoding. It is about as deep as an interface gets.

5. Leaky abstractions: the part that is genuinely unavoidable

Joel Spolsky's law states that all non-trivial abstractions leak to some degree, and understanding this keeps you from either being surprised by it or giving up on abstraction entirely.

An abstraction leaks when a detail it promised to hide becomes visible anyway, usually in the form of a failure or a performance cliff.

The network. Any interface that hides a remote call — a repository, an RPC client, a "just call this method" service proxy — hides that the call can be slow, can time out, can partially succeed, and can fail in ways local calls never do. That is not a flaw in your interface, it is the nature of the thing behind it, and Part 10 is largely about what to do with it. The practical response is to not let the interface lie: return a Promise so the caller knows it is asynchronous, and define errors in your own vocabulary (NotificationRejectedError) so callers can react without knowing the vendor's error codes.

The ORM. An object-relational mapper presents rows as objects, and then a loop that reads order.customer.name fires one query per order, which is the N+1 problem (9.4.10 covers the mechanism). The database did not disappear because you stopped writing SQL.

Streams. Reading a file "like a list" hides that the disk is slower than the consumer, until backpressure makes it visible (3.8.4).

The conclusion is not "abstraction is a lie". It is a set of working habits:

  1. Choose interfaces that do not promise what cannot be delivered. Do not name a remote call getUser() with a synchronous-looking signature.
  2. Expect to need an escape hatch. Good abstractions offer a way down to the layer below for the two percent of cases that need it, rather than forcing users to abandon the abstraction entirely. An ORM that lets you drop to raw SQL for one query is more useful than one that does not.
  3. Learn the layer below anyway. This is the real lesson. You will debug through your abstractions, and the person who knows what SMTP, TCP and SQL actually do is the one who can fix it. Volume I is organised bottom-up for exactly this reason.

6. Where the seam goes: dependency injection in one line

You may have noticed that the whole design turns on one small thing: OrderPlacer receives its notifier instead of creating one.

typescript
// ❌ creates its dependency — welded to a specific class forever
class OrderPlacer {
  private notifier = new EmailNotifier(new SmtpClient(config));   
}

// ✅ receives its dependency — the caller decides
class OrderPlacer {
  constructor(private readonly notifier: Notifier) {}
}

That is dependency injection in its entirety: do not create the things you depend on when they might need to vary; receive them. Everything else written about DI — containers, decorators, service locators, framework annotations — is machinery for doing this at scale in large applications, and none of it is required to get the benefit.

Somebody does have to make the actual choice, of course, and the answer is one place near the start of the program, usually called the composition root:

typescript
// main.ts — the one file that knows which concrete classes exist
const notifier = config.smsEnabled
  ? new SmsNotifier(twilio, customers)
  : new EmailNotifier(smtp, customers);

const placer = new OrderPlacer(orderRepo, notifier);   // wired once, at startup

Everything downstream of this file talks to roles only. Knowledge of concrete classes has been collected into one file where it is visible, instead of being sprinkled through every class that happened to need something. 9.4.6 covers the composition root in full, including why it is the honest answer to most of what people use singletons for.

When not to inject. Injecting everything is its own disease. Do not inject things that never vary and have no side effects: Math, JSON, your own value objects, a pure function. Injecting Money so it can be "swapped in tests" adds a parameter and buys nothing. The test is whether there is a plausible second implementation, or whether the real one is painful in a test. Clocks and random number generators pass that test, which is why now: () => Date is worth injecting. String formatting does not.

7. Abstraction inside a function: keep one level per view

Abstraction is not only about interfaces between classes. The same idea applies inside a single function, and it is the cheapest readability win available.

typescript
// ❌ mixed levels: business steps and byte-level details in the same view
async function placeOrder(req: Request): Promise<Response> {
  const body = JSON.parse(await readBody(req));
  if (!body.items || !Array.isArray(body.items)) return badRequest("items required");
  let total = 0;
  for (const it of body.items) total += it.qty * it.priceCents;      // (1)
  if (total > 5_000_00) { /* fraud rules inline */ }                 // (2)
  const conn = await pool.acquire();                                 // (3)
  try { await conn.query("INSERT INTO orders …"); } finally { conn.release(); }
  await smtp.sendMail({ /* … */ });
  return ok({ id });
}

// ✅ one level of detail: every line is a business step
async function placeOrder(req: Request): Promise<Response> {
  const command = parsePlaceOrderCommand(req);      // (1) input handling
  const order = Order.place(command.lines);         // (2) domain rules
  await this.orders.save(order);                    // (3) persistence
  await this.notifier.notify(order.customerId, orderPlacedMessage(order));
  return ok({ id: order.id });
}

In the first version, line (1) is arithmetic over cents, line (2) is a business policy, and line (3) is connection pool management. Three different altitudes in six lines, so a reader has to constantly change how they are thinking. That is what makes such code exhausting to read even when it is short.

In the second version, every line is at the same altitude. You can read the whole flow and know what happens without opening anything, and then descend into exactly the one step you care about. This is sometimes called keeping a single level of abstraction per function, and the practical test is: can you read the function's body as a summary of what it does?

8. When abstraction is the wrong answer

The failure mode of this page is applying it too early, so here is the counterweight.

One implementation and no test pain means no interface. interface UserMapper with exactly one implementation, forever, is indirection with no payoff: you now open two files to read one behaviour. Write the class. Extract the interface the day a second implementation or a painful test actually arrives, which takes ten minutes because the methods already exist.

Wait for the second case before naming the role. An interface designed from one implementation is usually shaped like that implementation, which is how SmtpGateway happens. With two real cases in front of you, the genuine shared shape is visible. This is the rule of three from 9.1 section 6 applied to interfaces.

Do not abstract what does not vary. Wrapping Array in a CollectionWrapper in case you switch collection libraries is a cost paid today against a benefit that will never arrive.

In TypeScript, the cost of waiting is unusually low, and this is worth knowing because it changes the calculation. Types are matched by shape rather than by declaration (3.7.2), so any object with a matching notify method already satisfies Notifier without an implements clause and without any change to its own file. You can introduce an interface after the fact over classes that were never written with it in mind, including classes from libraries you do not control. In Java or C#, retrofitting an interface means editing every implementing class, so the pressure to guess early is real. In TypeScript it is not, so guessing early is a choice, and usually the wrong one.

What the next page adds. You now have callers talking to roles. The next question is the mechanism that makes one call site run different code for different objects, which is 9.2.4 on inheritance and then 9.2.5 on dispatch — the engine that makes everything on this page actually run.

Recall

  • Abstraction means depending on a role, not on a thing. Encapsulation hides how one object works; abstraction hides which object you are talking to.
  • Name the role after the caller's need, in the caller's words. The test: can a completely different technology implement it? Notifier passes, SmtpGateway fails — it is a rename, not an abstraction.
  • Payoffs: your dependency points at something stable instead of something volatile, new channels arrive as new classes rather than edits, and tests get a hand-written fake that runs in a millisecond.
  • Judge an interface by depth: a small interface hiding a lot is deep and valuable; a wide interface over a thin body is shallow and has only moved code. Every layer must pay for itself by removing something.
  • All non-trivial abstractions leak — the network, the ORM's N+1, stream backpressure. So do not promise what you cannot deliver, provide an escape hatch, and learn the layer underneath anyway.
  • Dependency injection is one sentence: receive your varying collaborators instead of creating them. The concrete choices collect in one composition root at startup. Do not inject things that never vary.
  • Inside a function, keep one level of detail per view, so the body reads as a summary of what it does.
  • Do not abstract with one implementation and no test pain. In TypeScript, structural typing means you can add the interface later over classes that never mentioned it, so waiting is nearly free.

Self-test: Give the one-question test that separates a role from a rename. Why did the message passed to Notifier have to be described in business terms rather than as HTML? What makes a module deep, and what does a shallow layer cost? Name three leaks and the habit each one teaches. When is adding an interface the wrong move, and why is that call cheaper in TypeScript than in Java?

Quiz Bank

FoundationalWhat is the difference between abstraction and encapsulation? Most candidates blur them.

Encapsulation is about one object's internals. It says: this state is mine, here are the operations that may change it, and every rule about it is enforced inside those operations. The caller knows exactly which object it holds and simply cannot reach inside it.

Abstraction is about which object you hold at all. It says: you depend on a role — Notifier, PaymentGateway, OrderStore — and any object that plays that role will do. The caller does not know whether it is talking to email or SMS, Postgres or an in-memory fake.

A sharp way to see the difference: a class can be perfectly encapsulated and offer no abstraction. SmtpClient with private fields and clean methods hides its internals completely, and a caller that depends on it is still welded to SMTP forever. Abstraction is what breaks that weld.

They compose naturally, and the order matters. Encapsulation gives you an object worth trusting. Abstraction lets you swap which trustworthy object you use. Both serve the same goal from 9.1: keeping change from travelling.

FoundationalWhat makes an interface good rather than ceremonial? Give the test.

The test: could a completely different technology implement this? If the answer is no, you have renamed a class rather than abstracted it.

interface SmtpGateway { connect(host, port); sendMail({from, to, subject, html}); quit(); } fails. The caller still needs a host and a port, still has to call the methods in the right order, and still has to think in emails. Implementing it with SMS would mean empty connect and quit methods and an invented subject line, and those empty methods are the signal that the shape is wrong.

interface Notifier { notify(to: CustomerId, message: NotificationMessage): Promise<void> } passes. Email, SMS, push, webhook and an in-memory fake can all play it honestly.

Three properties that follow from doing this right. The interface is named for the client's need, in the client's vocabulary, so it reads naturally at the call site. It is thin, because a role is a few methods and a fat interface recreates the stamp coupling of passing something huge when you need a sliver (9.3.8 formalises this). And it is deep, meaning that a lot of complexity sits behind those few methods — retries, templating, vendor error mapping — because the value of an interface is measured by how much its callers no longer need to know.

AppliedA codebase has an interface for every class, each with exactly one implementation named the same thing plus the word Impl. Is this good design? What would you change?

No, and it is worth explaining why carefully, because the pattern was adopted for reasons that sound right. An interface with one implementation, and no prospect of a second, adds indirection with no payoff. Every "go to definition" lands on the interface rather than on the code, so reading any behaviour costs two files instead of one. The interface is also almost always shaped exactly like the single implementation, which means it will not fit a second one if it ever arrives, so it does not even buy the flexibility it was created for.

Where the habit comes from. In Java and C#, retrofitting an interface means editing every implementing class, and older mocking frameworks could only mock interfaces, so extracting one up front was genuinely cheaper. Neither reason applies in TypeScript: structural typing means an interface can be added later over classes that never mentioned it (3.7.2), and a hand-written fake class satisfies it with no framework at all.

What to change. Keep the interface where at least one of three things is true: there really are two or more implementations, the real implementation is painful in tests because it does input and output, or the interface marks an architectural boundary you intend to defend such as the line between domain and infrastructure. Delete the rest, inline the single implementation, and take the Impl suffix with it — a name that exists only to avoid colliding with an interface is a sign the interface was not adding meaning.

And when a second implementation does arrive, extract the interface then, from two real cases, which takes ten minutes and produces a much better shape than guessing did.

InterviewWhat is a leaky abstraction? Give real examples and say what a designer should do about it.

A leaky abstraction is one where a detail it promised to hide becomes visible anyway, usually as a failure mode or a performance cliff. Joel Spolsky's law is that all non-trivial abstractions leak to some degree, and it holds because the thing underneath has properties the interface has no way to remove.

Three examples that matter in practice. An ORM presents database rows as objects, and then a loop over order.customer.name fires one query per order, which is the N+1 problem — the database did not stop existing when you stopped writing SQL. Any interface hiding a remote call hides that it can be slow, time out, or partially succeed, none of which local calls do. A stream that lets you read a file "like a list" hides that the producer is faster than the consumer until backpressure makes it visible (3.8.4).

What a designer does about it, in three habits. First, do not promise what cannot be delivered: keep the asynchrony visible in the signature rather than pretending a network call is a field access, and define failure in your own vocabulary so callers can handle it without learning the vendor's error codes. Second, provide an escape hatch, because the alternative to "drop to raw SQL for this one query" is "abandon the ORM entirely", and a small hatch preserves the abstraction for the other ninety-eight percent. Third, learn the layer below regardless, because you will debug through the abstraction, and at that moment only knowledge of what is underneath helps.

The conclusion that separates a senior answer from a cynical one: leaks are a reason to choose abstractions carefully and to keep learning downward, not a reason to stop abstracting. Code that talks directly to SMTP everywhere leaks and is welded.

StaffYour team proposes a repository interface with 19 methods, mirroring the ORM. Argue the alternative and say what you would accept.

The problem in one line: the interface was written by looking at what the ORM offers rather than at what the application asks for, so it has inherited the ORM's shape and therefore its coupling. Nineteen methods means nineteen things every implementation must provide, including the in-memory fake, which is how teams end up abandoning fakes and mocking everything instead.

What that costs concretely. Any second implementation — a test fake, a caching layer, a read replica router, a different store during a migration — has to implement nineteen methods, most of which it does not need, and the stubs it writes for them are a lie waiting to be called. Callers, meanwhile, can reach for whichever of the nineteen they like, so query knowledge spreads out across the application instead of staying in one place. And the interface is shallow by Ousterhout's measure: it hides almost nothing, because it is a pass-through of the layer below.

The alternative. Define the interface from the call sites. Go through the application and list what it genuinely asks for, and it is usually four to six things with names that mean something in the domain: save(order), byId(id), openOrdersFor(customerId), overdueBefore(date). Each method is a query the business cares about, so the query knowledge lives inside the implementation where it can be optimised, indexed and explained, rather than being assembled ad hoc by callers.

What I would accept, so this is a negotiation rather than a veto. Two things. First, a wider interface where the reads are genuinely open-ended — reporting and admin screens legitimately need arbitrary filtering, and forcing those through a domain-shaped interface produces worse code, so give them a separate read-side interface and let it be closer to the database. That split is the seed of the CQRS idea in Part 10.8. Second, the concrete class may of course have more methods than the interface; only the interface needs to be narrow, because that is what everything else depends on.