Philosophy of Software Design overview (and rants)

Recently I spent some time reading this book that a coworker suggested. My overall feeling is that it’s a good book. The first 40% of pages can be triggering (makes some strong statements), but then some of those are rejected later, as expected in a philosophy book 🙂

My take is that I think this book should be literally read in a group. The best outcome of it was just discussing it with my coworkers or ranting about it (find <rant> in this post).


1. Intro

Caveat, no single solution, will talk from experiences

2. Complexity

Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system 💯

<rant>

The next quote is a narrow view, because a particular system may be built to do only one thing and do it good (think implementing an OS in PHP, vs a website), and extending that system’s feature set (larger improvement) may not necessarily be implemented with less effort.

In a complex system, it takes a lot of work to implement even small improvements. In a simple system, larger improvements can be implemented with less effort.

And this one.. hard to stop pointing these out 😀

If you write a piece of code and it seems simple to you, but other people think it is complex, then it is complex.

</rant>

Symptoms of complexity:

  • Change amplification: A simple change requiring modifications in many places
  • Cognitive load: “time to productivity”. malloc and free – free is an example of cognitive load
  • Unknown unknowns: Not obvious which pieces of code should be modified to complete a task. This is the trickiest one – e.g. won’t find out about it until a bug happens

Caused by:

  • Dependencies: A relation exists between the particular code we want to change to another code. “One of the goals is to reduce the number of dependencies”?!?!. Although I agree with “make the dependencies that remain as simple and obvious as possible”
  • Obscurity: Important information is not obvious, e.g. wrongly named variables

Complexity accumulates over time, hard to control (will talk later about the “zero tolerance” philosophy)

3. Working code isn’t enough

Tactical approach: Write code that just works, and do it fast (think FB “move fast and break things”) Strategical approach: Put more investment and thoughts into the design, and other aspects

4. Modules should be deep

Modular design: ideally collection of relatively independent modules, but not so easily achievable in the real world (modules need to talk to each other).

Abstraction: a simplified view of an entity, which omits unimportant details. Each module provides an abstraction.

A module is interface + implementation: interface = what a module does, implementation = how it does that.

An interface contains a) the formal part – functions, parameters they accept, etc. and b) the informal part – additional info not directly visible from the code (usually addressed with code comments). A clearly specified interface addresses “unknown unknowns”.

Abstractions go wrong in two ways:

  • When an important detail is omitted
  • When a not-so-important detail is kept

Deep modules are those modules that provide powerful functionality, yet have simple interfaces. Shallow modules are the opposite – a complex interface with not much functionality; doesn’t hide much complexity. Separating modules like this allows us to think in terms of cost/benefit.

<rant>

He also talks about how garbage collector is a very good deep module, yes, yes, but at what cost. Go write a kernel driver in Java! 🙂

For example, a class that implements linked lists is shallow.

In which language? class in Haskell is different 🙂 and speaking of C++ it’s a different thing…

He also gave the name “classitis” to the “single-responsibility principle”…

</rant>

5. Information hiding

Information hiding: Each module encapsulates knowledge in the implementation (not in the interface). The opposite – when a piece of knowledge is reflected in one or multiple interfaces – is information leakage. Simpler interfaces tend to correlate with better information hiding.

When designing modules, focus on the knowledge that’s needed to perform each task, not the order in which tasks occur.

Finally, a good section called “Taking it too far”

Information hiding only makes sense when the information being hidden is not needed outside its module. If the information is needed outside the module, then you must not hide it

6. General-Purpose modules are deeper

This chapter reminded me of strengthening induction hypothesis when working with math proofs, i.e. sometimes solving a more general problem (climbing the abstraction ladder a bit) is easier than the actual one.

7. Different layer, different abstraction

Software abstractions have different layers. Bashes pass-through method (aka useless wrappers). Each class should have a distinct set of responsibilities.

It is fine for several methods to have the same signature as long as each of them provides useful and distinct functionality.

A pass-through variable is a variable that is passed down through a long chain of methods. This adds complexity. Several approaches: global variables, context structs

