Adding functionalities without changing existing code using decorators. Practical example in Java
The decorator design pattern allows to add behaviour to an object without changing it, by implementing the behaviour in a new object that “wraps” the original one without extending it. Clear advantages are keeping the logic in separate classes, being able to compose them and being able to unit-test them in details.
Let’s take for example a basic URL download functionality. I just want to download an URL from a server and get the content, but I also want to cache the result, and also have a functionality to try again if the network was temporary unavailable. Note: Java already have more than one http client, and I recommend you use streams instead of string as output, but this is just an example focussed on the functionality enrichment so I keep it simple.
Placing all this logic into a single class would look like the quickest thing to do, but i’d not be the recommended way, you’ll have a big class doing too much, difficult to change and against more than one of the SOLID principles. I’ll show you how to develop it in steps using the decorator pattern. The code is Java (simplified to show the concept), but it’d be the same concept in any other OOP languages.
Implementation
Let’s define the download functionality in one interface DownloaderInterface
package io.github.elvisciotti.Downloader;import java.io.IOException;
import java.net.URL;public interface DownloaderInterface {
String getContent(URL url) throws IOException;
}
and provide an implementation using native libraries. Ignore the style, there are better ways to do this in Java with libraries, I just wanted to use native functions to keep things minimal and focus on the concept.
package io.github.elvisciotti.Downloader;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;public class NativeDownloader implements DownloaderInterface { @Override
public String getContent(URL url) throws IOException {
InputStreamReader isr = new InputStreamReader(url.openStream());
BufferedReader br = new BufferedReader(isr); StringBuffer content = new StringBuffer();
String inputLine;
while ((inputLine = br.readLine()) != null) {
content.append(inputLine);
}
br.close(); return content.toString();
}
}
Simple. This class does one thing, pure native downloading, nothing else. It’ll always stay the same and it’ll be easy to reuse in the future, and also test (given an URL, I expect the content).
Adding “cached downloading” functionality
We are adding a caching functionality separately. So this can be reused also for other components. There are better ways to do that if you are using Spring, but — again — this is an example.
Let’s define an interface for caching generic content.
package io.github.elvisciotti.Cache;public interface CacheInterface {
boolean exists(String key);
String get(String key);
void store(String key, String data);
}
And here one implementation (e.g. in memory) using HashMap:
package io.github.elvisciotti.Cache;import java.util.HashMap;public class MemoryCache implements CacheInterface {
private static HashMap<String, String> cache = new HashMap<>(); @Override
public boolean exists(String key) {
return cache.get(key) != null;
} @Override
public String get(String key) {
return cache.get(key);
} @Override
public void store(String key, String data) {
cache.put(key, data);
}
}
Our cached downloader can now be implemented decorating any downloader, and using the cache implementation.
Note that I could have injected directly the cache implementation, but then I’d have had to change the class and refactor the code to change the implementation to a different caching (e.g. on disk), violating more than one SOLID principle.
package io.github.elvisciotti.Downloader;import io.github.elvisciotti.Cache.CacheInterface;import java.io.*;
import java.net.URL;
import java.security.*;public class DownloaderCached implements DownloaderInterface {
private DownloaderInterface innerDownloader;
private CacheInterface cache; private static MessageDigest messageDigestSha1 = null;
static {
try {
messageDigestSha1 = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
} public DownloaderCached(DownloaderInterface innerDownloader, CacheInterface cache) {
this.innerDownloader = innerDownloader;
this.cache = cache;
} public String getContent(URL url) throws IOException {
String cacheId = new String(messageDigestSha1.digest(url.toString().getBytes()));
if (this.cache.exists(cacheId)) {
String cachedContent = this.cache.get(cacheId);
if (cachedContent.length() > 0) {
return cachedContent;
}
} String freshContent = innerDownloader.getContent(url);
this.cache.store(cacheId, freshContent); return freshContent;
}
}
In the next section we’ll add a further decoration. You can skip it if the concept is already clear.
Adding “try again” functionality
I now also want to have a functionality to re-attempt the download in case of temporary network failures. Here another decorator:
package io.github.elvisciotti.Downloader;import java.io.IOException;
import java.net.URL;public class DownloaderTryAgain implements DownloaderInterface {
private DownloaderInterface innerDownloader;
private int maxAttempts; public DownloaderTryAgain(DownloaderInterface innerDownloader, int maxAttempts) {
this.innerDownloader = innerDownloader;
this.maxAttempts = maxAttempts;
} public DownloaderTryAgain(DownloaderInterface innerDownloader) {
this(innerDownloader, 3);
} public String getContent(URL url) throws IOException {
StringBuilder errorMessage = new StringBuilder();
for (int i = 1; i <= maxAttempts; i++) {
try {
return this.innerDownloader.getContent(url);
} catch (IOException e) {
errorMessage.append("Failed to read URL at attempt #" + i + " attempts. " + e.getMessage() + ";");
}
} throw new IOException(errorMessage.toString());
}
}
Use and combine all the functionality
To summarize, we now have 3 classes implementing the DownloaderInterface (so performing a downloading). The first one (NativeDownloader) simply reads an URL from the network. The other two also do so, but needs another downloader inside as they “decorates” the behaviour.
- DownloaderCached (decorator): to store successful content into a storage by URL;
- DownloaderTryAgain (decorator): to re-attemp the download in case of failure;
Without changing any of those classes, I can now combine those functionalities, creating a downloader that simply download, or that caches on disk, or caches first in memory and then in the disk (for subsequent launches) and re-attempts downloads in case of failure without changing any of the classes internal implementation (and their tests).
CacheInterface cacheMemory = new MemoryCache();
// DiskCache is another CacheInterface implementation using the disk
CacheInterface cacheFile = new DiskCache("/tmp/downloader-");DownloaderInterface downloader = new NativeDownloader();
Once I’ve done this, I can now create all the behaviours by just constructing and passing what I want. So I can create 3 different downloader behaviour without coding anything.
var downloaderTryAgain = new DownloaderTryAgain(downloader);
var downloaderCachingOnDisk = new DownloaderCached(downloader, cacheFile);
var downloaderCachinginMemory = new DownloaderCached(downloader, cacheMemory);
Changing the behaviour is as simple as changing one line (or config in your framework) without changing nor recompiling anything else. This is good design also following the SOLID principles that applies on this context:
- Single responsability: no class implements two things, download and caching are declared in interfaces and implemented in different places;
- Open/Close: enforced by the decorator pattern that enforces to add behaviour by adding code;
- Interface segregation: note how caching is done in one interface, and not as part of the DownloaderCached. That allowed to add DiskCached downloader without making changes on that class;
- Dependency inversion: note how methods argument accept interfaces and call their functionalities (internal wrapped downloader definition or cache definition) without knowing what’s their actual implementation.
You have to understand this principles to work with huge codebases, it might seems it takes more time at first, but — unless you just code one-off prototypes or scripts — your productivity is based on changing (or adding on top of ) existing components in an efficient manner and minimising the bugs. If you don’t adopt those principles, you have no chance to do so most of the time.
Follow me for more and clap if useful.