It is good software design practice to make sure that methods can be entirely viewed in the code editor that typically shows 30 to 45 lines at a time. The root of this principle is easy to grasp: scrolling up and down over a too large method impedes code readability.
Refactoring a too large method or a too large class implies to create several code elements smaller in terms of number of textual lines. But ultimately the code behavior didn’t change. In other words the business complexity remained the same but the refactor session reduced the implementation complexity (at least we hope so).
Software complexity is a subjective measure relative to the human cognition capabilities. Something is complex when it requires effort to be understood by a human. Software complexity is a 2 dimensional measure. To understand a piece of code one must understand both:
- What this piece of code is doing at runtime, the behavior of the code, this is the business complexity.
- How the actual implementation solves the business needs at runtime, this is the implementation complexity.
The whole point of software design, SOLID principles, code maintainability, patterns… is to avoid adding implementation complexity to the business complexity. But from where implementation complexity comes from? There are 5 main sources of implementation complexity:
Too Large Code
We already mentioned this one. It is easy to limit implementation complexity by abiding by simple code rules that check thresholds on code metrics like methods number of lines of code and method complexity, or classes with too many methods. The Single Responsibility Principle (SRP) also contribute to smaller implementations: less responsibilities for a class necessarily means less code.
Lack of Abstractions
An abstraction such as an abstract method, an interface or even a delegate, is the minimum required knowledged to invoke an implementation at runtime without knowing it at design time. In other words an abstraction hides implementation detail. Abstraction represents a great weapon to reduce the implementation complexity because polymorphism can replace quantity of if/else/switch/case. Concretely code like that:
1 2 3 4 5 6 |
void DrawShape(Shape shape) { switch(shape.Kind) { case ShapeKind.Circle: DrawCircle(shape); break; case ShapeKind.Rectangle: DrawRectangle(shape); break; } } |
Can be replaced with code like that:
1 2 3 |
void DrawShape(IShape shape) { shape.Draw(); } |
Abstraction also reduces implementation complexity because it frees the developer mind of implementation details.
The S in SOLID is the SRP (mentioned above) and is not related to abstraction. But the OLID principles are all about using abstractions wisely:
- The Open-Close Principle (OCP) explains that an abstraction must remain stable with time, and thus must be designed carefully.
- The Liskov Substitution Principle (LSP) explains that the quantity of knowledge presented by an abstraction must be enough to use it in all situations.
- The Interface Segregation Principle (ISP) explains that an interface must be cohesive: methods that consume the interface typically use most of its members.
- The Dependency Inversion Principle (DIP) explains that most dependencies in a system must point toward abstractions and not toward implementations.
How do we check for good usage of abstractions? There is no magic stick like thresholds to limit too large code elements. However the Abstractness vs. Instability graph and metrics and the Level metric are a good start to identify code areas that need more abstractions. They are both described in this post about Dependency Inversion Principle.
State Mutability at Runtime
A common source of implementation complexity is mutable states. The human brain is not properly wired to anticipate what is really happening at runtime in a program. While reviewing a class, it is hard to imagine how many instances will simultaneously exists at runtime and how the states of each these instances will evolve over time. This is actually THE major source of problems when dealing with a multi-threaded program.
If a class is immutable (if the states of all its instances objects don’t change at runtime once the constructor is called) its runtime behavior immediately becomes much easier to grasp and as a bonus, one doesn’t need to synchronize access to immutable objects. See here a post explaining in-depth immutable class.
A method is a pure function if it produces outputs from its inputs without modifying any state. Like immutable classes, pure functions are also a good mean to reduce implementation complexity.
Some code rules exists to enforce state mutability like Structure should be Immutable or Fields should be marked as ReadOnly when possible. Being immutable for a class or pure for a method is such an essential property that dedicated C# keywords for that would be welcome, like readonly for fields. Here is a proposal for C# support of the immutable keyword. By now some ImmutableAttribute or PureAttribute can be used to tag such elements and some code rule can check for Types tagged with ImmutableAttribute must be immutable or Methods tagged with PureAttribute must be pure
Over Coupling
When trying to re-engineer/understand/refactor a large piece of code (I mean something made of dozens of classes like a big namespaces or an assembly), the difficulty is directly proportional to the amount of coupling between considered code elements. Both dependency graphs below shows dependencies between 36 classes: but the left contains 64 edges and the right one contains 133 edges: which one would you prefer to deal with?
One key strategy to control coupling is to layer components and make sure that there is no dependency cycles. This can be checked on namespaces for example with the code rule Avoid namespaces dependency cycles. Using abstractions is also a good way to limit over coupling. If an interface has N implementations then relying only on one interface is virtually like depending on the N underlying classes.
Lack of Unit Tests
Software testing is a large topic I won’t cover here. But one key benefit of writing tests (apart enforcing business rules and detecting regressions early) is to ensure that the code is testable. Being testable for code means less coupling, more abstractions and overall simple code. Ultimately if the code is easily testable we can safely assume that its implementation complexity is kept low. Here also many code rules like Code should be tested can help enforce high testability.
One Measure for All
There are more sources of implementation complexity but those 5 ones are certainly the bigger culprits. To reduce this unnecessary complexity one must be able to measure it. But how to unify too large code, bad design, poorly tested code and more in a single metric?
As we saw most of these aspects can be enforced with code rules. A code rule produces issues and for each issue the code rule can estimate the cost to fix an issue and the annual cost to let an issue unfixed. A famous analogy with the financial field says that:
- The estimated cost to fix code smells is the Technical-Debt: a measure of the implementation complexity.
- The estimated annual cost to let code smells unfixed is the Annual Interest of the Debt: a measure of the business operation cost induced by poorly written and poorly tested code.
These estimations can be expressed in developer-time and ultimately in money cost which can be used by management to take the right decisions.
I like that you mention mutability as a source of extra complexity. It’s often overlooked, and it’s not always easy to convince people that it is worth the effort.
Another way to reduce implementation complexity is to map design and implementation to the business domain. That’s the core idea behind Domain-Driven Design. Once we achieve this, the implementation complexity disappears as it is already there in the business complexity. Event Storming is a great way to kick start DDD in an organization (If you are interested, here is a series of post as to how to get started https://philippe.bourgau.net/misadventures-with-big-design-up-front/)
Good remark, indeed DDD like testing are big topics to cover in depth, thanks