It isn’t easy to imagine what software could be like if we had powerful principles for software design. Our current principles and practices often don’t result in great software, and the software industry works in a largely ad-hoc fashion. But what does it mean to create good software? Stanford Professor and author of UI framework Tcl/Tk and the RAFT consensus algorithm, John Ousterhout, is on a quest to work out the answers. His new book, A Philosophy of Software Design, is a set of principles software engineers can use to design low-complexity, high-capability systems.
Of all the complex systems humans have known, cities, in many ways, are a good comparison to software. Cities grow to become massive, they’re used by large numbers of people, and they tend to develop in a similarly chaotic fashion as software does. They’re built up by various people focused on various issues over time. In both cases, complexity tends to grow to the point that maintainers are overwhelmed with all the competing concerns and constraints. Development becomes increasingly slow until all but minor changes to the system incur a high cost.
Like cities, we primarily tackle complexity via design. Even though we may not exactly know what to design upfront, we know that we have more control in shaping complex systems in their formative stages. Unlike cities, code is not informed by geographical constraints and material relationships. This added freedom broadens our solution space and seems to permit much more complexity to accumulate.
The focus on software design appeals to many of us who aspire to do better. We should develop a thoughtful design in the early stages, and you stand a chance of being rewarded for those decisions later. Avoid design, and you’re doomed to suffer an unwieldy tangle of your own making.
Much like financial debt allows businesses to increase their power and velocity, we gain leverage in software by allowing complexity to accumulate. This might be an incomplete or poor implementation or an awkward interface. Unlike our discussions about best practices, there’s virtually no discussion on how to implement modules poorly when you’re short on time or can’t find a good solution. So systems often end up with debt in surprising and frustrating forms.
On the other hand, complexity is a common topic amongst software engineers. The fifth principle of the UNIX Design Principles is to Design for Simplicity. The Boring Tech movement is almost entirely about simplicity. The “Worse is Better” philosophy of Richard Gabriel explains that simplicity is so important that it should override other important concerns like consistency and correctness. Rich Hickey has spoken at length about his efforts to tackle complexity via ideas like immutable state and abstraction. John Carmack discusses ways to streamline flow control complexity and avoid unnecessary work. It’s one of those topics in software engineering that never gets old.
We all would agree that complexity is at the heart of our problems. We must understand that complexity is what makes our programs hard to understand. Our ability to manage complexity is what makes us effective or not and makes our programs achieve their objectives or not.
Make deep modules
Ousterhout argues instead for the design of “deep modules”. Deep modules maximize the volume of capabilities within an interface’s implementation while minimizing the cognitive load of the interface itself. Deep modules can minimize how changes echo and amplify across a system because changes can be isolated to the implementation of a module without interfering with any consumers.
As was discovered by Richard Parnas, a good criterion for decomposing modules is by encapsulating important design decisions. In this sense, deep modules provide complete solutions to whatever problems they encapsulate and can also vary those solutions in helpful ways. You might look at popular tools like FFmpeg, ImageMagick, Mpv, Postgres, Redis, Systemd, or Wayland through the lens of deep modules and think about if those programs can be characterized this way.
Modules are a natural, cultural artifact within companies, often synonymous with the teams that develop them. Deeper modules support deeper (possibly larger) teams. Less integration between teams means faster development overall.
Most advice on error handling aims to manage errors once they’re recognized. One should exhaustively enumerate error states, handle errors, expose status to users, document them to other developers, log them, retry, and fail loudly when unexpected errors occur.
That’s a lot of work and complexity. Not only is most error handling completely non-essential, but it can be avoided by designing with the elimination of errors in mind. Remove all reasons for errors to occur first, and you won’t have to implement all the logic related to handling them. In the remaining cases where you can’t eliminate errors at the design stage, aim to remove as much logic for dealing with errors as you can. Code that guards against faulty assumptions or attempts to recover from mistakes is one of the largest sources of complexity in programs.
A good example of this is file deletion in Unix. If a file is deleted, the file is merely unlinked from the filesystem tree. The file data is not really deleted. So if another process is reading a file, deletion of that file will not cause an error in the reader. By contrast, Windows will not permit a program or user to delete a file that is in use. They must search for the process that has the file open and terminate it, have the file closed in some other way, or repeatedly check if file will release on its own.
The principle can be affirmed positively: if you eliminate non-essential code paths, your code has a good shot at remaining simple. If you can avoid the complexity of special cases, you can more effectively implement the core use cases.
And the documentation
Start your code with documentation because that’s the part most people are least excited about at the end of a project but is often quite fun at the outset of a project. Use documentation as a place to work out details of your designs. Focus on code comments above most other forms of documentation like the changelog and commit history.
Optimize for fun. Life is too short to deal with the tyranny of bad design. Well-designed software should help make good solutions obvious. It should feel lightweight and empowering.
Complexity is caused by dependencies. Think carefully when selecting and integrating dependent modules.
Complexity is caused by obscurity. Code structure is a primary contributor to obscurity, so everything about your program structure should be as clear as possible. Comments and good identifier names are a great way to document entry points, places worth focusing on, and any trouble areas.
Lastly, the future of software design is uncertain, and forces ranging from data visualization to collaboration to security are shaping how we write software modules.
Here are some references for you for further reading on Software Designs.
- A Philosophy of Software Design – John Ousterhout
- How Software is Built – Gerald Weinberg
- Software Project Survival Guide – Steven McConnell
- Antifragile software design abstraction and the barbell strategy
- How much abstraction is too much
- On the Criteria to Be Used in Decomposing Systems into Modules - David Parnas