A Philosophy of Software DesignDeep AbstractionsLesson 4 of 13

Information Hiding

David Parnas introduced information hiding in 1972. Fifty years later it remains the single most effective technique for managing complexity. The idea: each module should encapsulate a design decision, and that decision should be invisible to the rest of the system.

Deep modules are the structure. Information hiding is the strategy for deciding what goes inside them.

What to Hide

Three categories of information should be hidden inside modules:

1. Implementation Details

How the module achieves its result. The caller should not know or care whether your cache uses an LRU eviction policy, a hash map, or a sorted array.

// The caller sees this:
interface Cache<K, V> {
  get(key: K): V | undefined;
  set(key: K, value: V): void;
}

// The implementation could be any of these:
// - Map with setTimeout-based expiry
// - Array with LRU eviction
// - WeakMap for GC-friendly caching
// - Redis connection
// The caller does not know. The caller does not need to know.

2. Data Representations

How data is stored internally. Whether dates are Unix timestamps, ISO strings, or Date objects inside the module is irrelevant to callers.

// BAD: Internal representation leaks through the interface
class EventStore {
  // Caller must know events are stored as a Map<string, Event[]>
  getEvents(): Map<string, Event[]> {
    return this.eventsByDate;
  }
}
// If you change the internal structure, every caller breaks.

// GOOD: Internal representation is hidden
class EventStore {
  private readonly events: Map<string, Event[]> = new Map();

  getEventsForDate(date: Date): readonly Event[] {
    const key = date.toISOString().slice(0, 10);
    return this.events.get(key) ?? [];
  }

  getUpcomingEvents(limit: number): readonly Event[] {
    const now = new Date();
    return [...this.events.values()]
      .flat()
      .filter(e => e.date > now)
      .sort((a, b) => a.date.getTime() - b.date.getTime())
      .slice(0, limit);
  }
}
// Caller asks questions, module decides how to answer.
// Internal Map can become an array, a database, or a B-tree
// without changing any caller code.

3. Design Decisions

Choices that might change. If you are not sure whether to use a priority queue or a sorted array, hide that decision behind an interface. When you change your mind, only one file changes.

// The design decision "tasks are ordered by priority descending"
// is hidden inside the module
class TaskQueue {
  private readonly tasks: Task[] = [];

  enqueue(task: Task): void {
    // Binary insertion to maintain sorted order - design decision hidden
    const idx = this.tasks.findIndex(t => t.priority < task.priority);
    if (idx === -1) this.tasks.push(task);
    else this.tasks.splice(idx, 0, task);
  }

  dequeue(): Task | undefined {
    return this.tasks.shift();
  }

  peek(): Task | undefined {
    return this.tasks[0];
  }
}
// Tomorrow you can replace the sorted array with a heap.
// No caller code changes.

Information Leakage

Information leakage is the opposite of information hiding. It occurs when internal details escape through the interface, either explicitly (in the type signature) or implicitly (in the behavior).

Explicit Leakage

The interface exposes internal types or structures.

// Leaking: the internal node structure is part of the interface
class LinkedList<T> {
  head: ListNode<T> | null = null;

  // Callers must understand ListNode to use the list
  insertAfter(node: ListNode<T>, value: T): ListNode<T> {
    const newNode = new ListNode(value, node.next);
    node.next = newNode;
    return newNode;
  }
}

// Hidden: callers work with values, not nodes
class LinkedList<T> {
  private head: ListNode<T> | null = null;

  insertAt(index: number, value: T): void {
    // Node manipulation is internal
  }

  get(index: number): T | undefined {
    // Node traversal is internal
  }
}

Implicit Leakage

The behavior reveals implementation details without exposing them in the type.

// Implicit leakage: callers learn that IDs are sequential
class UserStore {
  private nextId = 1;

  createUser(name: string): User {
    return { id: this.nextId++, name };
  }
}
// Callers will start relying on sequential IDs:
// "User 5 was created before User 6"
// "The newest user has the highest ID"
// When you switch to UUIDs, everything breaks.

