"A Philosophy Of Software Design" Summary

12 July 2024
Software EngineeringBook Summary

This is a summary of the book "A Philosophy of Software Design" by John Ousterhout.

Key Takeaways

Always when writing code, try to reduce complexity

Working code isn't enough: Primary goal is to create a great design solving the problem -> This might need some upfront investment

Try to write deep modules (functionality of a module is much greater than its interface) instead of shallow modules (module with a complex interface compared to its functionality)

Abstraction and information hiding is your friend. If some complex functionality can be hidden within a module, try to do it

Try to create somewhat general-purpose classes and methods (interface is general-purpose, implementation special purpose for what your current usecase needs)

Each layer of a system should have another abstraction -> If you have a lot of pass-through methods, maybe your layering could be improved

It is more important for a module to have a simple interface than a simple implementation

Try to avoid exceptions and errors if possible, since exception handling is generally complex code

Write good comments, adding precision about implementation or describing intent to the reader

Choose good names for variables, classes and methods

Be consistent with implementations, code style, names, by using interfaces and enforce consistency whenever possible

1 Introduction

The greatest limitation in writing software is our ability to understand the systems we are creating

It is inevitable that complexity increases over time, but simple designs keep larger and more powerful systems manageable

Two approaches to reduce complexity:

  • Making code simpler and more obvious
  • Encapsulate complexity -> modular designs

Software design is a continuous process spanning the entire lifecycle

The initial design for a system or component is almost never the best one

1.1 How to Use This Book

Try to identify red flags and then work to fix them

2 The Nature of Complexity

It is simpler to tell whether a design is simple than it is to create a simple design

But once one can recognize that a design is too complicated, a different approach can be chosen to see if it leads to a simpler design

Certain techniques tend to result in simpler designs, others correlate with complexity

2.1 Complexity Defined

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

Complexity can take different forms:

  • System is hard to understand
  • It takes a lot of effort to implement an improvement
  • Fixing a bug might be difficult without introducing another

A large system with sophisticated features is not complex if it is easy to work on it (for the purposes of this book)

Overall complexity is determined by the complexity of each part (c_p) weighted by the fraction of time developers work on that part (t_p):

C = sum(c_p * t_p)

-> if only a small part of the system is complex but never touched, it might not be that bad

Complexity is more apparent to readers than writers -> if somebody finds your easy code complex, it's complex

2.2 Symptoms of Complexity

Change Amplification

A small change requires code modifications in many different places

Goal is to reduce the amount of code affected by each design decision

Cognitive Load

Refers to how much a developer needs to know in order to complete a task

Higher cognitive load means more time required to learn required information and more risk for bugs

Complexity can not be measured by lines of code -> Approach that needs more lines of code could be simpler by reducing cognitive load

Unknown Unknowns

It is not clear what pieces must be modified to complete a task

Unknown unknowns are the worst manifestation of complexity: There is something you need to know but no way to find out what it is or if it is an issue

Problems often only arise after the change

2.3 Causes of Complexity

Complexity is caused by two things: Dependencies and obscurity

A dependency exists when a piece of code can not be understood or modified in isolation

Dependencies can't be eliminated but should remain as simple and obvious as posible

Obscurity occurs when important information is not obvious (e.g. unclear variable names)

Obscurity is often a problem of insufficient documentation, but the best way to reduce obscurity is by simplifying the system design

2.4 Complexity is Incremental

Complexity is not caused by a single catastrophic error, but is an accumulation of dependencies and obscurities over time

It's easy to convince yourself that a little bit of complexity introduced by your small change isn't a big deal

Incremental nature of complexity makes it hard to control -> once accumulated it's hard to eliminate

3 Working Code Isn't Enough

3.1 Tactical Programming

Main focus is to get something working

Tactical programming is short-sighted -> Finish before a deadline and don't spend time looking for the best design

Complexity accumulates -> Refactoring helps but takes time -> Quick patches make it worse

