Decorator Pattern in Simple Terms

Discover how the Decorator Pattern enhances functionality without altering existing code, making it ideal for adding features like logging to third-party libraries seamlessly.

TL;DR #

The Decorator Pattern is a wrapper. For example, when you need to add a logger to a third-party library but can’t modify its source code, you create a wrapper to extend its functionality.

Table of Contents #

Decorator Pattern #

The Decorator Pattern is a way to add new features to an object without changing its existing code. It works by wrapping the object with additional functionality, making it easy to extend and reuse.

Real Life Example #

Imagine you’re using a third-party network library to make HTTP requests. You want to add logging to each request, but you cannot modify the third-party code. The Decorator Pattern helps in this case by wrapping the network request with a new object that adds logging functionality.

Without Decorator (Hard-Coded Logging) #

In this approach, you mix logging directly into your network request, which makes the code harder to maintain and limits flexibility.

import ThirdPartyNetworkLibrary

class NetworkRequest {
    let networkClient = ThirdPartyNetworkClient()

    func fetchData(url: String) {
        print("Logging: Fetching data from \(url)")
        networkClient.fetchData(url: url) { result in
            switch result {
            case .success(let data):
                print("Data fetched: \(data)")
            case .failure(let error):
                print("Error: \(error)")
            }
        }
    }
}

With Decorator: Adding Logging Dynamically #

Instead of modifying the third-party network client, you use the decorator pattern to add logging. This allows you to keep the original functionality untouched and add extra behavior when needed.

import ThirdPartyNetworkLibrary

protocol NetworkRequestProtocol {
    func fetchData(url: String, completion: @escaping (Result<Data, Error>) -> Void)
}

class ThirdPartyNetworkRequest: NetworkRequestProtocol {
    let networkClient = ThirdPartyNetworkClient()

    func fetchData(url: String, completion: @escaping (Result<Data, Error>) -> Void) {
        networkClient.fetchData(url: url, completion: completion)
    }
}

class NetworkRequestWithLogging: NetworkRequestProtocol {
    private var networkRequest: NetworkRequestProtocol

    init(networkRequest: NetworkRequestProtocol) {
        self.networkRequest = networkRequest
    }

    func fetchData(url: String, completion: @escaping (Result<Data, Error>) -> Void) {
        print("Logging: Fetching data from \(url)")
        networkRequest.fetchData(url: url) { result in
            switch result {
            case .success(let data):
                print("Logging: Data fetched: \(data)")
            case .failure(let error):
                print("Logging: Error: \(error)")
            }
            completion(result)
        }
    }
}

let networkRequest = ThirdPartyNetworkRequest()
let networkRequestWithLogging = NetworkRequestWithLogging(networkRequest: networkRequest)

networkRequestWithLogging.fetchData(url: "https://example.com") { result in
    // Handle the result
}

Advantages of the Decorator Pattern #

  1. No Modification of Third-Party Code: You can add features (like logging) without changing the third-party library, making it easier to update or replace later.

  2. Extensibility: You can add more decorators for other functionality (e.g., retry logic, caching) without altering existing code. Each decorator can be applied independently.

  3. Cleaner Code: The core logic of your network requests remains simple and focused. Extra features like logging are handled separately, keeping the main class clean and maintainable.

  4. Flexible and Composable: You can stack multiple decorators to combine behaviors (e.g., logging + retry), giving you flexibility to adjust functionality as needed.