The fix: use opaque IDs from the start. If the caller does not need to know the ID format, do not let them discover it.

class UserStore {
  createUser(name: string): User {
    return { id: crypto.randomUUID(), name };
  }
}
// Callers cannot infer creation order from the ID.
// The internal ID strategy is hidden.

The Temporal Decomposition Trap

Temporal decomposition means splitting code by when things happen rather than by what information they manage. This is one of the most common design mistakes.

// Temporal decomposition: split by time
function readConfig(path: string): string {
  return fs.readFileSync(path, "utf-8");
}

function parseConfig(raw: string): Record<string, string> {
  return Object.fromEntries(
    raw.split("\n")
      .filter(line => line.includes("="))
      .map(line => line.split("=").map(s => s.trim()))
  );
}

function validateConfig(config: Record<string, string>): void {
  if (!config.host) throw new Error("Missing host");
  if (!config.port) throw new Error("Missing port");
}

// Caller must orchestrate all three steps in order:
const raw = readConfig("./config.ini");
const parsed = parseConfig(raw);
validateConfig(parsed);

Three functions, split by when they execute: first read, then parse, then validate. But they all deal with one piece of information: the configuration. The caller must know about all three steps and call them in the right order. The information (config file format) leaks across three module boundaries.

// Information-based decomposition: one module owns the config concept
interface AppConfig {
  readonly host: string;
  readonly port: number;
}

function loadConfig(path: string): AppConfig {
  const raw = fs.readFileSync(path, "utf-8");
  const entries = Object.fromEntries(
    raw.split("\n")
      .filter(line => line.includes("="))
      .map(line => line.split("=").map(s => s.trim()))
  );

  if (!entries.host) throw new Error("Missing host in config");
  if (!entries.port) throw new Error("Missing port in config");

  return {
    host: entries.host,
    port: parseInt(entries.port, 10),
  };
}

// Caller: const config = loadConfig("./config.ini");
// One call. Format details are hidden. Validation is hidden.

One function owns everything about configuration. The file format, the parsing logic, the validation rules. Change any of these, and only this function changes.

Generality Leads to Better Hiding

A module designed for a specific use case tends to leak information about that use case. A module designed for general use hides more because it does not assume a particular context.

// Specific: leaks knowledge about the "user notification" use case
function sendUserWelcomeEmail(userId: string): void {
  const user = db.getUser(userId);
  const template = loadTemplate("welcome");
  const html = template.render({ name: user.name, date: new Date() });
  smtp.send({ to: user.email, subject: "Welcome!", body: html });
}

// General: hides email implementation, works for any use case
interface EmailMessage {
  readonly to: string;
  readonly subject: string;
  readonly body: string;
}

function sendEmail(message: EmailMessage): void {
  // SMTP connection, retries, formatting - all hidden
  // Whether this is a welcome email, a receipt, or an alert
  // is irrelevant to this module.
}

The general sendEmail is deeper. It hides more (all SMTP details) and assumes less (does not know about users or templates). It is also more reusable, but reusability is a side effect, not the goal. The goal is information hiding.

Check Your Understanding

What are the three categories of information to hide?

Not quite. The correct answer is highlighted.
When internal details escape through the interface, it is called information .
Not quite.Expected: leakage

What is temporal decomposition?

Not quite. The correct answer is highlighted.

Why does generality lead to better information hiding?

Not quite. The correct answer is highlighted.

Practice

Build a task scheduler that hides its scheduling algorithm:

Summary

  • Information hiding is the strategy for deciding what goes inside deep modules.
  • Hide three things: implementation details, data representations, and design decisions that might change.
  • Information leakage is the enemy: explicit (in types) or implicit (in behavior).
  • Temporal decomposition splits by time instead of by information. Prefer information-based decomposition.
  • Generality helps hiding because general modules assume less about their context.
  • Do not generalize prematurely. Generalize when a real pattern emerges.

Next, we ask: what makes an abstraction good?