Real World Implementation Of The Decorator Pattern
Let's say we're working with a Pizza ordering application where customers can order a base pizza and then add toppings as they wish. The price of the pizza depends on the toppings added. The Decorator pattern can be a perfect fit for this situation.
Here's what the diagram represents:
Pizza <|.. SimplePizza : implements
andPizza <|.. ToppingDecorator : implements
show that bothSimplePizza
andToppingDecorator
implement thePizza
interface.ToppingDecorator <|-- CheeseTopping : extends
andToppingDecorator <|-- TomatoTopping : extends
indicate thatCheeseTopping
andTomatoTopping
extendToppingDecorator
.ToppingDecorator o-- Pizza : - has a
represents a composition relationship, meaning thatToppingDecorator
has a reference to aPizza
object.- The
class Pizza
,class SimplePizza
,class ToppingDecorator
,class CheeseTopping
, andclass TomatoTopping
blocks define the classes and interfaces with their methods. The<<interface>>
tag denotes thatPizza
is an interface. - The
+
and-
before the method and attribute names represent public and private access modifiers, respectively.
Real World Implementation
Firstly, we define an interface for the pizza:
interface Pizza {
getCost(): number;
getDescription(): string;
}
Next, we'll implement a SimplePizza class which implements the Pizza interface:
class SimplePizza implements Pizza {
getCost() {
return 10; // base cost of a plain pizza
}
getDescription() {
return "Plain pizza";
}
}
The Decorator class would also implement the Pizza interface and hold a reference to a Pizza object that it decorates:
abstract class ToppingDecorator implements Pizza {
protected pizza: Pizza;
constructor(pizza: Pizza) {
this.pizza = pizza;
}
getCost() {
return this.pizza.getCost();
}
getDescription() {
return this.pizza.getDescription();
}
}
Now we can create specific ToppingDecorator subclasses:
class CheeseTopping extends ToppingDecorator {
getCost() {
return super.getCost() + 2; // adding cost of Cheese
}
getDescription() {
return super.getDescription() + ", Cheese";
}
}
class TomatoTopping extends ToppingDecorator {
getCost() {
return super.getCost() + 1; // adding cost of Tomato
}
getDescription() {
return super.getDescription() + ", Tomato";
}
}
Now, let's use our decorators:
let pizza: Pizza = new SimplePizza();
pizza = new CheeseTopping(pizza);
pizza = new TomatoTopping(pizza);
console.log(`Cost of pizza: ${pizza.getCost()}`);
console.log(`Description: ${pizza.getDescription()}`);
In this example, the Decorator pattern allows us to dynamically add as many toppings to the pizza as we want. Each decorator adds its own cost and description to the pizza.
Advantages Of The Decorator Pattern
The Decorator pattern offers several advantages, particularly when it comes to extending or modifying object functionality in a way that's easy to manage and scale.
Enhanced Flexibility
Decorators can provide greater flexibility than inheritance because they can be used to modify behavior dynamically at runtime. This is in contrast to inheritance, where behavior is static and defined at compile time.
let pizza: Pizza = new SimplePizza();
pizza = new CheeseTopping(pizza); // adding cheese topping
pizza = new TomatoTopping(pizza); // adding tomato topping
In the above snippet, we're dynamically adding toppings to the pizza. The toppings can be changed at any point during runtime.
Promotion of Single Responsibility Principle
Each decorator has its own distinct responsibility. This is more maintainable than having one class handle multiple variations.
class CheeseTopping extends ToppingDecorator {
getCost() {
return super.getCost() + 2; // handling cost of Cheese
}
getDescription() {
return super.getDescription() + ", Cheese"; // handling description of Cheese
}
}
class TomatoTopping extends ToppingDecorator {
getCost() {
return super.getCost() + 1; // handling cost of Tomato
}
getDescription() {
return super.getDescription() + ", Tomato"; // handling description of Tomato
}
}
In the above snippet, CheeseTopping
and TomatoTopping
each handle their
own cost and description. Each class has a single responsibility, making the
code cleaner and more maintainable.
Open for Extension but Closed for Modification
The decorator pattern adheres to the Open/Closed Principle. Once a class is written, it is closed for modification. However, its behavior can be extended using a decorator without modifying its source code.
class SimplePizza implements Pizza {
getCost() {
return 10; // base cost of a plain pizza
}
getDescription() {
return "Plain pizza"; // base description of a plain pizza
}
}
In the above snippet, SimplePizza
class is closed for modification. If you
want to add more functionality (like toppings), you would not
modify SimplePizza
. Instead, you would create a new decorator that adds the
required functionality.
Reduced Complexity
Decorators can lead to simpler code by allowing you to divide a complex class into several smaller, more specific classes.
class CheeseTopping extends ToppingDecorator {
// ...
}
class TomatoTopping extends ToppingDecorator {
// ...
}
In the above snippet, the code is simpler and cleaner because the cheese and tomato toppings are each handled by separate classes rather than one complex class managing all topping variations.
Time To Transition From JavaScript To TypeScript
Level Up Your TypeScript And Object Oriented Programming Skills. The only complete TypeScript course on the marketplace you building TypeScript apps like a PRO.
SEE COURSE DETAILSWhat Can You Do Next 🙏😊
If you liked the article, consider subscribing to Cloudaffle, my YouTube Channel, where I keep posting in-depth tutorials and all edutainment stuff for software developers.