Restrictive Abstractions
Alexandre Aquiles
Posted on March 30, 2024
Recently, I was discussing with João Júnior, an experienced software engineer and a close friend, about how we are sometimes tempted to create abstractions that end up restricting us.
A Caching Example
Caching is a must-have for most applications because it reduces the time to retrieve frequently accessed data, thus improving performance. We usually implement caching with in-memory key-value data stores such as Redis or Memcached.
A simple abstraction for caching would enable us to perform operations such as associating key-value pairs, retrieving and deleting them, handling expiration and checking for existing keys.
We could materialize this abstraction in the following Java interface:
public interface Cache {
boolean set(String key, String value);
String get(String key);
boolean delete(String key);
boolean expire(String key, int seconds);
boolean exists(String key);
}
This interface is a simplified version of real caching abstractions from Java technologies such as the ones from Spring or JCache (JSR-107). Both are part of quite complex solutions, having more generic types and different capabilities. Also, annotations would be preferred to using Cache
directly in most Java applications.
Sure, we could improve error handling and cache invalidation in the interface above. But our neat Cache
abstraction could go very far! This abstraction would hide away the complexities of implementing clients for caching mechanisms like Redis or Memcached.
Eventually, if we decide to adopt any other caching mechanism with better scalability or performance, we could do it just by creating a new implementation for this interface, without changing (almost) any other code. The Open/Closed Principle in action!
When Abstraction Gets in Our Way
Some abstractions can present challenges in subtle ways. And even a well-designed abstraction might need to be adjusted if context changes.
What if we need to use Redis features like geospatial indexes, probabilistic data structures or even transactions? Our current simple abstraction would not fit those new use cases.
We might consider modifying our Cache
interface to throw UnsupportedOperationException
for implementations like Memcached, which lack those advanced features. However, this approach would not be a genuine solution but a consequence of weakening the notion of subtyping.
As we start depending more and more on Redis-specific capabilities, it would be difficult to generalize them back to other caching mechanisms.
In such a scenario, an abstraction that promoted extensibility and freed us to try new implementations would start to tie us up to old assumptions.
What would be our options to solve this? We could:
- Reopen the closed abstraction, trying to find a new generalization.
- Coexist the current abstraction for basic use cases with implementation-specific code for advanced functionalities.
- Discard the current abstraction altogether.
The Spring framework, for instance, chose to have both generic and specific abstractions. In Spring applications, we can use caching abstractions for simpler caching needs. If we need specific capabilities, we could adopt more specialized modules such as Spring Data Redis.
ANSI SQL Can Be Restrictive
SQL is another example of a very sophisticated abstraction that can be restrictive—and leaky.
We can go very far using standard ANSI SQL. But, eventually, we end up having code specific to PostgreSQL or MySQL to optimize for performance.
Adhering to ANSI SQL could become so restrictive that it might prevent us from solving bottlenecks as data volume increases.
If our scenario really demands more than one database—for instance, software deployed on-premise within our client's infrastructure—we would probably have to maintain separate optimizations for each one of them. This would lead to code duplication but, paraphrasing Sandi Metz: prefer duplication over the restrictive abstraction.
So, Are Abstractions Useless?
Hold on! Abstractions are quite useful.
The absence of well-designed abstractions could result in code difficult to understand and to adapt to changing requirements.
And we can reap the benefits even from simple abstractions. We can go very far if we're lucky enough to stick to basic usages.
But context changes. Trying to fit the new uses on the existing abstractions can be like fitting a square peg in a round hole.
No abstractions are definitive, closed for ever. We should keep revisiting them, evaluating if our scenario and assumptions are still the same.
If our current abstractions are too restrictive, we should rethink them.
Posted on March 30, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.