Home / Notebooks / Software Design
Software Design
intermediate

A Philosophy of Software Design

Key ideas from John Ousterhout's book on managing complexity in software systems

April 28, 2026
Updated regularly

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:

SymptomDescription
Change amplificationA simple change requires modifications in many places
Cognitive loadYou need to know a lot to make a small change
Unknown unknownsIt's unclear what you need to change, or whether you've done it correctly
Two causes of complexity:
  • Dependencies — when a piece of code cannot be understood or modified in isolation
  • Obscurity — when important information is not obvious
  • > 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.

  • Each feature is a quick patch
  • Complexity grows with every iteration
  • Fast in the short term, expensive in the long run
  • Strategic Programming

    Invest time in good design now to move faster later.

  • Working code is not enough — it must also be simple
  • Proactively find design problems and fix them
  • Accept small upfront costs to avoid larger future costs
  • > 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.

  • Hidden: data structures, algorithms, implementation details, lower-level interactions
  • Exposed: only what callers need to accomplish their goal
  • # ❌ 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:

  • What is the simplest interface that covers my current needs?
  • How many use cases does this interface serve?
  • Is a caller required to know implementation details to use this correctly?
  • # ❌ 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:

  • Names should be precise — a vague name (e.g. do_it, process, data) signals a vague concept
  • If you can't find a short name that captures the full meaning, the design may be wrong
  • Avoid names that conflict with common conventions or surprise the reader
  • # ❌ 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:

    TypePurpose
    Interface commentsWhat a module does, its guarantees, its parameters
    Implementation commentsWhy a non-obvious approach was chosen
    Data structure commentsInvariants, units, valid ranges
    Cross-cutting commentsBehaviour that spans multiple files or layers
    > A comment that describes only what the code already says is worthless. A comment that describes the why is invaluable.

    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:

  • Redefine the semantics so edge cases are no longer errors
  • Absorb the exception at the lowest level rather than propagating it
  • Use default values instead of raising on missing input
  • # ❌ 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:

  • Naming conventions (functions, variables, files)
  • Coding patterns (how errors are returned, how dependencies are injected)
  • Interface styles (positional vs keyword args, sync vs async)
  • File/module structure
  • > 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:

  • Event-driven flows where actions happen far from their triggers
  • Global variables that mutate from unexpected places
  • Generic containers (dict, list) with implicit structure
  • Code that violates reader expectations (surprise side effects, misleading names)
  • # ❌ 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.

  • Forces you to think critically rather than defaulting to the first idea
  • Often reveals a clearly better option or a hybrid
  • Applies to interfaces, data structures, and overall decomposition
  • > The best design often comes from the comparison, not from either option alone.

    Key Takeaways

    PrincipleRule
    Manage complexityIt's the primary job of a software designer
    Deep modulesSimple interface, powerful implementation
    Information hidingHide design decisions from callers
    Pull complexity downAbsorb it in the implementation, not the interface
    Strategic investment~10% of time spent on design pays long-term dividends
    Good namesPrecise names that set accurate expectations
    Useful commentsExplain the why, not the what
    Minimize exceptionsRedefine edge cases out of existence where possible
    ConsistencySame concept → same pattern, everywhere
    ObviousnessReader should understand without hunting for context

    Resources

  • A Philosophy of Software Design — John Ousterhout
  • Lecture Series by Ousterhout — Stanford CS190
  • Topics

    Software DesignComplexityClean CodeSoftware Engineering

    Found This Helpful?

    If you have questions or suggestions for improving these notes, I'd love to hear from you.