"Tactical tornado": Developer who pumps out code far faster than others but works in a totally tactical fashion -> Mess often needs to be cleaned up by someone else

3.2 Strategic Programming

Working code isn't enough

The most important thing is the long-term structure of the system

Primary goal is to produce a great design which also happens to work

Strategic programming requires time investment:

  • Proactive investment: Try a couple of approaches and use the simplest one, take extra time to find a simple design for each class
  • Reactive investment: Fix discovered design problems when they are discovered

3.3 How Much to Invest?

Spend about 10-20% of your total development time on investments

The initial time investment will be recovered easily by timesavings later due to better design

3.4 Startups and Investment

Mindset in startups is often tactical -> Big pressure to get releases out quickly

However the payoff for bad design comes quicly and one might pay high dev costs for the whole life of the product

4 Modules Should Be Deep

4.1 Modular Design

In an ideal world: Every Module is completely independent of others -> But modules need to call each others functions -> dependencies

Think of each module in two parts:

  • Interface: Describes what the module does (but not how it does it)
  • Implementation: Code that carries out the promises made by the interface

A developer should not need to understand the implementations of other modules

The best modules have an interface much simpler than their implementation

4.2 What's In An Interface

Interface contains formal and informal information:

  • Formal: For a method: number of arguments, type of arguments. Can often by checked for correctness by the programming language
  • Informal: High-level behavior (a function deletes a file defined by one of the arguments), other constraints for usage of a module (other method has to be called before calling this method)

Informal information can not be checked by the compiler and relies on comments

For most interfaces, the informal aspects are larger and more complex

4.3 Abstractions

An abstraction is a simplified view of an entity, which omits unimportant details

Interface is an abstraction for a module -> It hides the implementation for a user of the module

Abstraction should not abstract too much or too little -> An abstraction that omits important details is a false abstraction

4.4 Deep Modules

Deep Module: Module with powerful functionality and a simple interface

Example for a deep module: Garbage collector -> Huge complexity with now interface at all

4.5 Shallow Modules

Shallow Module: Module whose interface is complex in comparison to its functionality

🚩 Red Flag: Shallow Module: Doesn't help much in the battle against complexity -> Their benefit is negated by the cost of learning and using their interfaces

4.6 Classitis

Value of deep classes is not widely appreciated today -> Conventional wisdom is that classes should be small, not deep

Classitis: Developers encouraged to to minimize the amount of functionality of each class

Small classes don't contribute much functionality -> There have to be a lot of them -> All these interfaces accumulate to a big complexity

4.7 Examples

Java Example:

To open a file in order to read serialized objects from it -> Three objects must be created

FileInputStream fileStream = new FileInputStream(fileName); 
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream); 
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream); 
  • fileStream and bufferedStream are never used, all operations use objectStream
  • Buffering must be requested explicitly (by using BufferedInputStream) -> slow if forgotten
  • Better solution: buffer by default, provide different FileInputStream constructor which does not use buffering

4.8 Conclusion

By separating interface of a module from the implementation one can hide complexity

Deep modules maximize the amount of complexity that is concealed

5 Information Hiding (and Leakage)

5.1 Information Hiding

Each module should encapsulate a few pieces of knowledge

Information hiding reduces complexity in two ways:

  1. Simplifies the interface
  2. Information hiding makes evolution simpler -> hidden information is not accessed from outside of module

5.2 Information Leakage

Opposite of information hiding -> information is exposed ("leaked") via the interface

Leakage can happen outside of an interface: when two classes depend on a certain file format, both will have to be modified if the format changes, even when the format is not reflected in the interface

When you see information leakage, ask yourself: "How can I reorganize this so that this particular piece of knowledge only affects one class?"

  • For small classes -> maybe merge them
  • Pull the leaked information out into a separate class that encapsulates only this info

🚩 Information Leakage: Same knowledge is used in multiple places

5.3 Temporal Decomposition

