Decorator Design Pattern

MaverickMaverick
8 min read

The Decorator pattern is a design pattern that allows you to add additional behavior to an object without changing its underlying code. It achieves this by wrapping the original object with a decorator object that contains the additional behavior. The decorator object implements the same interface as the original object, allowing it to be used interchangeably.

Why Not Just Subclass?

You might be thinking, "Why not just use inheritance and subclassing?" While inheritance is a powerful tool, it can lead to a few issues when it comes to adding features:

  • Explosion of Subclasses: If you have many features that can be combined in various ways, you'll end up with a huge number of subclasses, each representing a unique combination. This quickly becomes unmanageable.

  • Static Behavior: Inheritance adds behaviors at compile time. If you need to add or remove features at runtime based on certain conditions, subclassing won't cut it.

  • Rigidity: Changing the base class can impact all its subclasses, making maintenance more difficult.

When to Use the Decorator Pattern

  • When you need to add responsibilities to individual objects dynamically and transparently (i.e., without affecting other objects).

  • When you want to be able to remove these responsibilities.

  • When extension by subclassing is impractical due to an explosion of subclasses.

Components of the Decorator Pattern:

The Decorator pattern elegantly solves these problems by wrapping objects within other objects. Here's a breakdown of its key components:

  1. Component: This is an interface or abstract class that defines the common operations for both the concrete components and the decorators. It's the base type that everything else adheres to.

  2. Concrete Component: These are the original objects to which you want to add responsibilities. They implement the Component interface.

  3. Decorator: This is an abstract class that also implements the Component interface. It holds a reference to a Component object. Its main purpose is to delegate operations to the wrapped component.

  4. Concrete Decorators: These are the actual "decorators" that add specific new behaviors. They extend the Decorator class and override the necessary methods to incorporate their added functionality before or after calling the wrapped component's method.

Implementation

Scenario 1: Before Using the Decorator Pattern (The Problem of Subclassing Hell)

Without the decorator pattern, adding features can lead to a "subclass explosion" or tightly coupled code. One common, but problematic, approach is to create a subclass for every possible combination of coffee and condiment.

  • Base Coffee interface (or abstract class)
interface Coffee {
    String getDescription();
    double getCost();
}
  • Basic coffee types
class SimpleEspresso implements Coffee {
    @Override
    public String getDescription() {
        return "Simple Espresso";
    }

    @Override
    public double getCost() {
        return 2.0;
    }
}
class SimpleLatte implements Coffee {
    @Override
    public String getDescription() {
        return "Simple Latte";
    }

    @Override
    public double getCost() {
        return 3.0;
    }
}
// Subclasses for combinations (This quickly gets out of hand!)
class MilkEspresso extends SimpleEspresso {
    @Override
    public String getDescription() {
        return super.getDescription() + ", Milk";
    }

    @Override
    public double getCost() {
        return super.getCost() + 0.5;
    }
}
class SugarLatte extends SimpleLatte {
    @Override
    public String getDescription() {
        return super.getDescription() + ", Sugar";
    }

    @Override
    public double getCost() {
        return super.getCost() + 0.2;
    }
}
class MilkSugarEspresso extends SimpleEspresso {
    // Oh boy, now we have to combine logic!
    // This starts to get messy if you want to add Caramel too!
    @Override
    public String getDescription() {
        return super.getDescription() + ", Milk, Sugar";
    }

    @Override
    public double getCost() {
        return super.getCost() + 0.5 + 0.2;
    }
}
  • Imagine classes for CaramelEspresso, MilkCaramelEspresso, SugarCaramelEspresso, MilkSugarCaramelEspresso, and the same for Latte! The number of classes explodes (2^N where N is number of condiments).
// ... Imagine classes for CaramelEspresso, MilkCaramelEspresso, SugarCaramelEspresso, MilkSugarCaramelEspresso,
// and the same for Latte! The number of classes explodes (2^N where N is number of condiments).

