Codementor Events

Investing in Code Quality: The Decorator Pattern and Its Role in Implementing SOLID Principles

Published May 06, 2023
Investing in Code Quality: The Decorator Pattern and Its Role in Implementing SOLID Principles

Photo by Simon Hurry on Splash

Introduction

Design patterns are a valuable tool in a software engineer's toolkit, providing reusable solutions to common problems and helping us write clean, maintainable code. However, when it comes to learning and understanding design patterns, many developers find themselves confronted with dry, technical examples that lack real-world context. For instance, we often see examples that involve abstract concepts like "Shape" interfaces, "ShapeDecorator" classes, and "Circle" objects, but these examples can feel disconnected from the challenges we face in everyday development.
ShapeDiagram.png
In this article, I aim to bridge that gap by exploring design patterns through practical, real-life examples that you can apply to your projects. We'll focus on one design pattern that plays a key role in adhering to a set of guiding principles known as the SOLID principles.

Introduced by Robert C. Martin (often referred to as "Uncle Bob"), the SOLID principles are a set of five design principles that provide a clear framework for writing clean and well-structured code. These principles have become a cornerstone of object-oriented design and are widely adopted by developers across the globe. The SOLID principles are as follows:

  1. Single Responsibility Principle (SRP): A class should have only one reason to change, meaning it should have only one responsibility.
  2. Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
  3. Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types, meaning objects of derived classes should be able to replace objects of the base class without affecting correctness.
  4. Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. In other words, it's better to have many small, specific interfaces rather than one large, general-purpose interface.
  5. Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules; both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.

Adhering to the SOLID principles leads to code that is easier to understand, modify, and extend. It helps prevent common design issues such as tight coupling, code rigidity, and code fragility.

Getting Started with the Decorator Pattern

The decorator pattern is a powerful design pattern that allows you to extend the behavior of an object dynamically at runtime without modifying its internal structure. To get started with the decorator pattern, it's essential to identify components in your system that have broad and fundamental functionality, such as HTTP communication or data persistence. These components are ideal candidates for decoration because they provide a wide range of opportunities for adding new features and behaviors.

To implement the decorator pattern effectively, begin by defining an interface or protocol that represents the basic functionality of the component. This interface should be kept simple and focused, typically containing a single method with minimal necessary parameters. As you create concrete implementations of the interface, you can add more specific functionality as needed.

By following these steps, you'll not only be applying the decorator pattern but also adhering to the SOLID principles of object-oriented design:

  1. Single Responsibility Principle (SRP): Each decorator is responsible for a single aspect of behavior, ensuring that each class has a single reason to change.

    Example: An HTTPClient class handles basic HTTP requests, while a separate LoggingHTTPClientDecorator adds logging functionality.

  2. Open/Closed Principle (OCP): The component is open for extension through decorators but closed for modification, allowing you to add new behaviors without changing existing code.

    Example: An AuthorizationHTTPClientDecorator can be added to handle authorization headers without modifying the original HTTPClient.

  3. Liskov Substitution Principle (LSP): Decorators and the original component are interchangeable because they adhere to the same interface.

    Example: A CachingHTTPClientDecorator can be substituted for the base HTTPClient without affecting the client code.

  4. Interface Segregation Principle (ISP): The interface for the component is kept simple and focused, avoiding the need for clients to depend on methods they don't use.

    Example: The HTTPClient interface has a single sendRequest method, avoiding unnecessary complexity.

  5. Dependency Inversion Principle (DIP): High-level modules depend on abstractions (interfaces) rather than concrete implementations, allowing decorators to be easily introduced.

    Example: Client code depends on the HTTPClient interface, allowing the use of different decorators (e.g., RetryHTTPClientDecoratorCompressionHTTPClientDecorator) without changing the client code.

By adhering to these principles, you can create flexible and maintainable software that is easy to extend with new features and behaviors.

The decorator pattern is a versatile and powerful design pattern that allows developers to dynamically extend objects' behavior at runtime. Like all design patterns, it has its own set of advantages and limitations.