In temporal decomposition, the structure of a system corresponds to the time order in which operations occur.

Example:

  • Application that reads file, modifies file and writes new file version
  • Temporal decomposition: three classes: ReadFile, ModifyFile, WriteFile
  • File reading and file writing classes have knowledge about the file format -> leakage
  • Solution: Core mechanisms of reading and writing go into one class

When designing modules, focus on knowledge required for each task, not on execution order

🚩 Temporal Decomposition: When classes are structured based on order, the same information might be used in multiple places (leakage)

Example: HTTPRequest object

  • To get a parameter of the parsed HTTP request: public Map<> getParams()
  • -> This exposes the complete map -> might even be modified by external code
  • better: public String getParameter(string name)
  • -> Deeper interface but better information hiding

🚩 Overexposure: Commonly used feature of API forces user to learn about other (rarely used) features -> Increases cognitive load on users

5.9 Taking It Too Far

If information is needed outside -> don't hide it!

Example: Performance of a module in different use cases is affected by configuration parameters -> expose them in the interface

5.10 Conclusion

Information hiding and deep modules are closely related

When decomposing a system into modules, try not to be influced by operation order but think about pieces of knowledge required to complete the different operations

6 General-Purpose Modules Are Deeper

General purpose: Solution that addresses a broad range of problems

  • May find unanticipated uses in the future -> saves time
  • Spend a bit more time up front to save time later on
  • Maybe this additional functionality is never needed though
  • Being to general purpose might result in the solution being bad for the actual usecase

Special purpose: Focus on the current use case and add functionality later

6.1 Make Classes Somewhat General-Purpose

Modules functionality reflets current needs, but the interface is more general-purpose

Example: Instead of deleteBackspace(Cursor), deleteDeletekey(Cursor), deleteSelection(Selection) use delete(PositionStart, PositionEnd)

6.5 Questions to Ask Yourself

What is the simplest interface that will cover all my current needs?

-> If you reduce the number of methods of your API without reducing its capabilities, you are probably creating more general-purpose methods (e.g. backspace, delete, deleteSelection -> delete)

In how many situations will this method be used?

-> One method for one particular use -> too special purpose!

Is this API easy to use for my current needs?

If the current API needs a lot of additional code to be used -> maybe to general-purpose

6.6 Conclusion

General-purpose interfaces have advantages over special-purpose ones

  • Tend to be simpler
  • Provide a clener separation between classes

7 Different Layer, Different Abstraction

In a well-designed system, each layer provides a different abstraction

7.1 Pass-Through Methods

Pass-through method: Method that does little except invoke another method

-> This is an indication that adjacent layers have similar abstractions

🚩 Pass-Through Method: Indicates that there is not a clean division of responsibility between the classes

When seeing a class with pass-through methods ask yourself: "Which features and abstractions is each of these classes responsible for?" -> overlap likely

Possible solutions:

  1. Expose lower level class directly to the caller of the higher level class
  2. Redistribute functionality between the classes
  3. Merge classes

7.2 When Is Interface Duplication OK?

Methods with the same signature are not always bad -> e.g. dispatcher

7.3 Decorators

Decorators also lead to API duplication across layers -> decorator takes existing object and extends its functionality while providing an similar or identical API

Decorators are easy to overuse, resulting in a lot of shallow classes

Alternatives:

  • Add functionality directly to the underlying class (for general purpose or closely related functionality)
  • Merge new functionality with existing decorator
  • Implement as standalone class

7.5 Pass-Through Variables

Pass-through variable: Variable which is passed through a lot of methods (e.g. commandline argument describing a certificate required by low level method to open socket)

Changing or adding these variables requires a lot of work, try to avoid

To eliminate:

  • Use a shared object between high and low level class / method
  • Store information in global variable (might create other problems)
  • Prefered solution: Introduce context object which stores application's global state (to prevent passing through context through methods -> inject reference to context via constructor)

-> context have however similar problems to global variables ("Where is this used?")

7.6 Conclusion

