I was somewhat skeptical when starting to read a Philosophy of Software Design, despite having it recommended by a friend. The book does a delightful job underselling itself. It is odd-shaped, published by a university press, and the preface mentions, "At this point, you might be wondering: what makes me think I know all the answers about software design? To be honest, I don't."
However, the fact that the book was written by someone who's been writing code for decades, the university press being Stanford press, and the book covering lessons learned during the first software design class at Stanford gauged my interest. Still, I wondered just how much I would learn about software design from experience partially distilled from a classroom - even if a Stanford classroom. A lot, as it would turn out.
This post summarizes key takeaways of the book and my take on these principles, drawing from my professional and industry experience. If you're interested in whether to read this book, my recommendation is that you probably should, for a few reasons I list in my conclusion.
The problem with software architecture: it's not repeatable in the real world. Or is it?
There is a significant difference between most books written on software design and John Ousterhout's one. It's repeatability. While almost all software architecture books are based on real-world experiences of experienced developers and consultants, those are not repeatable experiences. Those people solved challenging problems using certain methods: but they did not have the opportunity to do the same with a different approach. Even my views on software architecture are based on one-off projects and their aftermath.
John, on the other hand, had the vantage point of having multiple teams solve the same design problem during a semester, with him observing. He also had the luxury of repeating this experiment multiple times. With each repeat, he was able to both validate and tweak his observations.
So what is software design, according to John? He says it is a means to fight complexity.
The greatest limitation in writing software is our ability to understand the systems we are creating. (...) The larger the program, and the more people work on it, the more difficult it is to manager complexity. Good development tools can help us deal with complexity. (...) But there is a limit to what we can do with tools alone. (...) Simpler designs allow us to build larger and more powerful systems before complexity becomes overwhelming.
So how, exactly, should we fight this complexity? By encapsulating complexity via modular design and making code simple and obvious, like eliminating special cases or using consistent coding styles. The first part of the book focuses on good, modular design practices, while the second part of the book touches on techniques to make the code simple, as well as goes in-depth on commenting best practices.
Solid recommendations I agree with
Tactical vs strategic coding: deciding when to invest for the longer-term isn't easy. Be aware if you are putting out a fire, or building for the long-term. This observation matches my experience: "hacking" something together quickly, just to make it work, versus platformizing (making it reusable, extensible) is a tricky tradeoff to make. While this advice sounds simple, it is the one that requires experience and times of being burnt, to get it right. The challenge here is similar to the software development dilemma of moving fast, without breaking things.
Designing things twice (Chapter 11) is a suggestion that hits close to home. This is advice I've been suggesting to people to get better at designing systems, well before I read this book.
Deep vs shallow modules and smart usage of layers (Chapter 4 & 7) are chapters, where John notes how abstractions that have simple interfaces (deep modules) but hide complex functionality help reduce the complexity of programs. They do this better than shallow modules do - modules that have a simple implementation, but complex interfaces. This was something I've not given much thought before, but it certainly rings true. The depth of a module is a concept I've previously not thought to use, but I'm adding it to my toolset. Much of the book builds on this concept of module depth:
- It's more important for a module to have a simple interface than a simple implementation is a thought introduced in Chapter 8. While my experience confirms this - especially when talking about distributed systems and microservices. I did find logic and examples that John used to get here as interesting. For the theory, he builds on the logic that deep modules encourage information hiding and reduce complexity. For the practice, he takes examples from students implementing a class that manages files for a GUI text editor assignment. Students who chose a simple implementation, but complex interfaces - exposing the concept of lines - ran into far more trouble and complexity. They struggled more with this interface than those who went with a simple interface, with a complex implementation: a character-based interface.
- Layers should remove, not add complexity to a system (Chapter 7). The book refers to this as "different layer, different abstraction," arguing against the usage of call-through methods or decorator classes. While I agree that pass-through layers add complexity to the system and are best avoided, my experience when working in production systems tells me it's not that simple. Working within one application or codebase this complexity is more trivial to spot and eliminate. However, when working with standalone services, it's a more labor-intensive and process. It is a good reminder to both resist the urge to build services that do too much wrapping, as well as to keep track of this kind of architecture debt.
Information hiding and information leakage (Chapter 5) is another take on what good abstraction is and how efficient interfaces should behave. Those who have designed APIs have probably had first-hand experience with why leaking information beyond the bare minimum leads to tech- and architecture debt later on. John also mentions a glaring example of bad API design: over-exposing internals.
- Information leakage correlated to shallow classes was an interesting observation: the first of several based on the book not based on John's own experience, but about his analysis of the student's work. He noticed how students who divided their code into many, small classes, that were shallow, ended up with much duplicated logic, caused by information leakage.
- Generalizing leads to better information hiding. General-purpose/reusable modules are deeper - they have more complex internal logic, but simpler interfaces. Not surprising to anyone who's attempted to build more generic APIs. However, taking the previous argument further, it also means generality leads to better information hiding. Want to have simpler architecture? Consider generalizing components. The example of building a text editor during the semester is used to prove this point, with examples.
Tradeoffs when combining or separating implementations within modules or interfaces (Chapter 9) is an interesting debate that I don't know of a single best answer. We share this view with John, who also agrees the best solution will be context-dependent. John collects a few rules of thumbs he suggests to use when deciding to combine or separate. Combine when it makes for a simpler interface, to reduce duplication or when implementations share data/information. Separate general-purpose and special-purpose code.
While all of the above is sensible, personally still prefer single-purpose interfaces, even if that might leave some implementation separately, that could technically be combined. In the case of microservices, an important guiding principle is to avoid separating services while they use/modify the same data source. A larger service that is cohesive results in less complexity than several smaller services, manipulating the same data source.
The importance of good and simple naming (Chapter 14) mirror my experience on how simple names often mean simple functionality. Complex names - or difficulty to name something - is usually a code or architecture smell. John mentions that consistent naming contributes to less complexity - something I wholeheartedly agree with.
Recommendations I don't necessarily agree with
A strong stance against exceptions (Chapter 10) was an interesting read. John argues that exceptions introduce one of the worst sources of complexity and advocates for writing code that needs no exceptions to run. This is a section that seems to build on John's own experience, and him analyzing Windows APIs and does not bring classroom examples.
For backend systems, I view exceptions as a good thing: as long as they are thoroughly monitored and alerted on, targeting a zero exceptions policy. For client-side software like mobile apps and desktop apps, I agree that exceptions make for more complex logic. In these cases, exception masking - and logging - that John suggests in Chapter 10 can be a good solution. He lists the option of deciding that it's fine to crash the application on an exception: which would not fly in areas like mobile development.
Calling out event-driven programming as something that makes the code less obvious (Chapter 18) is an interesting take, that I would have hesitated to phrase as such. While event-driven programming is certainly more complex, due to no clear flow of control, in real-world scenarios, this is a necessity. Both in the case of distributed systems, as well as multi-threaded environments, there is no other option. John's suggestion to add a comment for event handlers doesn't cut it in my book. Instead, monitoring errors, alerting (for backend code) and logging, uploading, and analyzing logs (for client-side applications) is one way to stay on top of the system working correctly at all times. Much of modern programming, from messaging services to frameworks like Fx is moving towards an asynchronous model, where dealing with this complexity is part of the job.
John is quite in favor of writing comments, an approach I have little love for. Three chapters - chapters 12, 13, and 15 are dedicated to this topic, looking at it from different angles. In my view, a comment is an invitation for refactoring. This is especially true for code that is well-tested with unit and other automated tests. For junior engineers, the advice of writing comments first (Chapter 15 on) is solid. The part I do agree with John is how comments should describe things that aren't obvious from the code (Chapter 13). But, again, I would challenge the code instead, asking if refactoring would help eliminate the need for a comment.
Topics I missed
While the book does a good job covering architecture concepts when writing code, there are several areas that it does not touch on. Most of these are outside the scope of the software design course itself. However, some reference to these would have been useful.
Writing up, sharing, and debating design with other peers is a practice more and more common in tech companies. This includes both whiteboarding and presenting to a group of peers, as well as more formal RFC-like design processes. While it might be less applicable to the course John taught, it's a practice that comes with many benefits. These days, as shared editing and commenting tools are more common, with editors like Google Docs and O365. Sharing design ideas upfront and opening a debate using these tools is a great way to build more sturdy architecture. Especially when applying the "design it twice" principle, and writing up alternative designs considered, and the tradeoffs why the other design was not chosen.
Testing was completely absent from this book. Good architecture and testability go hand-in-hand. John dedicates discussing strategies to modify existing code in-depth in Chapter 16. His two pieces of advice are on staying strategic in modifying the design and maintaining the comments. This does not match my reality. The surest and safest way I know to modify existing code is to have tests. With tests in place, go ahead and make whatever change is necessary - and sensible. If there are no tests, start by writing tests. If there are tests, major refactors that wildly modify the existing design can also fly - the tests should give a safety net to do so. And with tests in place, comments become an afterthought: if there is a test for it, a comment is not that necessary.
The book is an easy read, thanks to its short sections and good bolding. It lends itself to both skimming through, and in-depth reading. The concepts are fresh and a welcome read. Most importantly, the book stays humble, does not try to convince, but offers perspectives backed by easy to follow examples.
I very much recommend the first half of the book - chapters 1-9 and chapter 14 - for all software engineers to read, digest, and consider applying. Concepts like depth of modules, layers adding complexity - or keeping complexity at bay - and information hiding are pragmatic tools to use when designing software. The book offers a fresh take on the concept of abstractions, and nicely complements principles like simplicity, KISS (Keep it Simple, Stupid) and YAGNI (You Ain't Gonna Need It).
The book does leave a glaring gap on testing and how testability and good architecture go hand in hand. I would recommend a book like Clean Code or Working Effectively with Legacy Code to augment the topics written here. Technical debt and architecture debt also don't get much mention or discussion, which is in contrast to my day to day work. It would be nice to see a later version touch on these topics, as well.
For people who have less experience in software development, the remaining of the book will also be practical. Naming is an especially useful section. The parts on writing comments before coding, and comments complimenting the code are decent strategies to start with. Those with more experience under their belt my disagree with some of the recommendations. Still, they all present interesting viewpoints, backed with examples. They also make for potentially sound advice to give, when mentoring less experienced engineers.
There are few books that discuss software design in a simple and approachable way, while leaving the reader novel and practical concepts to use. A Philosophy of Software Design is a standout and recommended read for this reason. We need more resources to remind us not overcomplicate software architecture. It will become complicated enough, module after module, layer after layer.