Skip to main content

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 and Pizza <|.. ToppingDecorator : implements show that both SimplePizza and ToppingDecorator implement the Pizza interface.
  • ToppingDecorator <|-- CheeseTopping : extends and ToppingDecorator <|-- TomatoTopping : extends indicate that CheeseTopping and TomatoTopping extend ToppingDecorator.
  • ToppingDecorator o-- Pizza : - has a represents a composition relationship, meaning that ToppingDecorator has a reference to a Pizza object.
  • The class Pizza, class SimplePizza, class ToppingDecorator, class CheeseTopping, and class TomatoTopping blocks define the classes and interfaces with their methods. The <<interface>> tag denotes that Pizza 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.

TypeScript Course Instructor Image
TypeScript Course Instructor Image

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 DETAILS

What 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.

YouTube @cloudaffle