Each piece of design infrastructure added to a system, such as an interface, argument, function, class, or definition, adds complexity, since developers must learn about this element. In order for an element to provide a net gain against complexity, it must eliminate some complexity that would be present in the absence of the design element

Different layer, different abstraction rule: if different layers have the same abstraction (pass-through method, wrappers, etc) likely, they don’t bring enough benefit.

8. Pull Complexity Downwards

Handle unavoidable complexity internally within a module rather than exposing it to the users of the module. Avoid complexity amplification. Pulling complexity down should be balanced and aligned to minimize overall system complexity.

Optimize for users – a simple interface for users is more crucial than a simple implementation for developers.

Configuration parameters are an example of moving complexity upwards – while they offer flexibility, they can become an excuse to avoid dealing with important issues and may be challenging for users or administrators to configure correctly.

9. Better Together Or Better Apart?

The fundamental question in software design is whether to implement two pieces of functionality together or separately.

In general, the lower layers of a system tend to be more general-purpose and the upper layers more special-purpose. For example, the topmost layer of an application consists of features totally specific to that application.

Few points:

  • Subdividing components may seem like a way to simplify, but it introduces complexities like managing more interfaces and potential duplication. Can be beneficial for independent modules but not so much if there are dependencies.
  • Joining components: shared information is a strong indicator that it should be done.
  • Refactoring repeated code

Argues about how an “undo” functionality should be separated (as a general-purpose action) into its own History class, and outside of the Text class – but still unclear what the purpose of a class like Text is?! :shrug:

Each method should do one thing and do it completely.

<rant>Finally goes against himself (think classitis) 🙂 This gave me the suggestion that the point of the book is indeed to trigger readers. I had rated the book 6/10 initially, but this bumped it up to 8/10.</rant>

The decision to split or join modules should be based on complexity. Pick the structure that results in the best information hiding, the fewest dependencies, and the deepest interfaces.

10. Define Errors Out Of Existence

Exception handling is a major source of complexity in software, especially when dealing with uncommon conditions: bad arguments, I/O failures, network issues, bugs, etc. Unnecessary exceptions can arise from a desire to catch and report every error (over-defensive), increasing the system’s complexity. The temptation to use exceptions to avoid dealing with difficult situations is discouraged, as it passes the problem to someone else and adds to the system’s complexity. The number of exceptions is highlighted as a significant factor in the complexity of exception handling code.

The exceptions thrown by a class are part of its interface; classes with lots of exceptions have complex interfaces, and they are shallower than classes with fewer exceptions.

The idea of defining errors out of existence is the most effective way to eliminate exception handling complexity: modifying APIs to eliminate exceptions, making errors non-existent by changing the semantics of operations, etc.

A few techniques are discussed, with a good disclaimer that none of these techniques should be taken too far.

  • Exception masking: handling exceptional conditions at a low level shields the higher level from being aware of the condition.
  • Exception aggregation is a technique that handles many exceptions with a single piece of code.
  • Just Crash? Sometimes the best option is crashing the application with certain errors that may be difficult or impossible to recover from, such as “out of memory” errors, avoiding complex error-handling code.
  • Eliminating special cases by designing the normal case in a way that automatically handles them without additional code.

11. Design it Twice

To design the best system (not just modules/interfaces but also in general), we need to design it twice (multiple times), because our first thoughts will not be close to the ideal solution. This also involves listing trade-offs for each approach and evaluating factors like ease of usability, generality, and efficiency.

When they are growing up, smart people discover that their first quick idea about any problem is sufficient for a good grade; there is no need to consider a second or third possibility. This makes it easy to develop bad work habits. However, as these people get older, they get promoted into environments with harder and harder problems. Eventually, everyone reaches a point where your first ideas are no longer good enough; if you want to get really great results, you have to consider a second possibility, or perhaps a third, no matter how smart you are.

12. Why Write Comments? The Four Excuses

Four common excuses: good code is self-documenting, the lack of time to write comments, concerns about comments becoming outdated, and the perception that existing comments are worthless.

Comments play a crucial role in abstraction. Well-written comments are essential for understanding high-level concepts, rationale for design decisions, addressing unknown unknowns, and clarifying system structure. The benefits outweigh the costs.

13. Comments Should Describe Things that Aren’t Obvious from the Code

The author talks against redundant comments that merely repeat code and encourages comments that provide additional information – the importance of precision, including units, boundary conditions, null values, resource ownership, and invariants.

Developers should be able to understand the abstraction provided by a module without reading any code other than its externally visible declarations. The only way to do this is by supplementing the declarations with comments.

<rant>“The only way”? 🙂 What about languages with more expressive type systems, such as Idris?</rant>

For cross-module design decisions, the use of a central file is proposed. While acknowledging potential drawbacks, the author advocates for focusing on “what and why” in comments, encouraging developers to think from the perspective of someone reading the code for the first time.

Engineers tend to be very detail-oriented. We love details and are good at managing lots of them; this is essential for being a good engineer. But, great software designers can also step back from the details and think about a system at a higher level.

14: Choosing Names

When choosing a name, the goal is to create an image in the mind of the reader about the nature of the thing being named.

Consistency in naming is another important part. If it’s hard to pick a name there might be a hint of unclean design.

15: Write The Comments First

Write comments as early in the development process as possible rather than delaying documentation until the end of the development cycle. Comments should be iteratively refined as the code evolves, ensuring they are reflective of the design.

The comment that describes a method or variable should be simple and yet complete. If you find it difficult to write such a comment, that’s an indicator that there may be a problem with the design of the thing you are describing.

16: Modifying Existing Code

Resist the temptation of quick fixes and think strategically about whether the current design is still the best given the desired change. The investment mindset: spending a little extra time to refactor and improve the system design can pay off in the long run.

When developers go into existing code to make changes such as bug fixes or new features, they don’t usually think strategically. A typical mindset is “what is the smallest possible change I can make that does what I need?”

A common mistake when modifying code is to put detailed information about the change in the commit message for the source code repository, but then not to document it in the code.

17: Consistency

Consistency is key to managing complexity. It creates cognitive leverage: When a system is overall consistent, it helps when developers work on one part they can use the same knowledge and apply it to other parts of the system. Examples include naming, coding style, interfaces, design patterns, and invariants.

Maintaining consistency is also tricky but there are ways to ensure it (documentation, convention, etc.) and also enforce it (tools for automated checks).

Another good “Taking it too far” subsection: consistency should be balanced and not enforced just for the sake of it – similar things should be done similarly and dissimilar things should be done differently.

18: Code Should be Obvious

Code should be written with clarity in mind. Write code that is easily understandable.

Another strong claim here, though mentions that while “obvious” can be subjective, it can be determined through code reviews.

If someone reading your code says it’s not obvious, then it’s not obvious, no matter how clear it may seem to you.

<rant>And another one 😅 sigh</rant>

Generic containers result in nonobvious code because the grouped elements have generic names that obscure their meaning. […] Thus, it’s better not to use generic containers.

19: Software Trends

Talks about:

  • Composition vs inheritance
  • Agile as an iterative process and cautions against tactical programming and accumulation of complexity
  • Tests and refactoring confidence
  • Test-driven development focuses on making a specific feature work rather than finding a good design. Tactical and incremental
  • Design patterns: rather than design a new mechanism from scratch, apply an already-known one. This is good for one part, but one risk is over-application.
  • Getters/setters

Developing incrementally is generally a good idea, but the increments of development should be abstractions, not features.

Software development trends require a critical examination to minimize complexity for effective system design.

20: Designing for Performance

Simplicity not only improves design but also tends to make systems faster. Don’t prematurely optimize but also don’t try to not optimize at all – should be balanced. Measurements over intuition. Clean design and high performance are not mutually exclusive.

The key is simplicity: find the critical paths that are most important for performance and make them as simple as possible.

21: Conclusion

Design is a fascinating puzzle: how can a particular problem be solved with the simplest possible structure?

Leave a comment