A Philosophy of Software Design
by John Ousterhout
Aim to keep things simple. Complexity inevitably arises in larger projects, but simplicity wherever possible should be a continuous pursuit.
- Write simpler code
- The second approach to reduce complexity is to use modular design. Couple this with good API design practices, and developers can do more with exposure to leas complexity.
Realize that refactoring is an ongoing process. As a developer, you should always be on the lookout for opportunities to improve design. Plan on spending some fraction (~10-20%) of your time on design improvements.
Spend about 10%-20% time on investments. Yes, you’ll be slower initially, but much faster in the long run.
Complexity is death by a thousand cuts. It never happens just overnight. It builds up over time. Decision by decision.
The primary goal is great design (that also works).
For this, you have to think long term. Not just how you can finish your current task as fast as possible.
Differentiates between tactical programming and strategic programming.
Tactical wins the battle, strategic wins the war. Tactical is fast, but leaves a trail of destruction (complexity, bad decisions due to shortcuts).
Strategic programming requires you to be proactive. Iterate a few times on your design. Take the time to write good documentation. It also requires you to be reactive. You continuously make small improvements. Refactoring. Updating when you learn something new.
I’m some environments, shipping fast now is viewed as the only viable option. For example, startups.
They often take a tactical approach, thinking that once they make it big, they’ll have money to hire extra engineers to clean up.
However, code bases that become spaghetti are nearly impossible to fix. You’ll be paying off technical debt for the rest of its time.
At the same time, the payoff for just doing it right from the start comes quite quickly.
In my view, people probably confuse moving fast with rushing. Some technical tools are simpler and have a smaller barrier to entry (cost, skill, time, etc.) than other tools that can scale better. Starting simple (to go faster initially) and then scaling up to (insert complex tech here: kubernetes, etc.) is one thing. Rushing out features without thinking through design is a whole other thing.
We’re often told that function/method implementations over X lines are bad and should be refactored to separate concerns.
This can, however, increase complexity.
I tend to agree. Die-hard enforcement of “a function has only one purpose” often leads to unnecessary bloat and complexity.
Don’t split up functions / methods unless it makes the function simpler. Don’t just go by line #.
Modules aren’t just classes or individual packages. It can be functions. Services. An API. as long as it has an interface and implementation.
The best modules are those with interfaces much simpler than their implementations. They abstract the complexity away.
Deep modules are preferable. They provide a lot of functionality (deep implementations) as the small interface hides a lot of unimportant details through abstraction. Example: the Linux IO system. It has just 5 methods, but hides a lot implementation and complexity.
On the flip side, a shallow module is one that just bare wraps around the small implementation. E.g. it doesn’t hide the unimportant details, implementation details, and so on. An example is a wrapper around a linked list. They aren’t very complex, so there isn’t much to abstract away.
Information hiding is key to producing deep modules.
You hide design decisions in the implementation, and ensure they don’t appear in the modules interface.
The best information hiding is when information is totally hidden within the module, so it’s invisible to the users.
Partial information hiding can be valuable as well, but ensure you choose sensible defaults and make the most used cases the most visible. That way, power users and regular users are all satisfied.
Information leakages are basically when design decisions / implementation details leak into other modules, such that they end up depending on that particular implementation. This is a bad dependency, and is a design red flag.
Another example of information leakage: exposing the internal data structures used.
Returning the data structure itself, depending on type, can lead to the user being in possession of the internal data, which they could potentially modify, thereby causing very painful bugs.
It also restricts your ability to make modifications, for performance and otherwise, as users will be dependent on that particular implementation. Changing the data type could lead to you changing the interface, potentially a breaking change.
Information hiding can go too far. Song hide information that is required outside of the module.
As a good designer, you think about how you can minimize the amount of information required outside of the module. Then there’s less for uses to learn, and your module is simpler.
Temporal decomposition is where you decompose logic into classes/functions based on its order of operations. This can lead to information leakage.
Imagine three operations: read a file, modify its content, write the file. 1 and 2 needs to know about the file format. If those steps are implemented separately, you have information leakage: their details are dependent on each other.
When designing modules, focus on what knowledge is necessary at which time, rather than order of operations.
Don’t encode the same information in different modules. Find a better design.
Making your modules general can simplify them.
It’s a common trap to make it too specific because you know exactly what you need from it (right now, that can change!). However, you should design the interface to be general and simple, rather than specific for the niche cases you’re currently having.
This can be taken too far. It is very hard to predict future needs. Try to ask yourself whether you could possibly make the interface simpler/have fewer methods, whether you’ve made it too targeted at your current use-case, and in how many cases each method is applicable (more = more general = good).
If your methods have few use cases, they may be too specific.
As mentioned earlier: design good defaults. Make the common case as simple as possible.
But don’t deny powerful customization: just only allow it when a specific path is taken, so it isn’t possible by accident.
Using the Decorator pattern can be a red flag.
When reaching for it, think whether it would make sense to just add the functionality to the base class. Should you merge the functionality with the use case instead of making a new class? Add it to an existing decorator? Could you implement the functionality as a stand-alone, independent of the base class?
As module developer, try to handle complexity for users (to a reasonable degree). There are usually many more users than developers for modules, so better the developers suffer (the utilitarian developer lmao).
Ways this can be done is:
- finding and setting sensible defaults so users don’t have to. The less configuration the better. But don’t remove the option, just the need
Here’s a great heuristic for method design: it should do one thing completely.
The interface should be simple to reduce mental overhead for users (less complexity to be exposed to).
It should be deep, i.e. the interface hides all the complexity involved in the implementation.
A good way to reduce complexity is to reduce exceptional states.
For this, a good approach is to define the errors out of existence. See if you can redesign your code like so. Perhaps that requires you to reframe the problem you’re solving, redesign an interface, or use another name.
- design patterns
- code style
Obscure code is complex code. Make your code obvious.
Relying on too many sneaky tricks, clever hacks, and so on can make your code unreadable.
It should be human readable, not just readable by the compiler. That’s the bare minimum.
Formatting and naming are big contributors.