A Philosophy of Software DesignDeep AbstractionsLesson 5 of 13

What Makes a Good Abstraction

An abstraction is a simplified model of something. A good abstraction eliminates irrelevant detail so you can focus on what matters. A bad abstraction just renames things and adds indirection without reducing what you need to know.

Most code that claims to be "abstracting" is not. It is wrapping. And wrapping without eliminating detail is pure overhead.

Abstractions as Simplified Models

When you use a Map in TypeScript, you do not think about hash functions, bucket arrays, collision resolution, or resizing strategies. The Map abstraction eliminates those details and gives you a simpler model: keys map to values. set, get, has, delete. That is the model. Everything else is hidden.

This is what good abstraction does. It replaces a complicated thing with a simpler thing that is sufficient for the caller's needs.

// No abstraction: raw HTTP everywhere
const response = await fetch("https://api.example.com/users/123");
if (response.status === 404) throw new Error("User not found");
if (response.status === 401) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const user = { id: data.id, name: data.name, email: data.email };

// Good abstraction: HTTP details eliminated
const user = await api.getUser("123");
// Status codes, JSON parsing, field mapping - all gone from the caller's world.

The api.getUser abstraction does not just rename fetch. It eliminates four concepts the caller no longer needs to think about: HTTP status codes, response parsing, error classification, and data shape transformation.

Bad Abstractions Just Rename Things

The most common abstraction failure is renaming without eliminating.

// Bad abstraction: StringUtils
class StringUtils {
  static capitalize(s: string): string {
    return s.charAt(0).toUpperCase() + s.slice(1);
  }

  static truncate(s: string, maxLength: number): string {
    return s.length > maxLength ? s.slice(0, maxLength) + "..." : s;
  }

  static isEmpty(s: string): boolean {
    return s.trim().length === 0;
  }
}

// The caller must still understand strings.
// StringUtils does not eliminate any detail.
// It does not provide a simpler mental model.
// It is just functions in a namespace.

StringUtils.capitalize(s) is not simpler than s.charAt(0).toUpperCase() + s.slice(1). It is the same amount of information with one extra hop. The "abstraction" did not abstract anything.

Compare this with an actual abstraction:

// Good abstraction: a display name that handles edge cases
function formatDisplayName(user: { firstName?: string; lastName?: string; username: string }): string {
  if (user.firstName && user.lastName) return `${user.firstName} ${user.lastName}`;
  if (user.firstName) return user.firstName;
  return `@${user.username}`;
}
// This IS an abstraction. The caller no longer thinks about
// "what if first name is missing?" or "what about username fallback?"
// One concept replaces three conditionals.

The Abstraction Test

Ask this question about any proposed abstraction:

Does it reduce what the caller needs to know?

If the caller still needs to understand the same concepts, the abstraction failed. If the caller can now think in fewer, higher-level concepts, the abstraction succeeded.

// Fails the test: caller still thinks about retries and timeouts
async function fetchWithRetry(
  url: string,
  retries: number,
  timeout: number,
  backoff: "linear" | "exponential"
): Promise<Response> {
  // ...
}
// Caller: fetchWithRetry("/api/data", 3, 5000, "exponential")
// The caller must decide retry count, timeout, and backoff strategy.
// The abstraction barely simplifies anything.

// Passes the test: retry details eliminated
async function fetchReliably(url: string): Promise<Response> {
  // Retries, timeouts, backoff - all decided internally
}
// Caller: fetchReliably("/api/data")
// The caller thinks: "fetch this reliably." Three concepts eliminated.

Fewer Concepts, Not Fewer Methods

Good interface design is about reducing the number of concepts, not the number of methods. A module with ten methods that all operate on one concept is simpler than a module with three methods that each introduce a new concept.

// Ten methods, one concept (a collection)
interface Collection<T> {
  add(item: T): void;
  remove(item: T): boolean;
  has(item: T): boolean;
  size(): number;
  toArray(): readonly T[];
  filter(predicate: (item: T) => boolean): Collection<T>;
  map<U>(transform: (item: T) => U): Collection<U>;
  forEach(action: (item: T) => void): void;
  find(predicate: (item: T) => boolean): T | undefined;
  isEmpty(): boolean;
}
// Many methods, but ONE mental model: a collection of items.
// Learn the concept once, use all ten methods naturally.

// Three methods, three concepts
interface DataProcessor {
  loadFromCSV(path: string): void;        // concept: file I/O
  transformWithSQL(query: string): void;  // concept: SQL
  exportToJSON(path: string): void;       // concept: serialization
}
// Three methods, but THREE mental models the caller must hold.
// The interface is "smaller" but harder to learn.

Designing Interfaces

Start with the Caller

Design interfaces from the outside in. Ask "what does the caller want to do?" not "what can the implementation provide?"

// Inside-out design (implementation-driven): exposes how it works
interface ImageProcessor {
  loadPixelBuffer(path: string): Uint8Array;
  applyConvolutionKernel(buffer: Uint8Array, kernel: number[][]): Uint8Array;
  quantizeColorPalette(buffer: Uint8Array, colors: number): Uint8Array;
  encodeToFormat(buffer: Uint8Array, format: "png" | "jpg"): Buffer;
}

// Outside-in design (caller-driven): exposes what callers need
interface ImageProcessor {
  resize(path: string, width: number, height: number): Promise<Buffer>;
  thumbnail(path: string, size: number): Promise<Buffer>;
  convert(path: string, format: "png" | "jpg"): Promise<Buffer>;
}
// Callers do not know or care about pixel buffers, kernels, or quantization.

Eliminate, Do Not Expose

When a module has an internal concept, the default should be to eliminate it from the interface, not to expose it. Only expose concepts that the caller genuinely needs to control.

// Exposed: caller must manage connection lifecycle
const conn = pool.acquire();
try {
  const result = conn.query("SELECT * FROM users");
  return result.rows;
} finally {
  pool.release(conn);
}

// Eliminated: connection lifecycle is internal
const users = await db.query("SELECT * FROM users");
// The caller does not know connections exist.
// Acquiring, using, and releasing is one atomic operation.

Check Your Understanding

What does a good abstraction do?

Not quite. The correct answer is highlighted.
The test for a good abstraction: does it reduce what the needs to know?
Not quite.Expected: caller

What is the difference between a good abstraction and a wrapper?

Not quite. The correct answer is highlighted.

Should you design interfaces from the inside out or outside in?

Not quite. The correct answer is highlighted.

Practice

Design a clean abstraction layer for HTTP response parsing:

Summary

  • Good abstractions are simplified models that eliminate irrelevant detail.
  • Bad abstractions just rename things and add indirection without reducing what the caller needs to know.
  • The abstraction test: does it reduce what the caller needs to know? If not, it is a wrapper, not an abstraction.
  • Fewer concepts > fewer methods. A ten-method interface around one concept is simpler than a three-method interface around three concepts.
  • Design outside-in. Start with what the caller wants to do, not what the implementation can provide.
  • Eliminate, do not expose. Internal concepts should be hidden by default.

Next, we turn to data. Data structures are the most important design decision, and they come before code.