A Philosophy of Software Design is an excellent summary of the things that matter for crafting good software. The ~200 page book with about 21 chapters is split across defining complexity and the two approaches to solving it. I loved the clarity of thought, presentation and even more the comparison with other prevalent systems such as “Clean Code”. This book is truly philosophy and art whereas “Clean Code” is more prescriptive. Heartily recommend this book for every software craftsman.
Complexity and ways around it
- Challenge: complexity, decomposing a problem and finding simpler solution.
- Complex systems are hard to understand and modify.
- Complexity breeds change amplification, high cognitive load, and lack of mental model (unknown unknowns).
- Unknown unknowns: there is no way to determine what code should be changed to add a functionality.
- How complexity is created?
- Dependencies that are hard to reason about.
- Obscurity caused by non-obvious code, irrational dependencies and inconsistency in code.
- Our practices make complexity worse. We invest more on tactical programming versus longer-term right structure.
- Two approaches
- Make code simpler and obvious.
- Encapsulate the complexity with a modular design.
- Good practices: continuous ~10-20% investment on right structure and future direction.
Modular design hides complexity
- Good abstraction with an interface simpler than actual implementation
- Anti-dote for the unknown unknowns problem. Developers know when and how to use the interface.
- Challenge: abstractions include unimportant details and omits the critical ones leading to obscurity.
- Best modules are deep. Allow a lot of functionality through a simple interface. For example, garbage collector in runtimes don’t have an interface yet can do its work.
- Challenge: we design classes to be shallow - too many of them.
- Information hiding creates deep modules
- Makes the interface simple and reduces cognitive load for module users.
- Challenge: information leakage occurs when design decision is reflected in multiple modules (i.e. a change touches everything).
- Focus on the knowledge required by a module to perform a task. Order of tasks is not important. Temporal decomposition leads to leakage.
- Use defaults to make usage obvious. Best features are ones you get without knowing they exist.
- Class design: ensure private methods encapsulate and hide complexity from rest of the class. Reduce number of places where a variable is used.
- Be extremely aware of what information is needed outside a module and expose it cautiously.
- General-purpose modules are deeper
- Module’s interface should reflect future vision. Module’s implementation can be scoped to current needs.
- Always ask who needs to know what, and when. Sometimes it’s better to make details explicit and obvious.
- Is this the simplest interface that will cover all my current needs?
- General-purpose methods ➡️ reduce API methods while retaining functionality.
- ⚠️ Lot of additional arguments in methods implies complexity.
- ⚠️ Think before creating methods only for single use.
- ⚠️ Tons of boilerplate code to use a class implies complexity.
- Push specialization upwards and complexity downwards
- Lower layers provide general-purpose code. Every consumer adapts it to their use case (specialize in the apps).
- Strive to separate specialized and general-purpose code.
- Make life easier for upper layers to consume complex concepts via simple interface.
- ⚠️ Avoid configuration parameters. Will higher-level modules be able to determine a better value than we can determine here?
- Use different abstractions at different layers
- Challenge: method invoking another method with same signature (pass-through) makes classes shallower. This indicates two similar abstractions live in the same layer.
- Several methods can provide different implementations for same interface. Easier to reason and no pass-through required.
- Decorators add functionality keeping contract same as downstream. They’re shallow and introduce complexity.
- Class interface should be different from its implementation. Otherwise class is likely shallow.
- Using pass-through variables implies API duplication. Every intermediate
layer is aware of the variable’s existence in its signature. Use a
single
context
object and inject it via constructor (save a reference).
- Decompose modules at boundaries
- Bring together related functionality to simplify the interface, or eliminate duplication. Readers can see them together.
- Create single general-purpose functionality that is reused everywhere.
- Do one thing and do it completely. ⚠️ You are not able to understand implementation of one method without looking at another.
- Define errors out of existence
- Challenge: exceptions may abort at inconsistent state. ➡️ Complexity.
- Exceptions are hard to test and can cascade. Even more complexity.
- Classes with too many exceptions are shallower. ⚠️ Throwing exceptions is easy, handling them is hard.
- Define APIs such that there are no exceptions to handle.
- Mask exceptions for higher layers. Pulls complexity downwards. Handle in low-level code.
- 🍀 Aggregate and handle many exceptions together in a single cleanup code. Opposite of exception masking, better for app code. Promote smaller errors to the one large error that is handled.
- Sometimes just crashing is a good solution.
Design it twice principle ✨
Try to pick approaches that are radically different from each other; you’ll learn more that way. Even if you are certain that there is only one reasonable approach, consider a second design anyway, no matter how bad you think it will be. It will be instructive to think about the weaknesses of that design and contrast them with the features of other designs.
Simple code is obvious
- Good documentation makes a good design usable
- Documentation clarifies the dependencies and eliminates obscurity.
- Comments capture information that was in the designer’s mind but couldn’t be represented in the code.
- ⚠️ Don’t repeat the code in the comment. Think nouns when documenting variables (what it represents, not how it is implemented).
- Lower level comments add precision, and higher-level comments develop the mental model (intuition). Latter represents the essence of abstraction.
- Separate interface comments from implementation details. Focus on what and why in implementation comments.
- Good naming reduces need for other documentation
- Good names create an image in reader’s mind about the nature of thing being named.
- Names should be precise and consistent.
- Greater distance between name declaration and usage, longer is the name.
E.g.,
i
,j
works for immediate usage.
- Start with a comment
- Documentation builds design. Note and fix comments during the process.
- Keep comments near the code and spread them out throughout the method instead of dumping them on top.
- Avoid duplicate comments, they are harder to keep up-to-date.
- Higher-level comments are easier to maintain.
How to control complexity during evolution of the System?
- Make strategic changes
- ⚠️ Beware of the smallest change to add a functionality. No quick fix.
- When you have finished with a change, the system should have the same structure it would have had if you designed the change from beginning.
- 🍀 Improve the design a little bit with every new code change.
- Ask is this the best I can possibly do to create a clean system design, given my current constraints?
- Consistency provides cognitive leverage. You learn a thing once in that code
base.
- ⚠️ Having a better idea is not good enough excuse to introduce inconsistency.
- Consistency is strategic, an investment mindset. Decide on conventions, automated linting, reusable abstractions and use code reviews to enforce these.
- Design for the ease of reading, not ease of writing
- Remember to avoid the funky one-liners.
- Ensure information is always available to readers when it’s needed.
Processes are a means not an end
- Object-oriented programming: prefer composition over inheritance
- More implementations of an interface makes it deep.
- Knowledge of interface can be used in multiple scenarios. E.g.,
StreamReader
works with any IO stream in the same way. - Can we avoid inheritance with smaller shared modules?
- Agile development encourages incremental and iterative development.
- ⚠️ Increments of development should be on abstractions, not features. Otherwise, you will put off design decisions to produce ungodly working software.
- Unit testing and TDD help with refactoring. Ensure you’re strategic and not pivoted to narrower feature mindset.
- Design patterns allow reusing solutions to common problems. Don’t over apply them.
- Measure and improve performance
- Design micro-benchmarks to measure cost of an operation in isolation.
- Always be measuring before and after optimization. Never assume and optimize.
- Find critical paths and optimize them with fundamental changes.
Decide what matters ✨
- Structure systems around things that matter.
- Things that matter have high leverage. E.g., solution to one problem may solve many other problems.
- Start with a hypothesis if you don’t know what matters the most. Build under this assumption and learn to iterate.
- Simpler systems have few things that matter 🍀
- Be radically minimal.
- Minimize the number of places where critical elements matter. E.g., things that matter in low-level are immaterial at higher-level. Use this to build abstractions around critical elements.
- Too many important things always clutter the design.
- Failure to find things that matter add to obscurity.
- Things that matter should be emphasized everywhere. Repeat the key ideas in comments. Or, make the critical elements central and prominent.
Focusing on what is important is also a great life philosophy: identify a few things that matter most to you, and try to spend as much of your energy as possible on those things. Don’t fritter away all of your time on things that you don’t consider important or rewarding.
This book is about one thing: complexity. Dealing with complexity is the most important challenge in software design. It is what makes systems hard to build and maintain, and it often makes them slow as well. Over the course of the book I have tried to describe the root causes that lead to complexity, such as dependencies and obscurity. I have discussed red flags that can help you identify unnecessary complexity, such as information leakage, unneeded error conditions, or names that are too generic. I have presented some general ideas you can use to create simpler software systems, such as striving for classes that are deep and generic, defining errors out of existence, and separating interface documentation from implementation documentation. And, finally, I have discussed the investment mindset needed to produce simple designs.
***
To all my friends trying to improve our craft, and fighting strategic battles with ever-changing processes - this book will help you create a craftsman mindset. Read, reread and share this book 🍻