Based on my notes from reading A philosophy of software design by John K Ousterhout , this post draws upon content from only the first five chapters of the book, liberally quoting directly from the text.
‘Ask yourself’ section is the core of my notes, something I come back time and again, this post is as much for me as it is for you.
1. It’s All About Complexity
Complexity is the root of software design challenges, a force that accumulates invisibly with every feature, every dependency, every shortcut, until the system becomes a tangled web, hard to understand and harder still to change.
“If software developers should always be thinking about design issues, and reducing complexity is the most important element of software design, then software developers should always be thinking about complexity.”
“It’s easier to see design problems in someone else’s code than your own. Reviewing code will also expose you to new design approaches and programming techniques.”
“Every rule has its exceptions, and every principle has its limits.”
There are two main ways to fight complexity in software.
The first approach is to eliminate complexity by making code simpler and more obvious, cutting out special cases and using identifiers consistently, so that anyone reading the code can quickly grasp what’s happening, and so that changes become easier and less risky.
The second approach is to encapsulate complexity, hiding the messy details inside well-defined modules, letting programmers work on parts of the system without being overwhelmed by everything at once. This is modular design, which allows teams to build and maintain large systems by breaking them into manageable, independent pieces.
Questions to ask yourself:
- Am I making this feature more complex than it needs to be?
- Could someone new to the project understand how this part works?
- What would happen if this feature needed to change —would it be easy or hard?
- Are you overdoing a good design approach? Every rule has its exceptions, and every principle has its limits.
2. The Nature of Complexity
There are three symptoms of complexity: change amplification (one change triggers many edits), cognitive load (the mental effort to understand code), and unknown unknowns (surprises lurking in the system).
“Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.”
” Complexity is what a developer experiences at a particular point in time when trying to achieve a particular goal. It doesn’t necessarily relate to the overall size or functionality of the system.”
“Complexity is defined by activities that are most common. If a system has few parts that are very complicated, but those parts almost never need to touched, then they don’t have much impact on the overall complexity of the system”
“One of the most important goals of a good design is for a system to be obvious.”
“If a system has a clean and obvious design, then it will need less documentation. The need for extensive documentation is often a red flag that the design isn’t quite right.”
When a system is obvious, a developer can quickly see how the code works and what needs to change. They can make a guess about what to do, without much thought and trust that guess is reasonably right.
When the code is clear and well-organized, it feels easy to follow, like walking down a familiar street. Each part of the system makes sense on its own and fits together smoothly. So, when a developer needs to make a change, they don’t get lost. They just keep moving forward, confident that the design will guide them in the right direction.
What causes complexity?
Two things: Dependencies and Obscurity often are the root causes of complexity.
Dependencies are the building blocks of software systems; inescapable and necessary, but when kept simple and obvious, they help reduce overall system complexity. Software can never be entirely free from dependencies, nor should it try to be.
Obscurity, on the other hand, is a different challenge, creeping in when important information isn’t clear, often because dependencies are hidden or poorly managed.
Obscurity is further compounded by inconsistency and a lack of documentation, which together make it harder for developers to understand what’s going on, though it’s ironic that obvious design can actually lessen the need for deep documentation.
While a single dependency or a bit of obscurity rarely causes trouble, complexity really sneaks in when hundreds of these small dependencies and hidden details pile up over time, each one quietly adding to the confusion until the system feels like a puzzle with missing pieces.
Questions to ask yourself:
- If I need to change a requirement, how many places in the code will I need to touch?
- Is there any part of this code that’s hard to hold in my head all at once?
- Can I create a list of “surprises” (obscurities) and hidden dependencies lurking in this module?
- How do I refactor to make the dependencies very obvious?
3. Working Code Isn’t Enough
Downward spiral of Tactical Programming
Tactical programming is what you do when your only focus is simply to get something working at any cost, it becomes nearly impossible to create a good system design, because you don’t spend much time searching for the best approach; you just want results fast, and so you skip over opportunities to simplify and clarify.

