Design: What is simple?
Should it be global or local?
There is no such thing as universal characteristics for simplicity: some think this is related to size (the number of code lines — but you can write too complex things on a few lines — or the number of concepts involved — but reducing that number mechanically increases the complexity of the remaining concepts ) while others believe it is related to the nature of concepts themselves (privileging a programming paradigm over another).
Actually simplicity is relative to both the task to do and the people who does it. As software development is most often a team effort however, it is important to grasp what are the most shareable traits of simplicity in software development.
The goals of simplicity
Maybe asking the question of the benefits of simplicity can help to grasp its definition. Why are we looking for simplicity? Obviously to make our work easier. We want:
- productivity: to write quickly so you can deliver ASAP ;
- maintainability: to read quickly as one could say, so you can keep doing step 1 as quickly as before. Not only this covers easy readability/understanding of the existing code, but also the ease of change (debug/fix or evolve).
As you can see, a common value behind simplicity of development is speed of development.
As you may have spotted as well, those goals seem at odds with each other: writing quickly often results in poor maintainability (difficulty to read/understand, difficulty to change) and, conversely, being able to fix or evolve code quickly often requires careful writing.
In most professional environments you will be pressured to deliver quickly and so to prioritize goal 1 over goal 2. This would a short-term, non-rentable choice however, as:
- a good maintainability warrants a good delivery pace ;
- by speeding up you may be thinking “too fast” and have a higher risk of introducing bugs ;
- we read code more frequently than we write it (often once) so you should not give more importance to something you do only 20% of the time.
Because of that, maintainability should be our primary goal, and productivity shall emerge from that because your design will be kept agile (i.e. change-friendly).
How to make things simple?
Global simplicity is only reachable for simple apps. Most applications provide value because they handle complex problems, so we have to find out how to design maintainable solutions to complex problems.
These are two (seemingly contradictory) moves: grouping and splitting.
Complexity can be defined as the need to handle a too great number of things. The intuitive way to solve this is to group them in a unit with fewer (and so clear) inputs and outputs.
As the “group” maintains a logical consistency, operates a defined function from its inputs towards its output, a “role” or abstraction can be defined for it. Its outer facade defines its “contract” with the outside (its users).
However, note that by reducing the complexity of the contract, you also reduce the possibilities of it: when you define the contract of a motor, you hardly allow its internal pieces to perform another kind of service.
So abstraction gives you more simplicity but less flexibility, which might be required to be agile. This is why you should be wary to fulfill the open/close principle of your software items in order to keep them extensible.
One renowned way to simplify complex things is to split it as a set of more simple things. Each time your app is refactored to use a component with a single responsibility, local simplicity is achieved. That component can then both solve its simple problem and be maintained easily.
When to split?
What is the proper granularity between too complex and too simple? A couple of principles can help deciding:
- the SRP principle states that as soon as you handle more than 1 concern, you should split your code. Conversely, if you want a component to not do something, you should split it too with another component responsible for the optional part.
- the cohesion of your class, that is, the propensity of a class’s method to use most of the class’ attributes, is also a big clue about the need for split. If some methods are only using a sub-set of the class’ attributes, they probably should better fit in a separate class.
Some people might be concerned by the idea that splitting adds layers and that communications between those layers might be costly in terms of:
- development time: you have to implement APIs between those layers ;
- performance, especially if the layers are remote, like in micro-services architectures.
So, following some YAGNI mantra, one may object that : “You won’t need to change that part”. However, this is missing the point of why we want to split in parts.
The reason for splitting has never been because we thought we’ll need to “change that part”. That is a remote possibility, but the actual reason for splitting is to separate concerns in the first place. “What if I needed to change that part ?” is just a fictional scenario that makes you design each part so that it is more maintainable and more testable (by limiting each part dependencies, not testing multiple things at the same time, etc.).
So what we’re talking about here is a logical split that have no more performance implications than a function call compared to inline code. There is no physical split implied here and, if so, it should be discussed in a separate architectural discussion.
Finally, one might then argue that the sole fact of insulating code in distinct functions or object classes is a development overhead that you could avoid. But this is looking only at the dark side of the coin, thus forgetting the bright side of a better maintainability. The gain of an increased maintainability is far beyond the cost of a function or object call. As Robert C. Martin stated in his reference book Clean Code:
Do you want your tools organized into toolboxes with many small drawers each containing well-defined and well-labeled components? Or do you want a few drawers that you just toss everything into?
Only simple programs can achieve global simplicity. The majority of others can and should only target local simplicity, that is, simplicity of the code part you’re maintaining at a given time.
This can only be achieved by two complementary refactorings:
- code grouping into higher-level — but extensible — abstractions;
- inside those groups, code split into simpler abstractions according to the SRP principle.