Tomáš Bořek

28. 9. 2026

A philosophy of Software Design - J. Ousterhout

Author: Tomáš Bořek

Tags: software-engineering, books

Complexity is what muddles our understanding of systems. We can reason about and focus on only so much, and when the amount of information passes a certain threshold, we tend to forget things and doubt ourselves. Our ability to make sound judgement plummets along with our confidence and ability to maintain oversight. Splitting a larger system into smaller modules helps us focus on fewer things at a time. The art of splitting a formidable problem into pieces is called decomposition, and it lies at the heart of any distributed system or complex codebase.

Indeed, microservices and modular design have been on the rise for the past decade, and while they bring their own pains – like duplication or boilerplate – this approach certainly has immense benefits in the area of dealing with complexity.

In the book A Philosophy of Software Design, John Ousterhout, a Stanford professor and renowned engineer with a fair share of experience in commercial software development, explains where complexity comes from and how to keep it under control.

Get to know complexity

Complexity arises from dependencies and obscurity; these often overlap, and with introduction of one, you're likely introducing the other.

The result of a system riddled with dependent parts and a number of unknowns is a lot of uncertainty and cognitive load added into the development process. This is conducive to bugs, slows down development, and causes even smaller changes to create friction – this is complexity. Next to correctness (which is the baseline quality standard, but it's not enough), it's the main thing you should consider when building software.

Modules as encapsulated abstractions

The best way to tackle complexity is with salient abstractions. The real benefit of decomposing a system into modules (i.e., relatively independent subsystems) is that we can significantly simplify our understanding of an entire logical or structural part of the system. It's sort of a semantic compression by generalization.

A module gives other parts of the system an interface to wield it. The formal part of the interface (the exposed code, naming, etc.) in combination with the informal part (the documentation, comments) together create an abstraction over the implementation. The key is that the abstraction needs to be simpler than the implementation – if it isn't, there isn't much gain in encapsulating the subsystem into a module in the best case, or the system gets more complex by adding boilerplate and more things to worry about in worse cases.

If we visualize modules as rectangles whose height represents the complexity of implementation and width the complexity of the interface, we can say that deep modules provide the most benefit to our system. Shallow modules often clutter the space, introduce complexity, or are indicative of other practices that introduce complexity.

The art of doing this right lies in distilling the right information for the abstraction. Add too much, and you're leaking information – which means more coupling and cognitive load –; add too little and you're creating a false abstraction and introducing obscurity.

Every module has a set of responsibilities over a certain layer of abstraction. Confusion around the separation of responsibilities often creates problems like API duplication. Duplicated APIs are often inter-dependent and don't add new functionality, only complexity and boilerplate. Having a clear idea about each module's responsibility and its place among various layers of the dependency tree helps to minimize these issues.

Pull complexity down

As noted, software is often broken down into layers of abstraction. Each layer uses the ones underneath to achieve the desired operation. When complexity appears in one layer, it should be contained within the responsible module. Unchecked complexity can reflect in the higher-level module and this can quickly bubble up through the whole system.

The decision to complicate the implementation for the sake of simplifying the interface is often the right one; as a developer of a module, you should jump on the grenade to spare its user from the complexity.

Providing sensible defaults for common cases; opting for smart computed values instead of configuration; and semantically tweaking modules, so the number of edge-cases and errors to handle is minimized are all strategies that help pull complexity into the lower layers and keep it there.

Investment mindset

Bugs and superficial correctness are often rightly considered the main quality determinants of a system; it's why software gets created – to work. But when "working code" becomes the only standard of quality, developers often employ a tactical approach which prefers ad hoc solutions that work and produce the fastest delivery.

This is in accordance with the prevalent agile methodology, in which software is built in quick iterations. It attempts to minimize the time spent on anything not directly related to solving the problem at hand, whether that's a bug fix or a new feature. This absence of thinking things through with wider considerations leads to a system filled with special cases, unclear intentions, and hacky solutions.

The design phase helps us think through our solution with future needs and circumstances in mind. If we conclude that we can't introduce our changes into the system without adding significant complexity, we need to add new abstractions. Features are commonly known to be the unit of abstraction in agile – if we make abstractions that unit, then we can ensure that the system will always adapt to new demands without introducing unnecessary complexity and clumsy solutions.

Tangled and difficult-to-understand software always has a price in the form of bugs and incidents, lost developer time, rigidity, and more. As long as you're sensible and don't take things to their extremes, taking time to design and think a problem through is going to take a fraction of the implementation time, and it will save you a lot of time in the future.

My thoughts

This book challenges some ideas that have been proliferating in the software engineering space for years, and it's salient in delivering its point on the basis of simple, easily understandable premises that can however be applied to almost any software design problem.

The book radiates enthusiasm for the craft of software engineering. The investment mindset is an important theme of the book, which recognizes that the best ideas aren't usually the first ones. It exhorts readers to take time to think, do things right, and work strategically. Complexity is rarely a result of one catastrophic decision; more often it builds up through a slow and iterative process.

It takes a lucid approach to things commonly evangelized and recognizes that you can overdo good practices. I'm happy Ousterhout calls out Uncle Bob's doctrine, and I believe he calls him out precisely on his worst takes, which extrapolate advice that might be good in many cases but, taken to an extreme, provides little benefit and often degrade the software.

Ideas about scattering complex interactions, overdoing abstractions and promoting comments and informal documentation, instead of condemning them as a mere failure, made me relieved, because they articulated something I was doing, or trying to do, usually against the grain.

Other ideas inspired me and I immediately applied them in my job, code-reviews and personal projects. I find many of the mental models the book offers useful and accurate.

The book focuses on software at a higher level than some other programming books. It might make it less accessible to beginners, because Ousterhout provides only few concrete examples, and it requires a certain level of experience and some lived complexity-grappling under your belt. But its abstract nature is something that might make it more future-proof than some other programming books, which aged notoriously badly.

Generally, I recommend the book.