Abstraction vs. Contraption
Many amp hours have been spent discussing principles of software design. Today, I would like to discuss two patterns for creating fragile or vacuous software. In my experience, we often criticize both of these patterns, but those who decry one repeatedly commit the other. The both of them should be avoided. Of course, I’m not above having opinions, only I hope my preference isn’t clearly present in this discussion.
Concretion should be our normal modus operandi in software design. The best code I’ve read usually exhibits both of these qualities:
- Mutable state resides high in the call stack, where it passes through stateless behavior. In contrast, mutability is often relegated to low-level, stateful objects.
- The flow of logic can be traced through direct function call. In contrast, highly abstract code often relies on virtual dispatch or dispatch on generic type parameters.
Inevitably, we fall prey to the two forces of software design–encapsulation and decomposition–and we start to think in terms of abstractions. Abstraction invariably requires us to sacrifice one of these qualities. Object-oriented programmers may craft wild causal machines, woven from concepts with increasingly obscure stateful behavior. They have decided to delegate mutability. In contrast, functional programmers often raise towers of meaningless abstraction, whose types bear no resemblance to the problem domain. They have decided to break the flow of logic with polymorphism.
I work among OOP designers, and I often hear complaints about taxonomies of
abstraction. I recall the time I introduced a coworker to tower
with the
Service
trait.
“What’s a Service, though? That name is so abstract, I’m not sure what it means.”
He’s right, to be fair. You have to read the tower
documentation, and then
write one and use it with the ecosystem in order to develop a mental model for
the term. Even then, there doesn’t exist any terminology which can concisely
communicate the semantics of tower::Service
to someone who isn’t already
familiar. The mental model, in this case, is an ontology–the classification of
things, and the kinds of relationships between them.
These abstractions often build upon each other, so that a solid foundational
understanding is necessary to communicate effectively about the design. “A
Router
is a Service
that switches requests between multiple Services
.”
On the other hand, I’ve encountered systems built by developers who took OOP principles too far. This code looks something like this:
crew.checkTelltales();
skipper.hikeMainSheet();
skipper.adjustTiller();
The domain is clear. Googling these terms indicates the domain is sailing
related, and it’s obvious that the crew
and skipper
objects are intended to
represent the roles of the sailors. But in real sailing, the crew needs to tell
the skipper how to adjust the tiller based on the telltales. In turn, the
skipper will adjust the main sheet to keep the heading he wants. How do the
skipper and the crew communicate in this code? Do these methods perform I/O?
What happens if the skipper
hikes the main sheet before the crew
checks the
telltales? These are good function names. The problem is that they take no
arguments, and they return no values, so the flow of logic is totally obscured.
It must be the case that state is mutated by side effect from these methods, but it’s impossible to reason about how that’s done from reading the code. The mental model, in this case, is a dynamic one, which represents behavior and sequence at runtime. Here, I’m obligated to reference Dijkstra’s Go To Statement Considered Harmful:
[…] Our powers to visualize processes evolving in time are relatively poorly developed.
This is a contraption. One action changes the behavior of another action, or causes a third action to occur, like in a Rube Goldberg machine.
Don’t Fall Into the Trap
We often use one or the other of these patterns to achieve a high-level of
abstraction. Unfortunately, this is sometimes because we are trying to achieve
some ideological goal: the Service
and the skipper
are open to extension,
but closed to modification (OCP).
Please don’t mistake my intent–there are appropriate times to write mutable objects, and there are appropriate times to have highly abstract types. These work well at architectural boundaries, which is often where we want to achieve this level of abstraction. Care should be taken not to allow these patterns to proliferate the code.
Where possible, use patterns that your team is familiar with. Some teams know
the GoF patterns extremely well, and can effectively communicate using these
terms. Other teams may be well-versed in the established patterns of some
framework or open-source software, and they should be encouraged to communicate
abstractions in these terms instead. If you know axum
and tower
, the terms
Service
and Router
have (at least one) precise meaning.
Most importantly, one of these strategies for abstraction is not inherently better than another. We need everyone on the team to be able to craft these kinds of abstractions, and to grok them. It’s the job of the team to keep each of us accountable to our abstractions. Design responsibly.