Managing Complexity: Lessons from A Philosophy of Software Design
By Jimmy Lindsey
Jan. 28, 2026 | Categories: development, thoughts, books, senior-engineerI’ve read plenty of software engineering books over the years, but only a few have genuinely changed how I think about writing and maintaining code. A Philosophy of Software Design, 2nd Edition by John Ousterhout is one of them.
What makes this book stand out isn’t that it offers a long list of rules to follow, but that it provides a clear mental model for understanding complexity and how it accumulates over time. In this post, I’ll summarize the ideas I found most valuable and explain how they’ve influenced the way I approach software design.
Complexity
Ousterhout starts off the book by defining what he considers to be the fundamental task in computer science: problem decomposition. That is to say, to divide a complicated problem into small, independent pieces. If problems weren’t complicated, we wouldn’t need to divide them at all. Even if we still had to, it would be simple to do. It is the complexity of the problems software engineers face that makes our jobs tricky.
"Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system." If you haven't figured out right now, recognizing complexity and how to handle it is the central theme of the book.
He then goes on to suggest 3 symptoms of complexity
- Change amplification
- Cognitive load
- Unknown unknowns
All three of these things are signs that your software is getting complex. Change amplification is when even a simple change requires code to be modified in multiple places. You can see this if you change the interface of a class in a way that requires changes wherever that class is instantiated. Cognitive load, I believe, is something every developer has felt many times. It sometimes feels like you have a bucket that is just a bit too small for all the information you need to collect to truly understand what is going on. Finally, unknown unknowns are another situation that I think developers would be very familiar with. How many times have you tried to do something only to learn you were barking up the wrong tree, or that you had only discovered step 5 of 23?
There are two main causes of complexity:
- Dependencies
- Obscurity
Dependencies, of course, are a fundamental part of software development. When we are talking about dependencies, we don't just mean external dependencies like a PyPI or NuGET package, we also mean dependencies created inside the software. For example, if a method in class A takes as a parameter an object of class B, then there is a dependency in class A on class B. In practice, we create dependencies every time we write code. However, if we can reduce the number of dependencies, then our code will become simpler. The two symptoms dependencies cause are cognitive load and change amplification.
For obscurity, it is when information that the developer needs to know to make changes are not obvious. Often, obscurity is associated with dependencies, because sometimes it is not obvious that a dependency exists. The two symptoms caused by obscurity are unknown unknowns and cognitive load.
Lastly, Ousterhout points out that complexity often occurs throughout the code in little ways. Usually, it is not just one module in the code that is monstrously complicated, instead it is hundreds or thousands of small dependencies and obscurities. In fact, there are usually so many small issues that every change is affected by several of them. It is the incremental nature of software development that makes all of these tiny complexities hard to control.
Summary of Red Flags
- Shallow Module
- the interface for a class or method isn't much simpler than its implementation
- Information Leakage
- a design decision is reflected in multiple modules
- Temporal Decomposition
- the code structure is based on the order in which operations are executed, not information hiding
- Overexposure
- An API forces callers to be aware of rarely used features in order to use commonly used features
- Pass-Through Method
- a method does almost nothing except pass its arguments to another method with a similar signature
- Repetition
- a nontrivial piece of code is repeated over and over
- Special-General Mixture
- special-purpose code is not cleanly separated from general-purpose code
- Conjoined Methods
- two methods have so many dependencies that its hard to understand the implementation of one without understanding the implementation of the other
- Comments Repeats Code
- all of the information in a comment is immediately obvious from the code next to the comment
- Implementation Documentation Contaminates Interface
- an interface comment describes implementation details not needed by users of the thing being documented
- Vague Name
- the name of a variable or method is so imprecise that it doesn't convey much useful information
- Hard to Pick Name
- it is difficult to come up with a precise and intuitive name for an entity
- Hard to Describe
- in order to be complete, the documentation for a variable or method must be long
- Non-obvious Code
- the behavior or meaning of a piece of code cannot be understood easily
Strategic Programming vs. Tactical Programming
The main way that Ousterhout suggests to deal with complexity is to practice what he calls "strategic programming". He contrasts this with "tactical programming", which is a programming style that does whatever it can to push features out the door by making sure code works, design be damned. For the strategic programmer, however, working code isn't enough by itself. In fact, the main goal of strategic programming is great design that can easily be maintained. Of course, the well designed code still needs to work, and there will definitely be situations where you will need to release a hot fix or push out a feature quickly, but hopefully those situations will be rare.
Strategic programming requires an investment mindset. You are going to invest more time now, to save yourself time in the future. That might mean you need to spend more time designing your solution, or it may mean that you dedicate some time to improve the health of the system. Ousterhout suggests you spend 10-20% of your time on small investments, and that with every feature you add or bug you fix, you make an additional change to make the system better.
As discussed earlier, most systems become complex through the accumulation of many small decisions. In order to avoid this bit rot, some amount of time has to be put into improving this complexity. In the end, it becomes clear that a well designed system is easier to program in, so you may eventually be able to develop 10-20% faster, and then your future investments will become free. The best time to do this was when you first started, but the second best time is now. Invest today!
How to Deal with Complexity
Most of the rest of the book talks about various strategies for how to deal with complexity. I am not attempting to summarize Ousterhout's whole book, so I will only talk about a few of the most significant ideas that change how I think about dealing with complexity.
Modules Should be Deep
If you have ever read Clean Code by Robert Martin, then this section will be a treat for you. Martin suggests that classes (and methods) should be as short as possible, whereas Ousterhout is arguing for what is essentially the opposite. I am not going to say that Martin's book has no value, but Ousterhout does make a pretty good argument on why this is the better way to do things. Of course, if you try to do silly things like jam a whole massive system into one class, that would be a very deep module, but one that would be pretty much impossible to work with. That wouldn't be very good design, and it would be needlessly complex.
Ousterhout argues for a modular design to be used. This way, developers only need to understand a small part of the complexity of the system at a time. The goal of such a system is to limit the dependencies between modules, and the best modules have interfaces that are simpler than their implementations. The interface is what other modules of the system will use, so a simple interface will minimize complexity. In addition, if you modify a module without changing its interface, then you do not need to make any changes in other modules that use that interface. When he says a module, he is usually talking about a class, but he said that the same statement applies to functions and methods as well.
The way to minimize the complexity of an interface is to design abstractions. If you can design a good abstraction by understanding what is important, you can use them to hide the unimportant info. This will make it easier for developers using your module. The best modules are deep because they provide powerful functionality with a simple interface. This makes sense to me. If the interface is more complex (or just as complex) as the implementation, then in some cases it may be easier for me to implement it myself rather than use what is already implemented. It is also nice to have a black box where I can just give it data, and have it spit out what I need without needing to understand how it got that result.
Finally, he presents the idea that interfaces should be designed to make the common case as simple as possible. This also makes sense. If it's the common case, then that should be the default usage of the module. For rare cases, it may be okay to have to some more complexity, because the situation will not come up very often.
Information Hiding and Leakage
Modules should encapsulate a few pieces of knowledge, which represent design decisions. This allows the module to hide information, which makes it simpler to use. For example, if you call a function that returns the most common number in a list of numbers, you don't care that the function uses a hashmap to figure out that information, you only care that it returns the correct data to you. The opposite of information hiding is information leakage, which occurs when a design decision causes a dependency between two or more modules. This results in those modules needing to know about the implementation details, and as a result increases complexity. As a happy coincidence, the simpler an interface is, the more information is hidden.
You can also hide information within a class. For example, the private methods of a class can encapsulate some behavior or capability. The rest of the class can use it, but doesn't need to be aware of the hidden information (such as implementation details). You can also use it to minimize the number of places where an instance variable is used to eliminate dependencies within the class. Doing either of these things would reduce a class's complexity, and therefore make it easier to use.
Of course, it is possible to take information hiding too far. Sometimes, the information is legitimately needed outside of a module. If this is the case, then you must not hide it.
Design It Twice
This was probably my favorite overall chapter of this book. Designing software is hard, which means it is unlikely that the first way you came up with to implement a piece of code will be the best design. This is especially true if you are new to programming or new to the project. So don't just design it once, instead design it twice. When you do this, its important to try approaches that are radically different from each other, so you can learn the most. In fact, you should do this even if you are certain that you already have a good approach. With two ideas, you can compare them. For example, here are some considerations you can make when comparing designs:
- Which interface is the best for ease of use of higher level software?
- Which alternative has the simplest interface?
- Is one interface more general-purpose than another?
- Does one interface enable a more efficient implementation than another?
The key insight is that designing it twice doesn’t require much extra time. For a class, it may only take you an hour or two to consider alternatives. Regardless, this consideration will likely take you less time than implementing the class. You do get something for your time, though, as you will end up with a better design overall. By designing it twice, you will also improve your design skills.
I have encountered this multiple times in my career. The most recent example I can think of, I was upgrading a class from .NET Framework 4.8 to .NET 8. At the same time, I was also working on parallelizing the tests. When I first started, I came up with a design and started to implement it, but about an hour in I had already hit some serious complexities. So I stopped, thought about the best way to implement everything, reverted all of my changes, then moved forward. The result was a much better design, and I was able to get all of the tests to run in just under an hour.
Comments
The book actually has 3 chapters dedicated to comments, but I want to summarize all three here. Comments are often reviled, but I have found that without them, the system would be much more complex than with them. The first consideration is that correctly written comments will improve a system's design. On the other side, good software without good documentation will lose a lot of its value.
Comments are also fundamental to abstractions, as the comment can stop a developer from having to read the implementation of the code. Also, there is a lot of design information that cannot be represented in code, such as: the informal aspects of an interface, the rationale behind a particular design decision, and the conditions under which it makes sense to call a particular method. Good comments will also help with the maintainability of the software, so spending a little extra time to write good comments will be helpful in the long term. In the end, the overall idea behind comments is to capture information that was in the mind of the designer, but couldn't be represented in code.
The most significant thing that comments can do is to describe things that aren't obvious in code. This means that comments shouldn't just repeat what the code does. Comments can augment the code by providing information at a different level of detail. You can write a lower-level comment that adds precision by clarifying the exact meaning of the code. Or you can write a higher-level comment that can enhance the intuition on how the code works.
For these reasons, Ousterhout suggests that comments should be written first. When comments are put off until the end of the development process, it usually results in poor documentation. This is because delaying documentation means that it will never be written, or if it is, your original ideas are a bit fuzzy in your mind since it has been some time since you wrote it. Instead, if you write the comment before your code, your ideas will be fresh. Comments are also a great design tool. They help you fully capture abstractions, as you can review and tune those abstractions before you even write any implementations. It is also much more fun to write comments when it is early. In the end, it may actually be faster to write comments first.
Choosing Names
Ousterhout starts off by saying that selecting names for variables, methods and other entities are one of the most underrated aspects of software design. However, I think it’s an area many developers already recognize as important. That said, I agree that good names do make code easier to understand, which results in the system being less complicated, and therefore they are important.
When choosing a name, create an image in the mind of the reader about the nature of the thing being named. Good names convey information about what the underlying entity is, and what it is not. Here are a couple of good questions to ask yourself when trying to come up with good names:
- "If someone sees this name in isolation, without seeing the declaration, documentation, or any code that uses the name, how closely will they be able to guess what the name refers to?"
- "Is there some other name that will paint a clearer picture?"
Keep in mind that there is a limit to how much information you can put in one name, as names that are really long become unwieldy after a while. Ousterhout suggests two or three words, but I think you shouldn't look at the word count necessarily. Also, sometimes a long name really is appropriate. Make sure your name focuses on what is most important about the underlying entity, and omit details that are not important.
Names should be precise and consistent. For example, all boolean variables should be predicates. Consistent naming like that will allow the reader to understand a bit more about the variable when they see similar names in a different context. Generic names like i and j in loops are fine, as long as the loop or code block isn't too long. Finally, try to avoid extra words like "field" or "object" in a name.
Summary of Design Principles
- Complexity is incremental: you have to sweat the small stuff
- Working code isn't enough
- Make continual small investments to improve system design
- Modules should be deep
- Interfaces should be designed to make the most common usage as simple as possible
- It's more important for a module to have a simple interface than a simple implementation
- General-purpose modules are deeper
- Separate general-purpose and special-purpose code
- Different layers should have different abstractions
- Pull complexity downward
- Define errors out of existence
- Design it twice
- Comments should describe things that are not obvious from the code
- Software should be designed for ease of reading, not ease of writing
- The increments of software development should be abstraction, not features
- Separate what matters from what doesn't matter and emphasize the things that matter
Final Review
One of the most impressive aspects of A Philosophy of Software Design is how much it delivers in a relatively short book. It’s dense, but intentionally so, focusing on principles that hold up across languages, frameworks, and trends rather than drowning the reader in examples.
What stood out to me most is how the book reframes software design as an ongoing responsibility, not something you complete before writing code. Small decisions compound over time, and being deliberate about them is often the difference between a system that’s easy to evolve and one that constantly resists change.
You don’t need to agree with everything Ousterhout says for this book to be valuable. Its real strength is in sharpening your ability to notice complexity and think more carefully about preventing it. For me, that alone makes it well worth reading.