Guide

A module (class, package, service, library) has an interface (what callers must know) and an implementation (what they can ignore). A deep module hides a lot of complexity behind a small interface. A shallow module exposes almost as much complexity as it contains — a thin wrapper that adds names without adding leverage.

Concept

Information hiding is the mechanism: put decisions (timeouts, serialization, retry policy, cache keys) inside the module so callers do not re-implement them. This is how you cut the complexity symptoms Ousterhout names.

The big idea

Pass-through layers that only rename methods (UserService calling UserRepository calling UserDao with the same three fields) add cognitive load without hiding anything.

TypeScript
// Shallow: callers still manage retries, status codes, and JSON shape.
async function httpGet(url: string): Promise<Response> {
  return fetch(url)
}

// Deeper: callers get a domain result; retries and parsing stay inside.
async function getUser(id: string): Promise<User> {
  const res = await fetchWithRetry(`/api/users/${id}`, { retries: 3 })
  if (res.status === 404) throw new NotFoundError("user", id)
  if (!res.ok) throw new ApiError(res.status, await res.text())
  return UserSchema.parse(await res.json())
}

The second function is a sketch of the idea, not a runnable SDK. Callers of getUser do not re-implement retries, 404 handling, or JSON parsing. Callers of httpGet still do all three.

Trade-off

Many small files vs fewer deeper modules. Tiny files look tidy in a tree view but force readers to hop across ten files to follow one flow. Prefer a module that owns a complete responsibility — even if the file is a few hundred lines — over a shallow folder of 40-line pass-throughs. Split when two responsibilities truly diverge, not because a line count linter complained.

Walking through a reading pass

When you open a legacy area:

  1. Find the entry point (HTTP handler, job, public function).
  2. Trace one success path end to end before hunting abstractions.
  3. Note invariants in a scratch file ("refund requires paid status").
  4. Only then tidy — ideally behind characterization tests.

Do not refactor for taste on day one of a new codebase. Consistency with local patterns beats your preferred style until you understand why the weirdness exists (often: an incident, a regulation, a performance hack). See refactoring and legacy.

Common mistakes

  • A UserService / UserRepository / UserDao stack that all pass the same three fields.
  • Splitting a file because of a line-count linter while the responsibility is still one thing.
  • Refactoring for taste before you have traced a success path.

Check yourself

UserService.get(id) only calls UserRepository.get(id) which only calls UserDao.get(id). Deep or shallow?

Shallow. Three names, no hidden decisions.

A payment module is 400 lines and owns retries, parsing, and domain errors behind charge(orderId). Should you split it because of length?

Not for length alone. Split when a second responsibility appears, not when a linter complains.

Key takeaways

  • Deep modules hide complexity behind a small interface.
  • Information hiding is the mechanism: retries, encoding, and domain rules live inside.
  • Tiny pass-through files raise cognitive load. Prefer one complete responsibility.
  • Read a success path before you tidy.

Further learning

Next, the first documentation the next engineer reads: naming and comments.