Explore the Decorator Pattern in Java by modeling a coffee shop menu, demonstrating how to dynamically add responsibilities to coffee objects with flexibility and maintainability.
In this section, we will explore the Decorator Pattern by modeling a coffee shop menu. This pattern is particularly useful for adding responsibilities to objects dynamically and is a perfect fit for scenarios where objects need to be extended with new functionality without altering their structure.
The Decorator Pattern allows behavior to be added to individual objects, dynamically, without affecting the behavior of other objects from the same class. This pattern is often used to adhere to the Open/Closed Principle, which states that software entities should be open for extension but closed for modification.
Let’s consider a coffee shop where customers can order different types of coffee with various add-ons. We’ll use the Decorator Pattern to model this system, enabling us to add responsibilities like milk, sugar, and whipped cream to coffee objects.
Coffee
InterfaceWe’ll start by defining a Coffee
interface with methods to get the cost and description of the coffee.
public interface Coffee {
double getCost();
String getDescription();
}
Next, we implement basic coffee classes such as Espresso
and Decaf
, which will serve as the concrete components.
public class Espresso implements Coffee {
@Override
public double getCost() {
return 1.99;
}
@Override
public String getDescription() {
return "Espresso";
}
}
public class Decaf implements Coffee {
@Override
public double getCost() {
return 1.49;
}
@Override
public String getDescription() {
return "Decaf";
}
}
We’ll create decorator classes for add-ons like Milk
, Sugar
, and WhippedCream
. Each decorator will implement the Coffee
interface and hold a reference to a Coffee
object.
public abstract class CoffeeDecorator implements Coffee {
protected Coffee coffee;
public CoffeeDecorator(Coffee coffee) {
this.coffee = coffee;
}
}
public class Milk extends CoffeeDecorator {
public Milk(Coffee coffee) {
super(coffee);
}
@Override
public double getCost() {
return coffee.getCost() + 0.50;
}
@Override
public String getDescription() {
return coffee.getDescription() + ", Milk";
}
}
public class Sugar extends CoffeeDecorator {
public Sugar(Coffee coffee) {
super(coffee);
}
@Override
public double getCost() {
return coffee.getCost() + 0.20;
}
@Override
public String getDescription() {
return coffee.getDescription() + ", Sugar";
}
}
public class WhippedCream extends CoffeeDecorator {
public WhippedCream(Coffee coffee) {
super(coffee);
}
@Override
public double getCost() {
return coffee.getCost() + 0.70;
}
@Override
public String getDescription() {
return coffee.getDescription() + ", Whipped Cream";
}
}
Let’s see how we can wrap coffee objects with different decorators to create various combinations.
public class CoffeeShop {
public static void main(String[] args) {
Coffee espresso = new Espresso();
System.out.println(espresso.getDescription() + " $" + espresso.getCost());
Coffee espressoWithMilk = new Milk(espresso);
System.out.println(espressoWithMilk.getDescription() + " $" + espressoWithMilk.getCost());
Coffee decafWithSugarAndCream = new WhippedCream(new Sugar(new Decaf()));
System.out.println(decafWithSugarAndCream.getDescription() + " $" + decafWithSugarAndCream.getCost());
}
}
The Decorator Pattern provides flexibility in creating various coffee combinations at runtime. New add-ons can be introduced without modifying existing code, adhering to the Open/Closed Principle. For example, adding a new Caramel
decorator would not require changes to the existing classes.
Testing different decorator combinations involves ensuring that each combination correctly computes the cost and description. Unit tests can be written for each decorator to verify that they add the correct cost and description.
@Test
public void testEspressoWithMilkAndSugar() {
Coffee coffee = new Sugar(new Milk(new Espresso()));
assertEquals(2.69, coffee.getCost(), 0.01);
assertEquals("Espresso, Milk, Sugar", coffee.getDescription());
}
While the order of decorators generally does not affect the behavior in this example, it can be crucial in other contexts. For instance, if a Discount
decorator is introduced, its position in the chain could affect the final cost.
By using the Decorator Pattern, we promote code reusability and maintainability. Each decorator is responsible for a single responsibility, making the codebase easier to manage and extend.
This example can be extended by introducing size options or promotions. For instance, a SizeDecorator
could adjust the cost based on the size of the coffee, while a PromotionDecorator
might apply discounts.
The Decorator Pattern is a powerful tool for dynamically adding responsibilities to objects. It allows for flexible and maintainable code, making it an excellent choice for scenarios like our coffee shop example. By understanding and applying this pattern, developers can create systems that are both extensible and easy to maintain.