A Philosophy of Software Design by John Ousterhout is one of my favorite books on Software Development. It talks about how building software is all about managing complexity.
There are two ways we can deal with this complexity:
- Write simple and obvious code to eliminate complexity.
- Or encapsulate the complexity so that you don't have to deal with it.
A software system is complex if it is hard to understand or modify. Another measure of complexity is how much time you spend dealing with the complexity in the system. If there is complexity in the system that is isolated in an area that you don't have to interface with then this is equivalent to removing complexity.
Complex systems are:
- hard to change
- hard to understand
- hard to predict
Since complexity can be managed by hiding it, it becomes important how we surface that complexity for other components in the system to interact with. One of the most important techniques for managing software complexity is by designing systems where you only need to deal with a small position of complexity at a time. This is called modular design. A module can be thought of as a service, component, class, or function. Essentially, it is any structure that encapsulates complexity.
You should aim to have deep modules that hide a lot of complexity and that expose it through a minimal interface. A minimal interface reduces the complexity that a module imposes on the rest of the system.
It is not worth having shallow modules. It is likely that your abstraction is not hiding enough details and the surrounding system has too many touchpoints with the large surface area of a shallow module. You should try to find the simplest interface and build a somewhat general-purpose module that is deep.
An example of a deep module is the garbage collector in Python. It essentially has no interface and encapsulates a lot of complexity that arises from the task it performs.
A lot of complexity can also be managed by how you structure things. One thing to pay attention to is to have different layers for different abstractions and to not mix those abstractions. This would make it easier to think about a given layer without concerning yourself with the rest.
Application of this principle can manifest itself at the interface of a module. It suggests that the representations that are used internally by a module should be different from what is used by the interface.
Pass-through methods and variables break this rule. When you pass high-level information to a low-level method, you end up having all the intermediate layers become aware of their existence.
Another way of dealing with complexity is by pulling it downwards. If you have a module that introduces complexity to the system due to the functionality it provides, it is better to hide that functionality inside that module. Most modules have more users than developers, so it is better to hide the functionality to have the developers deal with it. One example of this could be the config parameters. They could obviously be useful and provide flexibility but they can also be misused and pass the responsibility of configuration to the users when as a developer you could have handled it dynamically. Ideally, each module should solve a problem completely and config parameters can indicate an incomplete solution.
By creating too many small modules, you could increase the surface area of the entire system which could increase the complexity. It could also make it harder to read the code since the context is spread around. Only break things up if it is going to make the overall system simpler.
Exception handling code could be a source of a lot of complexity. For one, handling exceptions can create its own exceptions. Also, exception handling code doesn't get executed all that much by its very nature. This means it is hard to ensure that it actually works as intended and doesn't suffer from bugs. It could additionally be hard to test this kind of code too due to the exceptional circumstances it requires to be invoked. A piece of code that raises too many exceptions is shifting the responsibility of dealing with the complexity to the user. It would be best if you could just handle the errors silently without notifying the users whenever this makes sense.
Imagine a networking transport protocol such as TCP. You don't receive an exception when a package drops. The protocol just handles it automatically by resending it. This is the complexity hidden.
myList = [1, 2, 3] print(myList)
undefined. It doesn't throw an error and hence is less complex to deal with.
const myArray = [1, 2, 3]; console.log(myArray);
Choosing appropriate names when coding is incredibly important in managing complexity. Names are abstractions. A wrong or misleading abstraction can increase complexity and can be a source of hard-to-fix bugs. Another thing to be careful about names is their consistent usage. Be consistent when naming similar things, and don't use that name for anything that is unrelated.
Consistency is important in general too. It helps reduce the complexity in the system by reducing the cognitive burden. Consistency plays on our strength in pattern recognition and helps us leverage what we already know.
That's why it is important to abide by existing conventions. Having a better idea of how to do things is not a sufficient excuse to introduce inconsistencies to the system. Your new convention should be markedly better than the existing one to replace it.
There are arguments against the usage of comments out there but they can still be valuable. One primary argument against comments is that the code should be descriptive enough to reveal what it does. But, if you have to dive deep into the code to understand its functionality, then it means the code doesn't have a good enough abstraction.
Comments are abstractions that can help you hide complexity. Comments can also contain information that might be impossible to capture in the code (like hardware quirks, etc.) Comments don't necessarily add a maintenance overhead either if they are high-level enough.
A Philosophy of Software Design (affiliate link) is primarily 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. You should always evaluate your decisions from the standpoint of complexity to ensure the sustainable continuity of the overall system.