public class CoffeeShopBeforeDecorator {
    public static void main(String[] args) {
        System.out.println("--- Before Decorator Pattern ---");

        Coffee myEspresso = new SimpleEspresso();
        System.out.println(myEspresso.getDescription() + " - $" + myEspresso.getCost()); // Simple Espresso - $2.0

        Coffee myMilkEspresso = new MilkEspresso();
        System.out.println(myMilkEspresso.getDescription() + " - $" + myMilkEspresso.getCost()); // Simple Espresso, Milk - $2.5

        Coffee mySugarLatte = new SugarLatte();
        System.out.println(mySugarLatte.getDescription() + " - $" + mySugarLatte.getCost()); // Simple Latte, Sugar - $3.2

        Coffee myMilkSugarEspresso = new MilkSugarEspresso();
        System.out.println(myMilkSugarEspresso.getDescription() + " - $" + myMilkSugarEspresso.getCost()); // Simple Espresso, Milk, Sugar - $2.7
    }
}

Problems with this approach:

If we add a new condiment (e.g., Whipped Cream), we need to create N new subclasses for each existing combination, plus all new combinations. This is inflexible and leads to massive code duplication and maintenance headaches.

  1. Subclass Explosion: For every new condiment, the number of classes can double. This quickly becomes unmanageable.

  2. Rigidity: Adding a new condiment or changing how one condiment interacts requires modifying or creating many new classes.

  3. Code Duplication: Similar logic for adding costs and descriptions is repeated across many subclasses.

  4. Static Behavior: You can't add or remove condiments at runtime once the object is created.

Scenario 2: After Using the Decorator Pattern (The Solution)

Now, let's refactor our coffee shop using the Decorator pattern.

Components:

  1. Coffee (Component Interface): Defines the common interface for coffee and condiments.

  2. Espresso, Latte (Concrete Components): The basic coffee types.

  3. CoffeeDecorator (Abstract Decorator): An abstract class that implements Coffee and holds a Coffee object. This is the base for all specific condiments.

  4. Milk, Sugar, Caramel (Concrete Decorators): Specific condiments that extend CoffeeDecorator and add their own behavior.

// 1. Component Interface
interface Coffee {
    String getDescription();
    double getCost();
}
  • Concrete components
class Espresso implements Coffee {
    @Override
    public String getDescription() {
        return "Espresso";
    }

    @Override
    public double getCost() {
        return 2.0;
    }
}

class Latte implements Coffee {
    @Override
    public String getDescription() {
        return "Latte";
    }

    @Override
    public double getCost() {
        return 3.0;
    }
}
  • Abstract Decorator
abstract class CoffeeDecorator implements Coffee {
    protected Coffee decoratedCoffee; // Reference to the coffee being decorated

    public CoffeeDecorator(Coffee decoratedCoffee) {
        this.decoratedCoffee = decoratedCoffee;
    }

    // Decorators typically delegate basic operations but can add their own logic
    @Override
    public String getDescription() {
        return decoratedCoffee.getDescription();
    }

    @Override
    public double getCost() {
        return decoratedCoffee.getCost();
    }
}
  • Concrete Decorators (Condiments)
class Milk extends CoffeeDecorator {
    public Milk(Coffee decoratedCoffee) {
        super(decoratedCoffee);
    }

    @Override
    public String getDescription() {
        return super.getDescription() + ", Milk";
    }

    @Override
    public double getCost() {
        return super.getCost() + 0.5; // Add cost of milk
    }
}

class Sugar extends CoffeeDecorator {
    public Sugar(Coffee decoratedCoffee) {
        super(decoratedCoffee);
    }

    @Override
    public String getDescription() {
        return super.getDescription() + ", Sugar";
    }

    @Override
    public double getCost() {
        return super.getCost() + 0.2; // Add cost of sugar
    }
}

class Caramel extends CoffeeDecorator {
    public Caramel(Coffee decoratedCoffee) {
        super(decoratedCoffee);
    }

