Key Ideas from A Philosophy of Software Design
There's a moment when joining a new codebase where you can feel whether the design is working in your favor or against you. Not when the build fails, not when the tests are missing — before that. When you open a file and can't find an entry point. When a function's signature tells you nothing about what it actually does. When changing one small thing requires understanding five others first. That feeling — of reaching for solid ground and finding nothing — is what John Ousterhout calls complexity. And his book A Philosophy of Software Design is essentially one sustained argument that reducing it is the most important job in software design.
I picked this up partly because I'd started to feel uneasy with advice I'd heard many times — keep functions small, avoid comments, let the names speak for themselves — that sounded right but didn't quite match what I observed in practice. Ousterhout turned out to be a useful corrective.
Complexity Has a Real Definition
Most developers have an intuition for what makes a codebase hard to work in, but "complexity" tends to stay vague. Ousterhout makes it concrete by breaking it into three symptoms.
Change amplification: a simple conceptual change requires editing many different places in the code. You want to change how a date is formatted — and that logic is in six files.
Cognitive load: completing a task requires knowing more than you'd expect. The function is short and clean, but to understand it you need to know three things about the caller's assumptions, a global constant that was set elsewhere, and a side effect that happens two frames up the stack.
Unknown unknowns: the most dangerous kind. You don't know what you don't know, so a change you believed was safe has consequences you never anticipated. This is the one that breaks production.
Of these, unknown unknowns are by far the worst. Change amplification is annoying. Cognitive load is exhausting. Unknown unknowns are the ones you only discover after the fact.
The two underlying causes of all three symptoms are dependencies (when code can't be understood or changed in isolation) and obscurity (when vital information isn't apparent). Every practical suggestion in the book traces back to one of these.
Deep Modules Are Better Than Small Ones
This is the book's most interesting idea, and the one that most directly challenges conventional wisdom.
The common advice is to keep modules, classes, and functions small. Ousterhout pushes back — not because small is bad, but because small is the wrong dimension. What matters is the ratio between the complexity of a module's interface and the complexity it hides.
He calls this the distinction between shallow and deep modules.
A shallow module has a lot of surface area relative to what it does on your behalf. A function that wraps a single line of code with a name is shallow — you've added an abstraction without reducing anything the caller needs to understand. You've actually made things slightly worse, because now there's another file to navigate.
A deep module hides enormous complexity behind a minimal interface. His canonical example is Unix file I/O: five system calls — open, read, write, seek, close — hide the entire complexity of file systems, disk blocks, directory structures, permissions, and concurrent access. The interface is tiny. The implementation is vast. You get all of that hidden complexity for almost nothing in terms of what you need to hold in your head to use it.
graph TB
subgraph shallow["Shallow Module — wide interface, thin implementation"]
direction TB
s_caller["Caller"] --> s1["setFirstName()"]
s_caller --> s2["setLastName()"]
s_caller --> s3["setEmail()"]
s_caller --> s4["setAge()"]
s1 & s2 & s3 & s4 --> s_impl["sets a field"]
end
subgraph deep["Deep Module — narrow interface, rich implementation"]
direction TB
d_caller["Caller"] --> d1["write(fd, buf, n)"]
d1 --> d_a["buffer management"]
d_a --> d_b["disk block allocation"]
d_b --> d_c["permissions"]
d_c --> d_d["concurrency handling"]
end
This reframes a lot of common advice. You can have small functions that still produce a shallow system if those functions all expose as much complexity as they hide. The goal isn't small — it's deep. A module earns its place in a codebase by doing more for you than it demands from you.
Tactical vs. Strategic Programming
Every shortcut in a codebase is a small deposit into a debt account that compounds over time. Ousterhout is precise about this: complexity is incremental. No single tactical decision destroys a codebase. But every "I'll clean this up later" is a small addition to cognitive load, a small increase in change amplification, a small expansion in the number of things someone needs to understand before they can confidently make a change.
Tactical programming is the mode most teams default to under pressure. The deadline is real, the feature is needed, and the cleanest design can wait. Strategic programming is the alternative: treating design quality as an investment that pays dividends in future development speed.
The uncomfortable truth is that tactical programming is locally rational but globally destructive. Each individual shortcut is justifiable in the moment. The aggregate is a codebase where adding features gets slower every quarter, where developers become afraid to touch things they didn't write, where bugs appear in unexpected places because the system is too tangled to reason about.
Ousterhout estimates that strategic programming adds about 10–20% overhead upfront. For teams already behind, that feels unaffordable. The payoff is that the system doesn't gradually seize up. Over a two- or three-year lifecycle, the investment pays for itself many times over.
Comments Document the Interface, Not the Code
Ousterhout's take on comments is one of the more interesting points in the book, especially in contrast to the prevailing Clean Code view that a comment is essentially a failure — evidence that you couldn't make the code self-explanatory.
His argument is more precise. Comments should capture information that can't be derived from the code itself. Not what the code does (which a good name and clear logic already say), but what the reader needs to know: what invariant does this class maintain? What does this parameter actually represent, not just its type? What assumptions does this function make about its inputs?
This is the difference between describing behavior and documenting abstraction. A function named calculateDiscount tells you what it does. It doesn't tell you whether it's safe to call with a zero-value order, whether it handles currency rounding, or what "discount" means in the context of this system. Those things live in the gap between code and understanding, and comments are one of the only tools for closing it.
The practical version: the interface comment for a function should be written by thinking, "what does a caller need to know to use this correctly?" — not "what does this code do?" If you can derive the answer entirely from reading the implementation, you haven't written a useful comment.
Different Layers, Different Abstractions
One of the cleaner heuristics in the book: each layer of a system should change the vocabulary of the layer below it. If a layer just renames what the layer beneath it does without adding a new way of thinking about the problem, it's not providing value — it's adding surface area.
A passthrough function, one that calls another function of exactly the same shape without translating or combining anything, is the simplest case. It makes the system larger without making it simpler. The caller now has an extra stop to make on the way to the thing that actually does the work.
This is a useful check to apply to service layers, middleware, and wrappers. The question is: does this layer give its callers a different (and better) way to think about the problem? Or is it just routing data between two things that could communicate more directly?
Still Thinking About
Design it twice. Ousterhout advocates always exploring at least two substantially different designs before committing to one. The first design you arrive at is rarely the best — it's usually the first one that seemed to work. Sketching a genuinely different alternative, even briefly, forces you to examine assumptions you didn't know you were making. I believe this is right. I haven't found a consistent way to do it when there's pressure to move fast, and I'm not sure how to solve that tension beyond just accepting the discipline.
The TDD argument. Ousterhout is skeptical of test-driven development, arguing that building in tiny test-driven increments interferes with thinking through larger design questions before writing code. Robert Martin, author of Clean Code, pushed back on this directly — the two of them published a public discussion of their disagreements, which is worth reading on its own terms. My honest position is that both are identifying real failure modes. Ousterhout's concern — that TDD produces shallow, test-shaped code — is real. But so is the failure mode TDD prevents: code that turns out to be untestable because nobody thought about the seams. I don't have a resolved view on this, which probably means the answer is contextual in ways that are hard to prescribe.
Honest Take
Read this if you've started to feel uneasy with advice that sounds right but doesn't quite match what you observe in real codebases. The parts of Clean Code that felt like they should work but never quite did in practice — this book often explains why, from a different angle. It's short (around 200 pages), consistently opinionated, and grounded in Ousterhout's experience teaching a software design course at Stanford where students built a working distributed system from scratch. The ideas generalize well beyond that context.
The one caveat: some of the advice is calibrated toward large, long-lived systems where complexity accumulates slowly and design decisions compound over years. If you're building something small and genuinely temporary, the strategic investment calculus shifts. But most of us underestimate how long our software will live and how complex it will eventually become. Erring toward depth is usually the right call.