A Philosophy of Software Design
John Ousterhout's central thesis: the greatest limitation in writing software is our ability to understand the systems we are creating. Complexity is the root of most problems in software, and the primary goal of good design is to reduce complexity.
What is Complexity?
Complexity is anything in a system that makes it hard to understand and modify.
Three symptoms of complexity:
| Symptom | Description |
|---|---|
| Change amplification | A simple change requires modifications in many places |
| Cognitive load | You need to know a lot to make a small change |
| Unknown unknowns | It's unclear what you need to change, or whether you've done it correctly |
> Complexity is incremental. It accumulates in small doses — no single decision ruins a system, but many small shortcuts do.
Tactical vs Strategic Programming
Tactical Programming
Working code first. Minimum effort. Shortcuts accepted.
Strategic Programming
Invest time in good design now to move faster later.
> Ousterhout recommends spending ~10–15% of total development time on proactive design investment.
Deep vs Shallow Modules
A module is any unit with an interface and an implementation (function, class, service).
Interface (what callers see)
┌────────────────────────────┐
│ │ ← Deep module: small interface,
│ Implementation │ large hidden functionality
│ │
└────────────────────────────┘
┌────────────────────────────┐
│ Interface (nearly as big) │ ← Shallow module: interface cost ≈
└────────────────────────────┘ implementation benefit
Deep modules provide powerful functionality through a simple interface. They are the best tool for managing complexity.
Shallow modules expose most of their implementation through a large interface — callers gain little abstraction value.
# ❌ SHALLOW: caller must manage all details
def read_file_chunk(fd, buf, offset, length, flags):
...
# ✅ DEEP: complex I/O hidden behind a simple interface
def read_file(path: str) -> str:
...
> Unix file I/O is a classic deep module: five calls (open, read, write, lseek, close) hide thousands of lines of OS complexity.
Information Hiding
Each module should encapsulate a few design decisions that other modules do not need to know about.
# ❌ Information leaking — caller must know about HTTP internals
class UserService:
def get_user(self, user_id: str) -> dict:
response = requests.get(f"/users/{user_id}")
return response.json()["data"]["user"] # caller now knows the API shape
# ✅ Information hidden — caller just gets a User
class UserService:
def get_user(self, user_id: str) -> User:
response = self._client.get(f"/users/{user_id}")
return User.from_dict(response["data"]["user"])
Information leakage is the opposite: a design decision is reflected in multiple modules, creating a dependency between them.
General-Purpose vs Special-Purpose Interfaces
> Make modules somewhat general-purpose — solve today's problem in a way that's general enough to be useful in the future.
The sweet spot is not fully general (over-engineering) but also not so special-purpose it only solves one narrow case.
Ask yourself:
# ❌ Too special-purpose — only works for one exact scenario
def delete_selected_text_and_save_clipboard(editor):
...
# ✅ General enough — composable, reusable
def delete(editor, start: int, end: int) -> str:
...
Different Layer, Different Abstraction
Each layer in a system should offer a different vocabulary from the layers above and below it. If adjacent layers have similar abstractions, there is probably a missing design decision somewhere.
# ❌ Pass-through methods add no value — same abstraction, two layers
class TextEditor:
def backspace(self):
self.buffer.backspace() # exact same concept, just delegated
# ✅ Each layer adds something
class TextEditor:
def backspace(self):
deleted = self.buffer.delete(self.cursor - 1, self.cursor)
self.history.record(deleted)
self.cursor -= 1
Pull Complexity Downward
When complexity is unavoidable, hide it in the implementation rather than pushing it up to callers.
> It is more important for a module's interface to be simple than for its implementation to be simple.
# ❌ Pushes complexity to every caller
def read_config(path: str) -> dict:
# caller must handle FileNotFoundError, JSONDecodeError, missing keys
with open(path) as f:
return json.load(f)
# ✅ Absorbs complexity internally
def read_config(path: str) -> Config:
try:
with open(path) as f:
data = json.load(f)
return Config.from_dict(data)
except FileNotFoundError:
return Config.defaults()
except (json.JSONDecodeError, KeyError) as e:
raise ConfigError(f"Invalid config at {path}: {e}") from e
Choosing Good Names
Names are the primary tool for creating obvious code. A good name conveys what an entity is or does, and sets accurate expectations.
Rules:
do_it, process, data) signals a vague concept# ❌ Vague, forces reader to look at implementation
def handle(data):
...
# ❌ Misleading — sounds like a getter but has side effects
def get_user(id):
user = db.fetch(id)
audit_log.write(f"Accessed user {id}")
return user
# ✅ Accurate and honest
def fetch_and_audit_user(id: str) -> User:
...
Comments
Ousterhout argues most developers write too few comments, not too many.
Comments should describe what is not obvious from the code — the why, the intent, the constraints, the tradeoffs.
# ❌ Restates the code — adds no value
# Increment i by 1
i += 1
# ❌ Missing — leaves reader to reverse-engineer the intent
self._version += 1
# ✅ Explains the non-obvious reason
# Bump version so in-flight cache reads don't serve stale data
# after a write completes. See cache invalidation design doc.
self._version += 1
Four types of comments worth writing:
| Type | Purpose |
|---|---|
| Interface comments | What a module does, its guarantees, its parameters |
| Implementation comments | Why a non-obvious approach was chosen |
| Data structure comments | Invariants, units, valid ranges |
| Cross-cutting comments | Behaviour that spans multiple files or layers |
Define Errors Out of Existence
Exception handling is a major source of complexity. The best strategy is to reduce the number of places exceptions must be handled.
Techniques:
# ❌ Forces every caller to handle the key-missing case
def get(d: dict, key: str):
if key not in d:
raise KeyError(key)
return d[key]
# ✅ Caller states the default — no exception to handle
def get(d: dict, key: str, default=None):
return d.get(key, default)
> Exceptions thrown are part of a module's interface. Every exception you add is complexity you impose on callers.
Consistency
Consistent code reduces cognitive load — once a reader learns a pattern, they can apply it everywhere.
Areas to keep consistent:
> Inconsistency forces the reader to wonder: "Is this different on purpose?" That question is wasted cognitive effort.
Code Should Be Obvious
Obvious code allows a reader to quickly understand what it does and why, without hunting through other files.
Things that make code non-obvious:
dict, list) with implicit structure# ❌ Non-obvious — what does "data" contain? What does process do?
def run(data):
result = process(data)
update(result)
# ✅ Obvious — types, intent, and flow are clear
def run_monthly_billing(invoices: list[Invoice]) -> None:
charges = calculate_charges(invoices)
persist_charges(charges)
Design Twice
Before committing to an implementation, design at least two alternatives and compare them.
> The best design often comes from the comparison, not from either option alone.
Key Takeaways
| Principle | Rule |
|---|---|
| Manage complexity | It's the primary job of a software designer |
| Deep modules | Simple interface, powerful implementation |
| Information hiding | Hide design decisions from callers |
| Pull complexity down | Absorb it in the implementation, not the interface |
| Strategic investment | ~10% of time spent on design pays long-term dividends |
| Good names | Precise names that set accurate expectations |
| Useful comments | Explain the why, not the what |
| Minimize exceptions | Redefine edge cases out of existence where possible |
| Consistency | Same concept → same pattern, everywhere |
| Obviousness | Reader should understand without hunting for context |