Every piece of design infrastructure (interface, argument, function, class) adds complexity

Therefore, every element must eliminate some more complexity than it creates

For different layers with the same abstraction there's a good chance that they don't add enough benefit to compensate for the additional infrastructure they represent

8 Pull Complexity Downwards

It is more important for a module to have a simple interface than a simple implementation

If possible, try to handle complexity related to the functionality of a module within the module

8.2 Example: Configuration Parameters

Rather than determining a behavior internally → export a few parameters which control a classes behavior and let the user specify them

    • → Users are more familiar with their domains
    • → Excuse to avoid dealing with important issues

Example: Network protocol that must deal with lost packets

  • Retry interval could be a configuration parameter
  • Or could be calculated based on a multiple of the response time for successful transmissions → this pulls complexity down

Avoid configuration parameters as much as possible (”will users be able to determine a better value than we can determine here?”)

If not avoidable, try to provide a good default value so that it only has to be set in exceptional conditions

8.3 Taking It Too Far

Only pull complexity down if:

  1. Complexity being pulled down is closely related
  2. Results in many simplifications elsewhere in the application
  3. Pulling down complexity simplifies the classes interface

8.4 Conclusion

Look for opportunities to take a little bit of extra suffering upon yourself in order to reduce suffering of your users

9 Better Together Or Better Apart

When deciding to combine or separate implementations, the goal is to reduce the complexity of the system as a whole

Dividing the system into a large number of small components might not be the best solution:

  • Some complexity comes from the number of components itself
  • Subdivision can result in additional code to manage components
  • Subdivision creates separation (methods and up in different classes or even files)
  • Subdivision can result in duplication

Related code is generally better together. Indicators that code is related:

  • Share information
  • Used together (when using one, the other must be used too)
  • Hard to understand one piece of the code without the other

9.1 Bring Together if Information is Shared

If some specific information is needed in two methods / classes -> bring them together

🚩 Repetition: Repeted code indicates that you haven't found the right abstraction

9.9 Conclusion

Pick structure resulting in the best information hiding, fewest dependencies and deepest interfaces

10 Define Errors Out Of Existence

10.1 Why Exceptions Add Complexity

Exception handling code is inherently more difficult to write

Exception handling code may be larger than actual logic code

Exception handling code rarely gets executed and is harder to test -> bug prone

10.2 Too Many Exceptions

Over-defensive coding style can lead to unnecessary exceptions

Exceptions are inherently part of a classes interface -> they influence calling classes and methods

Throwing exceptions is easy -> handling them is hard

10.3 Define Errors Out Of Existence

Try to change method definition so that no exceptions are required.

Example: unset method which deletes a set variable

  • Definition 1: unset deletes a variable, if it does not exist, exception is thrown
  • Definition 2: unset ensures that a variable no longer exists
  • 2 does not require an exception, 1 does

10.8 Just Crash?

For unrecoverable errors, it makes sense to crash the application

10.11 Conclusion

Special cases make code harder to understand (e.g. exceptions)

If possible, redefine semantics that eliminate error conditions

If elimination is not possible, try to mask exceptions at a low level to limit their impact

11 Design It Twice

Always consider multiple design solutions for a problem. If you are not happy with any of them, try to combine or come up with additional designs

12 Why Write Comments? The Four Excuses

12.1 "Good Code Is Self-Documenting"

Things as good variable names can reduce the need for comments -> but it doesn't replace it

Some information can not be represented in the code, only in a comment:

  • Conditions required to call a certain method
  • Design decisions
  • Informal aspects of an interface such as a high-level description of the methods

Abstraction using methods only makes sense when the user does not have to read and understand the whole method -> comment can provide required information

12.2 "I Don't Have Time To Write Comments"

Benefits of having good documentation will quickly offset time invested

12.3 "Comments Get Out Of Date"

Changes to documentation are only required with changes of the code

Code reviews should help detecting stale comments

12.5 Benefits Of Well-Written Comments