When you program tactically, each task adds a few small complexities, each seeming like a reasonable compromise at the time, but these complexities pile up quickly, and before long, they start causing real problems, making the code harder to understand and harder to change.
Now there’s no time to refactor, because the next feature is already overdue, so you look for quick patches, which only create more complexity and require even more patches, turning the code into a tangled mess that would take months to clean up, while your schedule can’t handle that kind of delay, and fixing just one or two problems doesn’t seem to make much difference.
I know this, because I’ve been tangled in several such webs myself, each time thinking I had found a quick way out, only to discover that every shortcut led to more knots, every patch created new problems, and every compromise tightened the grip of complexity around me, until I realized there is no way out.
“The first step towards becoming a good software designer is to realize that working code isn’t enough. It’s not acceptable to introduce unnecessary complexities in order to finish your current task faster. The most important thing is the long-term structure of the system.”
“Most of the code in any system is written by extending the existing code base, so your most important job as a developer is to facilitate those future extensions. Thus, you should not think of working code as your primary goal, though of course your code must work. Your primary goal must be to produce a great design, which also happens to work. This is strategic programming.“
“The term technical debt is often used to describe problems caused by tactical programming. You are borrowing time from the future: development will go more quickly now, but more slowly later on. As with financial debt, the amount you pay back will exceed the amount you borrowed. Unlike financial debt, most technical debt is never fully repaid: you’ll keep paying and paying forever.”
Absolutely! Here’s your passage rewritten with sharp, short cumulative sentences, building momentum and clarity:
Strategic programming demands an investment mindset, some of it proactive, some reactive.
It’s worth taking extra time to explore a few design alternatives for each new class, not just settling on the first idea, because choosing the cleanest design now saves headaches later. Good documentation is another proactive investment, clarifying intent and easing future changes.
Many other investments will be reactive. As with any growing systems, design mistakes slip in no matter how much you plan ahead, and over time these mistakes surface, demanding attention. When you spot a design problem, don’t ignore it or patch it over; take the extra time to fix it properly, and with each fix, you make the system a little better.
This approach steadily improves system design, in sharp contrast to tactical programming, which only piles on complexity with every quick fix.
In the real world, you can’t escape tactical programming; deadlines are always looming, pushing you to deliver quickly. Recognize that blending in strategic programming, balancing short-term pressures with thoughtful design, can help you avoid spiraling webs of complexity in the near future,
Questions to ask yourself:
- What trade-offs are we making for Speed?
- Are we skipping design exploration too frequently?
- Are we allowing time for refactoring and improvement? May be 10-20%. Is that too much to ask?
- Is this code easy to read and understand, or just easy to write?
- Would another developer be able to extend or fix this without asking me for help?
4. Modules Should Be Deep
The best modules offer powerful functionality through simple, minimal interfaces, hiding their complexity within.
But why? First, a simple interface minimizes the complexity than a module imposes on the rest of the system. Second, if a module is changed without changing its interface, then no other module will be affected by the modification.
Modules can be thought of in two parts: an interface and an implementation. The interface is what a developer working in a different module must know to use the given module, the implementation is none of his concern really.
The interfaces in turn can be considered in two ways, the explicit formal promise ( the method signature) and the informal unspecified promise that includes its high-level behavior such as that a file gets deleted somewhere based on the one of the input arguments. This is a good place to add comments.
“The best modules are those that provide powerful functionality yet have simple interfaces. I use the term deep to describe such modules. A shallow module is one with a relatively complex interface, but not much functionality: it doesn’t hide much complexity.”
“Providing choice is good, but Interfaces should be designed to make the common case as simple as possible”
Thinking about some of the python packages that we commonly use, most of us would need to be aware of a few module imports (interfaces) irrespective of the overall complexity of the package. To a developer, the effective complexity of that package is just the complexity of the commonly used imports.
Questions to ask yourself:
- Is this code easy to read and understand, or just easy to write?
- Are your modules too shallow? It is probably shallow when the interface is complicated relative to the functionality it provides?
- Are the mostly commonly used interfaces of my module simple enough?
- Have I documented or explained any non-obvious logic?
5. Information Hiding (and Leakage)
Hide internal details and expose only what’s necessary, keeping implementation secrets safe from the rest of the codebase. Think carefully about what information can be hidden in a module. If you can hide more information, you should also be able to simplify the module’s interface, and this makes the module deeper.
“It is more important for a module to have a simple interface than a simple implementation.”
“Information hiding can often be improved by making a class slightly larger.”
“Information hiding reduces complexity in two ways. First, it simplifies the interface to a module, reducing cognitive load on developers who use the module. Second, information hiding makes it easier to evolve the system. If a piece of information is hidden, a design change related to that information will affect only the one module.”
“The opposite of information hiding is information leakage. Information leakage occurs when a design decision is reflected in multiple modules: any change to that design decision will require changes to all of the involved modules. If a piece of information is reflected in the interface, then by definition it has been leaked.”
“In temporal decomposition, the structure of a system corresponds to the time order in which operations will occur. This order is often in your mind when you code. However most design decisions manifest themselves at several different times over the life of the application; as a result, temporal decomposition often results in leakage.”
With temporal decomposition, an application that needs to read a file , modify the file and write file out again might be broken down into three classes, one for each operation based on the time order or execution order of operations. Both the file reading and writing steps have knowledge about the file format, which results in information leakage. The solution is to combine the core mechanisms for reading and writing files into a single class.
Questions to ask yourself:
- Am I exposing internal details that should remain private?
- Could another part of the codebase break if I change this module’s internals?
- How can I reorganize these classes so this particular piece of knowledge only affects a single class?
- Are there any framework-specific details leaking into my business logic?
- When decomposing modules, are you overly influenced by the order in which operations occur at run time? Temporal decomposition is waiting for you.
- Is your class or module deep enough? Can all the code related to a particular capability be brought together into a single class.
Before you go
Take all the design advice with a pinch of salt and may be some pepper as well, minding that no single piece of wisdom can fit every use-case .
Each cat, should you ever find yourself needing to skin one (metaphorically, of course), can be skinned in multiple ways, each method revealing a new philosophical dilemma, a fresh perspective on life’s messiness, and a reminder that, just as cats are mysterious creatures, so too is the path to wisdom—best approached with curiosity and a dash of humor.
Leave a comment