In software systems, maintenance quickly becomes harder as more components are added. When a component deteriorates, it should be possible to refactor or replace it without having to change many other parts of the system.
Following this principle, each component needs a clear separation of concern with a clear interface, and without leaky abstractions.
Minimize the cost of change
Over time, implementations and underlying dependencies will change, but the interfaces should remain stable. Changes to a component’s implementation should have minimal impact on other components in the system.
An interface should depend on the code that calls it, not its implementation.
Let’s take an example component “C” in our system. The deeper C is everywhere in the system, the more often it is used, the more important its interface becomes. When considering the cost of C, it may help to ask ourselves questions like this:
- Is it hard to refactor without affecting other components?
- Is it hard to replace within the structure?
- Is it hard to replace its underlying dependencies?
- Is it hard to build another feature with an alternative of C?
Answering “yes” means more coupling of other components towards C. This means the project is harder to maintain, increasing the cost of C.
This may also indicate a leaky abstraction, if C fails to encapsulate and hide its underlying implementation details.
In summary, there is value in adding the right abstraction for C.
The more expensive it is to refactor or replace a component in the system, the more value it has to design an interface to abstract the implementation away.
The hard part is to figure out the modularity of the system, and what components will benefit from an abstraction.
Is a component large and complex, perhaps resembling more of a framework? If you know upfront a potential refactoring is hardly feasible then an abstraction is likely not worth it. Yet in that case, maybe you need to take a step back and reconsider the modularity of the system as a whole. Is it possible to increase its modularity and minimize the cost of changes that will inevitably be necessary over time?
On the other hand, excessive component fragmentation leads to an over-engineered system with too many moving parts and interrelationships. The balancing act between under- and over-engineering can be a tough cookie.
When the overall structure is largely in place, the process of interface design comes down to iteration until the questions above are answered with “no”.
Perhaps ironically, this article itself is abstract, and the process of software design may feel distant or overrated. Yet taking the time to think and design before and during ambitious projects pays off in the long run as it will reduce maintenance complexity and facilitate system evolution.
Enforce module boundaries
When everything becomes less abstract and more concrete, we descend into lower level module boundaries. Here, it becomes essential to guard and enforce module boundaries. Even the best interfaces and abstractions may go entirely unnoticed by fellow developers. However, depending on the technology stack of choice, tooling might be available to guard certain layers of modularity. At the level of code and configuration, linters may prove very effective.
They help to enforce module boundaries and prevent direct imports of underlying modules or dependencies, and suggest to use the provided abstraction instead.
When properly configured, tools like this effectively encourage developers to think about the system and its components, and consider the usage and value of abstractions.
Resources about related programming principles: