Skip to content

Design Patterns: Decorator

Published: at 01:50 PM

Introduction

Design patterns help make our codebase cleaner, maintainable, and easy to understand. In this article, we will dive into the Decorator design pattern, which helps alter an object’s behavior without modifying existing classes.

Table of contents

Open Table of contents

Definition

The Decorator is a structural design pattern that allows you to add extra functionality to an object without changing its structure. This enables you to wrap an existing object inside a special wrapper that contains the new behavior.

The Decorator pattern follows the SRP (Single Responsibility Principle) as it allows functionality to be divided among different classes, each with a unique area of concern. At the same time, it adheres to the OCP (Open/Closed Principle), which states that a class should be open for extension but closed for modification, as the decorator doesn’t change existing classes but adds new behavior using a wrapper class.

The Decorator design pattern allows adding extra functionality dynamically at runtime. The pattern suggests that the object may execute additional logic before or after forwarding a request to the original object.

What kinds of problems can the Decorator design pattern solve?

  1. Adding extra functionallity dynamically at run-time. Let’s imagine a situation where an object should alter its behavior based on different factors that are not known at compile time. How can we achieve this? We could put all the code inside the object and use if/else or switch statements to handle them.

    If the object has many different behaviors, and all of these behaviors are contained within the object, it will be difficult to maintain this object. Moreover, this approach violates the SRP and OCP principles.

  2. Avoiding Class Explosion. Multiple combinations of behaviors can be achieved through inheritance, but this often leads to the creation of numerous subclasses, making the system harder to maintain and scale. The Decorator pattern avoids this by composing behaviors at runtime.

    When using subclassing, different subclasses extend a class in various ways. However, these extensions are fixed at compile-time and cannot be modified at runtime.

  3. Reusing Functionality. Common functionality can be reused across different decorators instead of being duplicated multiple times.

What solution does the Decorator design pattern describe?

  1. Implement the interface of the extended object and forward all requests to it.
  2. Implement new logic to be performed before or after forwarding a request to the original object.

UML Diagram

UML Diagram

The abstract Decorator class holds a reference to the decorated object and forwards all requests to it. This ensures that the Decorator remains transparent to the clients of the Component.

Subclasses such as ConcreteDecorator1 and ConcreteDecorator2 add specific behavior to the Component, either before or after forwarding a request.

Real World Example

The Flare in-app purchase framework uses the decorator pattern to cache retrieved products if required by the configuration, and another one to sort the products. Let’s take a look at how it works behind the scenes. I will skip some implementation details to focus only on the use of the decorator pattern.

The Flare framework defines an interface that describes a typical product provider:

/// A type that is responsible for retrieving StoreKit products.
protocol IProductProvider {
    /// Retrieves localized information from the App Store about a specified list of products.
    func fetch(productIDs: some Collection<String>, requestID: String, completion: @escaping ProductsHandler)

    /// Retrieves localized information from the App Store about a specified list of products.
    @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
    func fetch(productIDs: some Collection<String>) async throws -> [StoreProduct]
}

The ProductProvider object conforms to this protocol and implements methods to retrieve the requested products by their IDs.

final class ProductProvider: NSObject, IProductProvider {
    func fetch(productIDs: some Collection<String>, requestID: String, completion: @escaping ProductsHandler) {
        // implementation
    }

    @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
    func fetch(productIDs: some Collection<String>) async throws -> [StoreProduct] {
        // implementation
    }
}

The ProductProvider object only retrieves products and doesn’t perform any specific operations, such as sorting or caching them, as described above. This is exactly where decorators come in.

Since, the retrieved products should be sorted and cached, the Flare framework uses two decorator objects SortingProductsProviderDecorator and CachingProductsProviderDecorator. Let’s take a closer look at their interfaces:

/// Type that caches retrieved products.
protocol ICachingProductsProviderDecorator: IProductProvider {}
/// Type that sorts retrieved products.
protocol ISortingProductsProviderDecorator: IProductProvider {}

Both protocols inherit from the base IProductProvider protocol, which means that objects conforming to these protocols can be used in the same places as the original object.

Next, two objects implement these interfaces to provide additional functionality according to their names. Each of these objects must hold a reference to the base IProductProvider object.

Some technical details unrelated to the pattern have been omitted in this piece of code to simplify understanding.

/// `CachingProductsProviderDecorator` is a decorator class that adds caching functionality to an `IProductProvider`.
final class CachingProductsProviderDecorator: ICachingProductsProviderDecorator {
    /// Atomic property for thread-safe access to the cache dictionary.
    @Atomic
    private var cache: [String: StoreProduct] = [:]

    /// Creates a `CachingProductsProviderDecorator`instance.
    init(productProvider: IProductProvider, configurationProvider: IConfigurationProvider) {
        self.productProvider = productProvider
        self.configurationProvider = configurationProvider
    }

    // MARK: IProductProvider

    func fetch(productIDs: some Collection<String>, requestID: String, completion: @escaping ProductsHandler) {
        let cachedProducts = cachedProducts(ids: productIDs)
        let missingProducts = Set(productIDs).subtracting(cachedProducts.keys)

        if missingProducts.isEmpty {
            completion(.success(Array(cachedProducts.values)))
        } else {
            productProvider.fetch(productIDs: missingProducts) { 
                // Some logic for storing retrieved products.
             }
        }
    }

    @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
    func fetch(productIDs: some Collection<String>) async throws -> [StoreProduct] {
        let cachedProducts = cachedProducts(ids: productIDs)
        let missingProducts = Set(productIDs).subtracting(cachedProducts.keys)

        if missingProducts.isEmpty {
            return Array(cachedProducts.values)
        } else {
            return try await productProvider.fetch(productIDs: missingProducts)
        }
    }

    // MARK: Private

    /// Caches the provided array of products.
    private func cache(products: [StoreProduct]) {
        products.forEach { _cache.wrappedValue[$0.productIdentifier] = $0 }
    }

    /// Retrieves cached products for the given set of product IDs.
    private func cachedProducts(ids: some Collection<String>) -> [String: StoreProduct] {
        let cachedProducts = _cache.wrappedValue.filter { ids.contains($0.key) }
        return cachedProducts
    }
}

There is an implementation of a sorting decorator. This decorator retrieves a collection of products and sorts them in a specific order.

// MARK: - SortingProductsProviderDecorator

final class SortingProductsProviderDecorator: ISortingProductsProviderDecorator {
    // MARK: Properties

    private let productProvider: IProductProvider

    // MARK: Initialization

    init(productProvider: IProductProvider) {
        self.productProvider = productProvider
    }

    // MARK: ISortingProductsProviderDecorator

    func fetch(
        productIDs: some Collection<String>,
        requestID: String,
        completion: @escaping ProductsHandler
    ) {
        productProvider.fetch(productIDs: productIDs, requestID: requestID) { [weak self] result in
            guard let self = self else { return }

            switch result {
            case let .success(products):
                let sortedProducts = self.sort(productIDs: productIDs, products: products)
                completion(.success(sortedProducts))
            case let .failure(error):
                completion(.failure(error))
            }
        }
    }

    @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
    func fetch(productIDs: some Collection<String>) async throws -> [StoreProduct] {
        let products = try await productProvider.fetch(productIDs: productIDs)
        return sort(productIDs: productIDs, products: products)
    }

    // MARK: Private

    private func sort(productIDs: some Collection<String>, products: [StoreProduct]) -> [StoreProduct] {
        // The implementation of the sorting algorithm.
    }
}

Currently, all of these can be combined in the following way:

let productProvider = ProductProvider()
let sortingDecorator = SortingProductsProviderDecorator(productProvider: productProvider)
let cachingDecorator = CachingProductsProviderDecorator(productProvider: sortingDecorator, configuration: /* some configuration */)

The cachingDecorator can sort and cache products. This way, we can create different combinations and add new functionality without modifying existing ones.

If you’re interested in more details, feel free to check the repository.

Summary

The Decorator design pattern allows adding new functionality to objects dynamically without altering their structure, promoting flexibility. By wrapping objects in independent decorators, it simplifies maintenance and enables scalable solutions compared to subclassing.

Thanks for reading

If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.


avatar

Nikita Vasilev

A software engineer with over 7 years of experience in the industry. Writes this blog and builds open source frameworks.


Previous Post
Design Patterns: Strategy
Next Post
How the UIKit Layout Engine Works Under the Hood