Decorator Design Pattern – Head First Approach
The Decorator Pattern is one of the key design patterns that helps in extending the behavior of objects in a flexible and reusable manner. In the Head First Design Patterns book, this pattern is introduced with an analogy to adding toppings to a beverage or customizing a coffee order. It provides a solution to extend an object's functionality without altering its structure, keeping the system open to extension but closed for modification.
Let's explore the decorator pattern, its definition, and practical applications with examples inspired by the Head First approach.
What is the Decorator Pattern?
Definition: The decorator pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
This pattern allows you to "wrap" objects with other objects that can add new functionality or modify behavior, without changing the core of the original object. It helps in creating a layered architecture where additional features can be added in a flexible and modular way.
Problem Scenario
Imagine you're running a coffee shop that offers various types of coffee with several customizable toppings like milk, soy, mocha, or whip. Each topping adds a specific cost to the final price. Without the decorator pattern, you would have to create a new class for every possible combination of coffee and toppings, which is inefficient and hard to maintain.
Instead, the decorator pattern allows you to start with a base coffee class and dynamically add toppings through decorators that enhance the coffee's behavior, like adding the cost of toppings or altering its description.
Using the Decorator Pattern
The decorator pattern makes it easy to extend a class's functionality at runtime, without modifying the core class or creating an explosion of subclasses for every combination of features.
Step 1: Create the Base Component
First, define a Beverage
class, which is the component that can be "decorated." This class will be the core type representing the various types of coffee.
public abstract class Beverage {
String description = "Unknown Beverage";
public String getDescription() {
return description;
}
public abstract double cost();
}
Step 2: Create Concrete Components
We then define concrete implementations of Beverage
, such as Espresso
and HouseBlend
, which represent different types of coffee.
public class Espresso extends Beverage {
public Espresso() {
description = "Espresso";
}
public double cost() {
return 1.99;
}
}
public class HouseBlend extends Beverage {
public HouseBlend() {
description = "House Blend Coffee";
}
public double cost() {
return 0.89;
}
}
Step 3: Create the Decorator Abstract Class
Next, we create an abstract decorator class CondimentDecorator
that extends Beverage
. The decorator must be interchangeable with Beverage
, so it extends the same class.
public abstract class CondimentDecorator extends Beverage {
public abstract String getDescription();
}
Step 4: Create Concrete Decorators
Now, we create concrete decorators like Mocha
, Soy
, Whip
, and Milk
that extend the CondimentDecorator
class. These decorators will dynamically enhance the behavior of the coffee.
public class Mocha extends CondimentDecorator {
Beverage beverage;
public Mocha(Beverage beverage) {
this.beverage = beverage;
}
public String getDescription() {
return beverage.getDescription() + ", Mocha";
}
public double cost() {
return 0.20 + beverage.cost();
}
}
public class Whip extends CondimentDecorator {
Beverage beverage;
public Whip(Beverage beverage) {
this.beverage = beverage;
}
public String getDescription() {
return beverage.getDescription() + ", Whip";
}
public double cost() {
return 0.30 + beverage.cost();
}
}
Step 5: Simulate the Coffee Shop
Finally, we simulate a coffee order where customers can add any number of condiments to their coffee.
public class StarbuzzCoffee {
public static void main(String args[]) {
Beverage beverage = new Espresso();
System.out.println(beverage.getDescription() + " $" + beverage.cost());
Beverage beverage2 = new HouseBlend();
beverage2 = new Mocha(beverage2); // Add mocha
beverage2 = new Whip(beverage2); // Add whip
System.out.println(beverage2.getDescription() + " $" + beverage2.cost());
}
}
Output:
Espresso $1.99
House Blend Coffee, Mocha, Whip $1.39
Bullet Points
Open for extension, closed for modification: You can easily add new behavior by wrapping the existing component in decorators, without modifying the original class.
Flexible design: This pattern avoids the complexity of a deep class hierarchy and instead offers a more dynamic and flexible design that allows new behaviors to be added at runtime.
Single Responsibility Principle: Each class handles one piece of functionality (e.g., the beverage, or the specific condiments).
When to Use the Decorator Pattern
When you need to add responsibilities to objects dynamically and transparently, without affecting other objects.
When extending a class using inheritance becomes impractical due to an explosion of subclasses.
When you want to add features or capabilities to an object, but still want the ability to add or remove them dynamically.
Conclusion
The decorator pattern offers a powerful way to add behavior to objects dynamically while keeping your code clean, flexible, and easily maintainable. As we’ve seen in the Head First Design Patterns approach, you can quickly wrap your core objects with decorators to extend their functionality without modifying their structure. This pattern is especially useful in scenarios like UI components, file streams, or even, as we've seen, coffee shops.
By leveraging the decorator pattern, you can avoid subclassing hell and create more maintainable and extendable designs.
Subscribe to my newsletter
Read articles from Alyaa Talaat directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Alyaa Talaat
Alyaa Talaat
As a continuous learner, I’m always exploring new technologies and best practices to enhance my skills in software development. I enjoy tackling complex coding challenges, whether it's optimizing performance, implementing new features, or debugging intricate issues.