    @Override
    public String getDescription() {
        return super.getDescription() + ", Caramel";
    }

    @Override
    public double getCost() {
        return super.getCost() + 0.7; // Add cost of caramel
    }
}
  • Let’s go get the coffee
public class CoffeeShopAfterDecorator {
    public static void main(String[] args) {
        System.out.println("--- After Decorator Pattern ---");

        // Order 1: A simple Espresso
        Coffee myEspresso = new Espresso();
        System.out.println(myEspresso.getDescription() + " - $" + myEspresso.getCost());
        // Output: Espresso - $2.0

        // Order 2: A Latte with Milk
        Coffee myLatteWithMilk = new Milk(new Latte()); // Wrap Latte with Milk
        System.out.println(myLatteWithMilk.getDescription() + " - $" + myLatteWithMilk.getCost());
        // Output: Latte, Milk - $3.5

        // Order 3: An Espresso with Sugar and Milk
        Coffee myEspressoWithSugarAndMilk = new Milk(new Sugar(new Espresso())); // Espresso -> Sugar -> Milk
        System.out.println(myEspressoWithSugarAndMilk.getDescription() + " - $" + myEspressoWithSugarAndMilk.getCost());
        // Output: Espresso, Sugar, Milk - $2.7

        // Order 4: A Latte with Caramel, Sugar, and then Milk
        Coffee myComplexLatte = new Milk(new Sugar(new Caramel(new Latte()))); // Latte -> Caramel -> Sugar -> Milk
        System.out.println(myComplexLatte.getDescription() + " - $" + myComplexLatte.getCost());
        // Output: Latte, Caramel, Sugar, Milk - $4.4

        // New Feature: Adding Whipped Cream (Easy!)
        // Just create a new Concrete Decorator:
        class WhippedCream extends CoffeeDecorator {
            public WhippedCream(Coffee decoratedCoffee) {
                super(decoratedCoffee);
            }

            @Override
            public String getDescription() {
                return super.getDescription() + ", Whipped Cream";
            }

            @Override
            public double getCost() {
                return super.getCost() + 0.8;
            }
        }

        // Order 5: An Espresso with Caramel and Whipped Cream
        Coffee myNewCoffee = new WhippedCream(new Caramel(new Espresso()));
        System.out.println(myNewCoffee.getDescription() + " - $" + myNewCoffee.getCost());
        // Output: Espresso, Caramel, Whipped Cream - $3.5

        // The order of decorators matters for the description, but not for the cost in this simple case.
    }
}

Pros of the Decorator Pattern

  • Flexibility: You can add or remove responsibilities from objects dynamically at runtime.

  • Avoids Subclass Explosion: Instead of creating numerous subclasses for every combination of features, you can combine decorators in a flexible manner.

  • Open/Closed Principle: You can extend the functionality of an object without modifying its existing code. This aligns perfectly with the Open/Closed Principle (open for extension, closed for modification).

  • Maintainability: Changes to a specific decorator only affect that decorator, not the entire class hierarchy.

Cons of Decorator Pattern

  • Increased Complexity: Adds more classes and objects, potentially making the codebase harder to navigate.

  • Identity Issues: Decorated objects aren't strictly identical to the original, which can cause problems if identity checks are crucial.

  • Order Dependence: The sequence of applying decorators can sometimes matter, requiring careful management.

  • Configuration Overhead: Can lead to verbose, nested constructor calls for heavily decorated objects.

  • Debugging Challenges: Tracing execution through multiple layers of wrapped objects can complicate debugging.

Conclusion:

The Decorator pattern is a powerful tool in object-oriented programming that allows you to add additional behavior to an object without affecting the existing code. It's a flexible and reusable way to extend the behavior of an object without modifying its underlying structure. By using the Decorator pattern, you can create complex objects by combining simple objects with additional behavior, making your code more modular and maintainable.

0
Subscribe to my newsletter

Read articles from Maverick directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Maverick
Maverick