A Philosophy of Software DesignDeep AbstractionsLesson 3 of 13

Deep Modules

This is the most important concept in this course. If you internalize one idea from Ousterhout, make it this one.

A module is any unit of code with an interface and an implementation: a function, a class, a package, a service. Every module has two parts:

  1. The interface: what the caller needs to know
  2. The implementation: what the module does internally

The relationship between these two parts determines whether the module manages complexity or creates it.

The Rectangle Visualization

Ousterhout uses a rectangle to visualize modules. The width is the interface (what users must learn). The height is the implementation (functionality hidden inside).

Deep Module                     Shallow Module
┌──────┐                       ┌──────────────────────────┐
│      │                       │                          │
│      │                       └──────────────────────────┘
│      │
│      │                       Wide interface, little depth.
│      │                       The caller must learn a lot
│      │                       but gets little in return.
│      │
└──────┘

Narrow interface, deep implementation.
The caller learns little but gets a lot.

A deep module has a narrow interface that hides significant complexity. A shallow module has a wide interface that hides almost nothing. Deep modules are good. Shallow modules are usually a design smell.

The Canonical Example: Unix File I/O

The Unix file I/O system is Ousterhout's favorite example of a deep module, and for good reason. Five functions handle everything:

// The entire Unix file I/O interface (simplified)
open(path: string, flags: number): FileDescriptor
read(fd: FileDescriptor, buffer: Buffer, count: number): number
write(fd: FileDescriptor, buffer: Buffer, count: number): number
seek(fd: FileDescriptor, offset: number, whence: number): number
close(fd: FileDescriptor): void

Five functions. Behind them: disk drivers, file system formats, caching, buffering, permissions, journaling, block allocation, inode management, directory traversal, symbolic links, and more. Hundreds of thousands of lines of implementation hidden behind five function signatures.

That is depth. The caller does not need to know about ext4 vs NTFS, about page caches, about disk scheduling algorithms. The interface eliminates those concepts entirely.

Shallow Module Antipatterns

Pass-Through Methods

A method that does nothing except call another method with the same arguments.

// Shallow: pass-through method
class UserService {
  private repository: UserRepository;

  getUser(id: string): User {
    return this.repository.getUser(id);
  }

  saveUser(user: User): void {
    this.repository.saveUser(user);
  }

  deleteUser(id: string): void {
    this.repository.deleteUser(id);
  }
}
// UserService adds ZERO value. It is pure bureaucracy.
// Every caller must learn the UserService interface,
// which is identical to the UserRepository interface.
// You doubled the interface surface for no benefit.

Pass-through methods are a signal that your abstraction boundaries are wrong. If UserService does not add logic, it should not exist. If it will add logic "later," add it later. Do not create empty abstractions for hypothetical future needs.

Thin Wrappers

A class that wraps a single value and provides one or two trivial accessors.

// Shallow: thin wrapper
class TemperatureHolder {
  private value: number;

  constructor(value: number) {
    this.value = value;
  }

  getValue(): number {
    return this.value;
  }

  setValue(value: number): void {
    this.value = value;
  }
}
// This is a variable with extra steps.
// The interface is WIDER than just using a number directly.

Compare with a deep temperature type:

// Deep: significant functionality behind a narrow interface
class Temperature {
  private readonly celsius: number;

  private constructor(celsius: number) {
    this.celsius = celsius;
  }

  static fromCelsius(c: number): Temperature {
    return new Temperature(c);
  }

  static fromFahrenheit(f: number): Temperature {
    return new Temperature((f - 32) * 5 / 9);
  }

  static fromKelvin(k: number): Temperature {
    if (k < 0) throw new Error("Temperature below absolute zero");
    return new Temperature(k - 273.15);
  }

  toCelsius(): number { return this.celsius; }
  toFahrenheit(): number { return this.celsius * 9 / 5 + 32; }
  toKelvin(): number { return this.celsius + 273.15; }

  isBelow(other: Temperature): boolean {
    return this.celsius < other.celsius;
  }

  difference(other: Temperature): Temperature {
    return new Temperature(Math.abs(this.celsius - other.celsius));
  }
}
// The interface is narrow (create, convert, compare).
// The implementation hides all conversion math and validation.

The Clean Code Decomposition Problem

Clean Code's advice to "extract till you drop" creates shallow module proliferation. Consider a function that processes a CSV:

