Decorator Pattern, a personal favorite

Adding features without touching existing code; is it even possible?

At first glance, it might seem impractical, if not impossible. Yet, with the right design pattern, this becomes not only feasible but elegantly simple. Today, we’ll explore the Decorator Pattern, a structural Design Pattern that allows us to add functionality to a class without altering its code.

A Real-World Example

Recently, while working on a side project (one of those that I’ll eventually finish and share online!), I needed to download book cover images from a web server. I built a simple class leveraging the Open Library APIs to retrieve a cover image for a given book:

public final class OpenLibraryBookCoverLoader: BookCoverLoader {
    
    private let client: any HTTPClient
    
    public init(client: any HTTPClient) {
        self.client = client
    }
    
    public func loadCoverData(for book: Book) async throws -> Data {
        guard let url = URL(string: "https://covers.openlibrary.org/b/id/\(book.id)-L.jpg") else { 
            throw URLError(.badURL)
        }
        let request = URLRequest(url: url)
        let (data, _) = try await client.makeRequest(request)
        return data
    }
}

The Problem

I quickly realized that each time the library feature screen appeared, the images were re-downloaded from the server. Clearly, this was highly inefficient. My first thought was, “I should add caching!”

A naive solution might involve modifying the OpenLibraryBookCoverLoader class directly to incorporate caching, something like: 1

public final class OpenLibraryBookCoverLoader: BookCoverLoader {
    
    private let client: any HTTPClient
    private var cache = LRUCache<Book, Data>(maxCapacity: 25)

    public init(client: any HTTPClient) {
        self.client = client
    }
    
    public func loadCoverData(for book: Book) async throws -> Data {
        if let data = cache[book] { return data }
        guard let url = URL(string: "https://covers.openlibrary.org/b/id/\(book.id)-L.jpg") else { 
            throw URLError(.badURL)
        }
        let request = URLRequest(url: url)
        let (data, _) = try await client.makeRequest(request)
        cache[book] = data
        return data
    }
}

Why this is a bad idea

While this might seem like a straightforward and working solution, it’s problematic for several reasons:

  1. Ownership: We don’t always control the classes we work with. What if OpenLibraryBookCoverLoader was part of an external module, making it inaccessible for modification?
  2. Violating the Open/Closed Principle: According to the O in SOLID principles, software entities should be open for extension but closed for modification. By adding caching, we are altering the class, thus breaking this principle.
  3. Violating Single Responsibility Principle: The S in SOLID stands for Single Responsibility Principle. Our class should focus on a single responsibility, in this case, downloading data. Introducing caching means it now handles two different concerns, violating this principle.

Moreover, since OpenLibraryBookCoverLoader is a final class, we can’t subclass it to create a specialized version with caching.

We need to move the caching somewhere else, but where? How do we add caching without modifying the original code?

The Decorator Pattern

The Decorator Pattern is a unique structural pattern because it allows an object to simultaneously be of a certain type and have something of the same type. This pattern lets us wrap an existing object (the decoratee) to extend or alter its behavior, all without changing the underlying code.

classDiagram
    class Client {
        -component: Component
    }

    class Component {
        +doSomething()
    }

    class ConcreteComponent {
        +doSomething()
    }

    Client --> Component

    Component <|-- ConcreteComponent 

    class Decorator {
        -decoratee: Component
        +doSomething()
    }

    Component <|-- Decorator
    Component <-- Decorator

We can now implement our desired caching solution, creating a new Decorator class:

class BookCoverLoaderLRUCacheDecorator: BookCoverLoader {

    private let decoratee: BookCoverLoader
    private let cache: LRUCache<Book, Data>

    init(_ decoratee: BookCoverLoader, cacheMaxCapacity: Int) {
        self.decoratee = decoratee
        self.cache = LRUCache(maxCapacity: cacheMaxCapacity)
    }

    public func loadCoverData(for book: Book) async throws -> Data {
        if let data = cache[book] { return data }
        let data = try await decoratee.loadCoverData(for: book)
        cache[book] = data
        return data
    }
}

With our new BookCoverLoaderLRUCacheDecorator in place, adding caching becomes as simple as wrapping the original loader:

let httpClient = SomeHTTPClient()
let coverLoader = BookCoverLoaderLRUCacheDecorator(
    OpenLibraryBookCoverLoader(client: httpClient), // original BookCoverLoader
    cacheMaxCapacity: 25
)

The coverLoader object is still a valid BookCoverLoader, but now it also handles caching. Importantly, this decorator isn’t tied to any specific book cover loader; it can be used with any BookCoverLoader.

References

To dive deeper into design patterns, I highly recommend the classic book: Design Patterns


  1. I used LRUCache from my package APUtils↩︎