Sample Code:

struct Request {
    let url: URL
    // Additional properties such as HTTP method, headers, and body can be added here
}

struct Response {
    let statusCode: Int
    let data: Data
    // Additional properties such as headers can be added here
}

protocol HTTPClient {
    func sendRequest(_ request: Request) async throws -> Response
}
class LoggingHTTPClientDecorator: HTTPClient {
    private let wrappedClient: HTTPClient
    
    init(wrappedClient: HTTPClient) {
        self.wrappedClient = wrappedClient
    }
    
    func sendRequest(_ request: Request) async throws -> Response {
        // Log the request
        print("Sending request to URL: \\(request.url)")
        
        do {
            // Call the wrapped client's sendRequest method
            let response = try await wrappedClient.sendRequest(request)
            // Log the response
            print("Received response with status code: \\(response.statusCode)")
            return response
        } catch {
            // Log the error
            print("Request failed with error: \\(error)")
            throw error
        }
    }
}
class CachingHTTPClientDecorator: HTTPClient {
    private let wrappedClient: HTTPClient
    private var cache: [URL: Response] = [:]
    
    init(wrappedClient: HTTPClient) {
        self.wrappedClient = wrappedClient
    }
    
    func sendRequest(_ request: Request) -> Response {
        // Check if the response is cached
        if let cachedResponse = cache[request.url] {
            // Return the cached response
            return cachedResponse
        }
        
        // Call the wrapped client's sendRequest method
        let response = try await wrappedClient.sendRequest(request)
        
        // Cache the response
        self?.cache[request.url] = response
        
        // Forward the result
        return response
    }
}
// Example usage
let baseClient: HTTPClient = URLSessionHTTPClient()
let loggingClient = LoggingHTTPClientDecorator(wrappedClient: baseClient)
let cachingClient = CachingHTTPClientDecorator(wrappedClient: loggingClient)

let request = Request(url: URL(string: "<https://example.com>")!)
let response = try await cachingClient.sendRequest(request)

Advantages

  1. Flexibility and Extensibility: One of the key advantages of the decorator pattern is its ability to add new behaviors or modify existing ones without altering the underlying object's structure. This flexibility allows developers to extend functionality on-the-fly, making it easier to adapt to changing requirements.
  2. Adherence to the Open/Closed Principle: The decorator pattern adheres to the Open/Closed Principle, which states that software entities should be open for extension but closed for modification. By using decorators, we can introduce new features without modifying the original classes or interfaces.
  3. Modular and Reusable: Decorators are modular and can be reused across different components. This modularity promotes code reusability and reduces code duplication, leading to cleaner and more maintainable code.
  4. Composable: Decorators can be composed in various combinations to achieve different behaviors. This composability allows developers to mix and match decorators to create complex functionality without introducing code bloat.

Limitations

  1. Increased Complexity: While decorators provide flexibility, they can also introduce complexity, especially when many decorators are in the system. Developers need to be mindful of the order in which decorators are applied and how they interact with each other.
  2. Overhead and Performance Impact: Each decorator introduces an additional layer of indirection, which can lead to performance overhead. This impact may be negligible in most cases, but it's important to consider the potential performance implications in performance-critical scenarios.
  3. Confusion with Inheritance: The decorator pattern can sometimes be confused with inheritance, as both involve extending behavior. However, inheritance is a static relationship, while decoration is dynamic. Developers should carefully choose between the two based on the specific use case.
  4. Limited Applicability: The decorator pattern is not always the best solution for every scenario. For example, when behavior changes require modifications to the object's state or internal data, the decorator pattern may not be suitable.

Conclusion

To wrap things up, the decorator pattern is like a secret ingredient that helps us write code that's neat, organized, and easy to update. It's all about adding new features to our code without making a mess! By using the decorator pattern along with the tried-and-true SOLID principles, we can create software that's ready to grow and adapt to new challenges. So go ahead and give it a try—let the decorator pattern and SOLID principles be your trusty companions on your coding adventures!

Discover and read more posts from Adrian Bilescu
get started