Craft of code
Deep modules
A deep module hides a lot of complexity behind a small interface. Shallow pass-through layers add names without adding leverage.
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.
// 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:
- Find the entry point (HTTP handler, job, public function).
- Trace one success path end to end before hunting abstractions.
- Note invariants in a scratch file ("refund requires paid status").
- 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/UserDaostack 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.
Related
Writing code others can maintain
Managing complexity with deep modules, clear naming, honest comments, consistent style, and error strategies that shrink the blast radius of change.
Software complexity
Complexity shows up as change amplification, cognitive load and unknown unknowns. The next stranger's change is the cost that matters.
Interfaces that age well
Design the interface for the caller's job. Take the smallest inputs, make illegal states hard, and keep one obvious way to do each thing.