Towards better abstractions by Mark Seemann
In my previous post I discussed why the use of interfaces doesn't guarantee that we work against good abstractions. In this post I will look at some guidelines that might be helpful in defining better abstractions.
One important trait of a useful abstraction is that we can create many different implementations of it. This is the Reused Abstractions Principle (RAP). This is particularly important because composition and separation of concerns often result in such reuse. Every time we use Null Objects, Decorators or Composites, we reuse the same abstraction to compose an application from separate classes that all adhere to the Single Responsibility Principle. For example, Decorators are an excellent way to implement Cross-Cutting Concerns.
The RAP gives us a way to identify good abstractions after the fact, but doesn't say much about the traits that make up a good, composable interface.
On the other hand, I find that the composability of an interface is a pretty good indicator of its potential for reuse. While we can create Decorators from just about any interface, creating meaningful Null Objects or Composites are much harder. As we previously saw, bad abstractions often prevent us from implementing a meaningful Composite.
Being able to implement a meaningful Composite is a good indication of a sound interface.
This understanding is equivalent to the realization associated with the concept of Closure of Operations from Domain-Driven Design. As soon as we achieve this, a lot of very intuitive, almost arithmetic-like APIs tend to follow. It becomes much easier to compose various instances of the abstraction.
With Composite as an indicator of good abstractions, here are some guidelines that should enable us to define more useful interfaces.
ISP #
The more members an interface has, the more difficult it is to create a Composite of it. Thus, the Interface Segregation Principle is a good guide, as it points us towards small interfaces. By extrapolation, the best interface would be an interface with a single member.
That's a good start, but even such an interface could be problematic if it's a Leaky Abstraction or a Shallow Interface. Still, let us assume that we aim for such Role Interfaces and move on to see what other guidelines are available to us.
Commands #
Commands, and by extension any interface that consists of all void methods, are imminently composable. To implement a Null Object, just ignore the input and do nothing. To implement a Composite, just pass on the input to each contained instance.
A Command is the epitome of the Hollywood Principle because telling is the only thing you can do. There's no way to ask a Command about anything when the method returns void. Commands also guarantee the Law of Demeter, because there's no way you can ‘dot' across a void :)
If a Command takes one or more input parameters, they must all stay clear of Shallow Interfaces and Leaky Abstractions. If these conditions are satisfied, a Command tends to be a very good abstraction. However, sometimes we just need return values.
Closure of Operations #
We already briefly discussed Closure of Operations. In C# we can describe this concept as any method that fits this signature in some way:
T DoIt(T x);
An interface that returns the same type as the input type(s) exhibit Closure of Operations. There may be more than one input parameter as long as they are all of the same type.
The interesting thing about Closure of Operations is that any interface with that quality is easily implemented as a Null Object (just return the input). A sort of Composite is often also possible because we can pass the input to each instance in the Composite and use some sort of aggregation or selection algorithm to return a result.
Even if the return type doesn't easily lend itself towards aggregation, you can often implement a coalescing behavior with a Composite by returning the first non-null instance returned by the contained instances.
Interfaces that exhibits Closure of Operations tend to be good abstractions, but it's not always possible to design APIs like that.
Reduction of Input #
Sometimes we can keep some of the benefits from Closure of Operations even though a pure model isn't possible. Any method that returns a type that is a subset of the input types also tends to be composable.
One variation is something like this:
T1 DoIt(T1 x, T2 y, T3 z);
In this sort of interface, the return type is the same as the first parameter. When creating Null Objects or Composites, we can generally just do as we did with pure Closure of Operations and ignore the other parameters.
Another variation is a method like this:
T1 DoIt(Foo<T1, T2, T3> foo);
where Foo is defined like this:
public class Foo<T1, T2, T3> { public T1 X { get; set; } public T2 Y { get; set; } public T3 Z { get; set; } }
In this case we can still reduce the input to create the output by simply selecting and returning foo.X and ignoring the other properties.
Still, we may not always be able to define APIs such as these.
Composable return types #
Sometimes (perhaps even most of the times) we can't mold our APIs into any of the above shapes because we inherently need to map one type into another type:
T2 Map(T1 x);
To keep such a method composable, we must then make sure that the output type itself is composable. This would allow us to implement a Composite by wrapping each return value from the contained instances into a Composite of the return type.
Likewise, we could create a Null Object by returning another Null Object for the return type.
In theory, we could repeat this design process to create a big chain of composable types, as long as the last type terminates the chain by fitting into one of the above shapes. However, this can quickly become unwieldy, so we should go to great efforts to make those chains as short as possible.
It should be noted that every type that implements IEnumerable fits pretty well into this category. A Null Object is simply an empty sequence, and a Composite is simply a sequence with multiple items. Thus, interfaces that return enumerables tend to be good abstractions.
Conclusion #
There are many well-known variations of good interface design. The above guiding principles looks only at a small, interrelated set. In fact, we can regard both Commands and Closure of Operations as degenerate cases of Reduction of Input. We should strive to create interfaces that directly fit into one of these categories, and when that isn't possible, at least interfaces that return types that fit into those categories.
Keeping interfaces small and focused makes this possible in the first place.
P.S. (added 2019-01-28) See my retrospective article on the topic.
Comments
I was able to follow you perfectly until the section "Composable return types", however I'd like to clarify how letting go of closure of operations affects composability. Certainly, if the return type isn't composable, you are SOL. However, conforming to the three previous shapes mentioned is only a (generally) sufficent but NOT necessary condition for composability, is that correct? I ask because you follow with "as long as the last type terminates the chain by fitting into one of the above shapes", and not just "... by being composable" so it muddies the waters a bit.
My second question is about this chain itself - not clear on what it is. It's not inheritance and I thought about the chain formed formed by recursively called Composites in the parent Composite's tree structure, but then they wouldn't be conforming to the same interface.
Also when you said other well-known variations of good interface design, were you talking about minimalism, RAP, rule/intent interfaces and ISP? I'm not aware of much more.
Many thanks!
Orchun, thank you for writing. I intend to write a retrospective on this article, in the light of what I've subsequently figured out. I briefly discuss that connection in another article, but I've yet to make a formal treatment out of it. Shortly told, all the 'shapes' identified here form monoids, and the Composite design pattern itself forms a monoid.
Stay tuned for a more detailed treatment of each of the above 'shapes'.
Regarding good interface design, apart from minimalism, my primary concern is that an API does its utmost to communicate the invariants of the interaction; i.e. the pre- and postconditions involved. This is what Bertrand Meyer called design by contract, what I simply call encapsulation, and is related to what functional programmers refer to when they talk about being able to reason about the code.