Good comments capture information that was in the mind of the designer but couldn't be represented in the code -> having this information simplifies future changes

13 Comments Should Describe Things That Aren't Obvious From The Code

Developers should be able to understand the abstraction provided by a module without reading any code other than its externally visible declarations

Comment Categories:

  • Interface comment: Comment preceding a class or method describing its abstraction and behaviour, as well as side effects or exceptions
  • Data structure member comment: Comment next to the declaration of a field in a data structure
  • Implementation comment: Comment within the code describing how it works internally
  • Cross-module comment: Comment describing dependencies that cross module boundaries

🚩 Comment that repeats code

13.3 Lower-Level Comments Add Precision

Used e.g. for commenting variable declarations:

  • Units
  • Inclusive or exclusive boundaries
  • Invariants

13.4 Higher-Level Comments Enhance Intuition

Help the reader understand intent and structure of the code

13.5 Interface Documentation

Interface comments provide information that someone needs in order to use a class or method

14 Choosing Names

🚩 Vague Name: Variables with vague names are more likely to be misused

🚩 Hard to pick name: If it is hard to find a simple name for a variable or method, the design might be unclean

Be consistent with naming over the whole application

15 Write The Comments First

Writing comments can be part of the design process

Benefits:

  1. Produces better comments. Lets you focus on the abstractions of methods. During the coding and testingn you will notice and fix problems with the comments
  2. Produces better designs because you prevalidate your abstractions
  3. Makes comment writing more fun

🚩 Hard to describe: If it is difficult to write a simple and complete comment, there may be a problem with the design

16 Modifying Existing Code

Always think if the current system design is still the best one after that change

17 Consistency

Consistency creates cognitive leverage and reduces mistakes

17.1 Examples of Consistency

  • Names
  • Coding style -> Coding style guide within an organization
  • Interfaces -> An interface enforces consistency over multiple implementations. When you understand one you can easily understand others
  • Design patterns

17.2 Ensuring Consistency

Document rules and coding style guides and review regularly

Enfore rules (if possible automatically, otherwise in code reviews)

Don't change existing conventions. If conventions are updated, all old conventions in the code should be replaced -> make sure it's worth it!

18 Code Should Be Obvious

Code should always be easy to understand. It should be obvious what it does.

Your code is only obvious if it is obvious for someone else

18.1 Things That Make Code More Obvious

-> Good names (chapter 14)

-> Consistency (chapter 17)

🚩 Nonobvious code: If the meaning and behavior of code cannot be understood quickly -> change it

18.2 Things That Make Code Less Obvious

Event driven programming: Makes it hard to follow the flow of control. Event handlers are invoked indirectly by the event module

Generic containers: Tuples (without names) can be hard to immediately understand.

-> Software should be designed for ease of reading, not ease of writing!

Code that violates reader expectations: Unexpected behavior makes code hard to understand

18.3 Conclusion

To make code obvious:

  • Reduce the amount of information that is needed
  • Take advantage of information that readers already acquired in other contexts (by using conventions and adhering to expectations)
  • Present important information to the reader via good names or comments

19 Software Trends

19.1 Object-Oriented Programming and Inheritance

Dependency Injection over Inheritance -> Interfaces are your friend, inheritance is not

19.2 Agile Development

Increments of development should be abstractions, not features

Take your time for clean design and make it somewhat general purpose

19.3 Unit Tests

Makes developers more confident to refactor and make structural improvements which improves system design

19.4 Test-Driven Development

Problem with TDD is that it focuses attention on getting features done instead of getting the best design

19.5 Design Patterns

Don't try to force a problem into a design pattern when a custom approach is cleaner

19.7 Conclusion

Always challenge new software development pardigms from the standpoint of complexity

20 Designing For Performance

Simplicity does not only improve a system's design, but it usually also makes systems faster

Complicated code tends to be slow because it does extraneous or redundant work

21 Conclusion

Avoid complexity