Design: Chain of responsibilities
A very common need in development is to add some process to an existing one. Whether it is optional or not, to be executed first or last (should you know), it can lead to poorly maintainable and non-flexible code.
Let say you have a DataProvider (replace “Data” by any business type) :
class DataProvider {
get(key): Data {
// Fetch and return the selected data
}
}
and you want to add a caching capability to it.
The naive way
A naive way to add a cache capability to it could be like below (of course there are several issues you should care about here, like the cache growing without limits – use a LRU implementation instead – not caching data when not found, using a WeakMap if appropriate, etc. but this is not the topic here):
class DataProvider {
private cache = new Map<string, Data>()
get(key): Data {
let data = this.cache.get(key)
if (!data) {
data = // Fetch and return the selected data
this.cache.set(key, data) // Cache it for next time
}
return data
}
}
Now, imagine you need other features, like:
- providing data from memory only: This could be useful to provide client-side results without issuing server requests (because the backend is not ready yet, or malfunctioning)
- disabling caching: sometimes you want to be sure to get the real deal from the database and not any cached value (without necessarily clearing the cache).
Ugly complexification
Both of those needs would require to complexify the implementation with something like:
class DataProvider {
private cache = new Map<string, Data>()
constructor(private useCache: boolean, fetch: boolean) {
}
get(key): Data {
let data = this.useCache && this.cache.get(key)
if (!data) {
if (this.fetch) {
data = // Fetch and return the selected data
}
if (this.useCache) {
this.cache.set(key, data) // Cache it for next time
}
}
return data
}
}
and you would instantiate them using:
fullMemory = new DataProvider(true, false)
nonCaching = new DataProvider(false, true)
Pretty ugly, right? You should have started worrying as soon as adding flags checked with a bunch of “if”s. Those are code smells.
Furthermore, disabling flags doesn’t remove the relevant code, so anybody requiring only the full-memory version will get the code (and the potential bugs and maintenance hassle) of the non-caching one, and vice versa.
What a mess. As soon as you have a problem, thinking of “adding a if” or “adding a flag” should be a warning in your head that you might heading to bad design.
Thinking clean
To rewrite this ugly code in a clean way, we have to ask how many responsibilities we are dealing with. There are two:
- fetching
- caching
Applying the golden SRP, that means there should be two separate implementations, not a single one trying to deal about all the concerns.
The non-caching one being purely about fetching:
class FetchDataProvider implements DataProvider {
get(key): Data {
// Fetch and return the selected data
}
}
And the caching one purely about caching, with a twist: it decorates (delegates to) the other one to perform fetching on cache miss:
class MemoryDataProvider implements DataProvider {
private cache = new Map<string, Data>()
constructor(fetchingProvider?: FetchingDataProvider) {
}
get(key): Data {
let data = this.cache.get(key)
if (!data) {
data = fetchingProvider?.get(key)
this.cache.set(key, data)
}
return data
}
}
Two simple codes instead of a complex, not flexible one. And any of the combination will load just the required code, no more:
memoryOnly = new MemoryDataProvider()
fetching = new FetchingDataProvider()
caching = new MemoryDataProvider(fetching)
As you saw, both of them aim the comply with the same “DataProvider” business contract, or interface:
interface DataProvider {
get(key): Data
}
so that this API user will always and only see the public contract, without necessarily knowing about the implementation, which could even be a mock object implementing this interface to provide test-only values:
memoryOnly.get("myKey")
fetching.get("myKey")
caching.get("myKey")
testDataProvider.get("testKey")
Generalizing
Now, what if you want to add a ciphering capability? That would mean writing a third CipherDataProvider implementation, then binding it as the delegate of another class… but where should it be, just after fetching, or later, or what?
You shouldn’t care about that, and provide the best flexibility of a chain of responsibility without much more effort, by generalizing the delegation pattern. For instance:
class CipherDataProvider implements DataProvider {
constructor(private delegate?: DataProvider) {}
get(key): Data {
let data = this.delegate?.get(key)
if (data) {
data = cipher(data)
}
return data
}
}
Now, once all implementations are refactored this way, you can place the ciphering (or logging, or whatever other step) wherever you want:
cipheringLast = new CipheringDataProvider(new MemoryDataProvider(new FetchingDataProvider()))
cipheringFirst = new MemoryDataProvider(new CipheringDataProvider(new FetchingDataProvider())))
noCiphering = new MemoryDataProvider(new FetchingDataProvider())
cipheringMemory = new CipheringDataProvider(new MemoryDataProvider())
Conclusion
By applying SRP and design patterns, you can design a complex chain of responsibilities as a set of simple and easily testable implementations of the same business contract. There separate classes provide the best flexibility and ease maintenance and testing.
This shows how simplicity relates more to maintainability than a number of files or types, and so can emerge from a “divide-and-conquer”/SRP design strategy.