// Over-extracted (Clean Code style): 6 functions, each trivially small
function processCSV(input: string): Record<string, string>[] {
  const lines = splitLines(input);
  const headers = extractHeaders(lines);
  const dataLines = removeHeaderLine(lines);
  const rows = parseRows(dataLines, headers);
  const cleaned = cleanRows(rows);
  return validated(cleaned);
}

function splitLines(input: string): string[] { return input.split("\n"); }
function extractHeaders(lines: string[]): string[] { return lines[0].split(","); }
function removeHeaderLine(lines: string[]): string[] { return lines.slice(1); }
function parseRows(lines: string[], headers: string[]): Record<string, string>[] {
  return lines.map(line => {
    const values = line.split(",");
    return Object.fromEntries(headers.map((h, i) => [h, values[i]]));
  });
}
function cleanRows(rows: Record<string, string>[]): Record<string, string>[] {
  return rows.filter(row => Object.values(row).some(v => v?.trim()));
}
function validated(rows: Record<string, string>[]): Record<string, string>[] {
  return rows.map(row => Object.fromEntries(
    Object.entries(row).map(([k, v]) => [k.trim(), v?.trim() ?? ""])
  ));
}

Each helper is 1-2 lines. To understand processCSV, you must read all six functions. The complexity did not decrease; it scattered.

// Deep module: one function, complete implementation
function parseCSV(input: string): Record<string, string>[] {
  const lines = input.split("\n");
  const headers = lines[0].split(",").map(h => h.trim());

  return lines
    .slice(1)
    .filter(line => line.trim().length > 0)
    .map(line => {
      const values = line.split(",");
      return Object.fromEntries(
        headers.map((header, i) => [header, values[i]?.trim() ?? ""])
      );
    });
}
// One function. All logic visible. Nothing to chase.
// The interface is one function with one parameter.

Is the second version longer? Slightly. Is it easier to understand? Dramatically. You read it top to bottom, once, and you know everything.

Pull Complexity Downward

When you design a module, you face a choice: put the complexity in the interface (caller deals with it) or put it in the implementation (module deals with it).

Always pull complexity downward. The module is written once; the interface is used many times. Complexity in the interface is paid by every caller. Complexity in the implementation is paid once.

// Complexity pushed UP to the caller
function connectToDatabase(
  host: string,
  port: number,
  username: string,
  password: string,
  database: string,
  ssl: boolean,
  poolSize: number,
  timeout: number,
  retries: number,
  retryDelay: number
): Connection {
  // ...
}

// Every caller must figure out 10 parameters.
// Most callers use the same defaults.
// This is interface pollution.
// Complexity pulled DOWN into the module
interface DatabaseConfig {
  readonly connectionString: string;
  readonly poolSize?: number;
}

function connectToDatabase(config: DatabaseConfig): Connection {
  const poolSize = config.poolSize ?? 10;
  const url = new URL(config.connectionString);
  const retries = 3;
  const retryDelay = 1000;
  // ... all the complexity is here, not in the caller
}

// Caller: connectToDatabase({ connectionString: "postgres://..." })
// One required field. Done.

The module absorbed the decisions. The caller provides the minimum necessary information. Every default, every retry strategy, every connection pooling detail is hidden.

Depth as a Design Goal

When designing any module, ask:

  1. What does the caller need to know? Minimize this.
  2. What does the module handle internally? Maximize this.
  3. If I add a feature, does the interface grow? If yes, reconsider.

The best modules are those where the interface stays stable as the implementation evolves. Unix's five file I/O functions have not changed in 50 years. The implementations behind them have been rewritten dozens of times.

Check Your Understanding

What makes a module 'deep'?

Not quite. The correct answer is highlighted.
A method that does nothing except call another method with the same arguments is called a -through method.
Not quite.Expected: pass

When designing a module, where should complexity live?

Not quite. The correct answer is highlighted.

What is the problem with 'extract till you drop'?

Not quite. The correct answer is highlighted.

Practice

Consolidate a shallow key-value store into a deep module:

Summary

  • Deep modules: narrow interface, significant implementation. The caller learns little, gets a lot.
  • Shallow modules: wide interface, trivial implementation. The caller learns a lot, gets little.
  • Unix file I/O is the canonical deep module: five functions hiding an entire storage subsystem.
  • Antipatterns: pass-through methods, thin wrappers, over-extraction.
  • Pull complexity downward: the module absorbs decisions so callers do not have to.
  • Interface stability: the best interfaces do not change as implementations evolve.

Next, we explore what to hide inside those deep modules: information hiding.