The value of abstractions

Published by on (last update: )

In software systems, maintenance quickly becomes harder as more components are added. Given a well-designed system, 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. Any abstractions should not be leaky.

This article presents some considerations in the process of designing a complex system and the components therein.

Connected particles in abstract style (AI-generated by OpenAI)

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:

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 short: there’s value in having 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.

One of the hardest part of system design is to work out the modularity of the system, and seeing what components benefit the most 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. 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 lower the cost of changes that will inevitably be necessary over time? What are the trade-offs when going all-in on the framework?

On the other hand, excessive component fragmentation leads to an over-engineered system with too many moving parts and interrelationships.

This 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 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.

Within the JavaScript and Node.js ecosystem, a great example of such a linter is ESLint. Relevant rules include the built-in no-restricted-imports and Nx’s @nrwl/nx/enforce-module-boundaries rule.

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.

Further reading

Resources about related programming principles: