Mastering Design Patterns — 09: A Comprehensive Guide to the Decorator Design Pattern

In this article, I will tell you everything you need to know about the Decorator Pattern, including what it is, what it does, and how to use it.

Andrea Gernone 🫀🥷
8 min readMar 1, 2023

Hello guys! 😎

Welcome back to my gang of four series!

Following up with the Structural Design Patterns, today we’re going to see something about the Decorator Design Pattern!
We will examine its definition, structure, implementation, and usage in real-world situations.

The Decorator Pattern wandering in a simulation forest 🌳
The Decorator Pattern wandering in a simulation forest 🌳

Definition

The Decorator Design Pattern is a structural design pattern that enables behavior to be added to an individual object statically or dynamically without affecting other objects in the same class. It is helpful when we need to add new functionalities to an object but not to the complete class.

Structure

The Decorator Design Pattern consists of four main components:

  • Component: The basic class or interface of the objects that can be decorated.
  • The concrete class that implements the Component and to which the decorator adds functionality is referred to as the concrete component.
  • The abstract class known as Decorator adds functionality to the Concrete Component.
  • Concrete Decorator: The concrete class that adds particular functionality to the Component.

Implementation

Using TypeScript, let’s implement the Decorator Design Pattern. In this example, we will add discount functionality to a basic product class.

// Component
interface Product {
getPrice(): number;
}

// Concrete Component
class BasicProduct implements Product {
getPrice() {
return 100;
}
}

// Decorator
abstract class ProductDecorator implements Product {
protected product: Product;

constructor(product: Product) {
this.product = product;
}

abstract getPrice(): number;
}

// Concrete Decorator
class DiscountedProduct extends ProductDecorator {
getPrice() {
const price = this.product.getPrice() * 0.8;
return price;
}
}

// Usage
const basicProduct = new BasicProduct();
const discountedProduct = new DiscountedProduct(basicProduct);

console.log(`Basic product price: ${basicProduct.getPrice()}`);
console.log(`Discounted product price: ${discountedProduct.getPrice()}`);

In this example, we have a simple product class that implements the Product interface. The ProductDecorator abstract class then implements the same interface and adds the decorator functionality. Finally, a DiscountedProduct class extends the ProductDecorator and adds discount functionality.

Real-World Scenarios

Let’s take a peek at some real-world applications of the Decorator Design Pattern.

User Permissions

// Component interface
interface User {
getPermissions(): string[];
}

// Concrete Component
class BasicUser implements User {
private permissions: string[];

constructor(permissions: string[]) {
this.permissions = permissions;
}

// Method implementation
getPermissions() {
return this.permissions;
}
}

// Decorator abstract class
abstract class UserDecorator implements User {
protected user: User;

constructor(user: User) {
this.user = user;
}

// Method declaration (no implementation)
abstract getPermissions(): string[];
}

// Concrete Decorator
class AdminUser extends UserDecorator {
// Method implementation
getPermissions() {
// Call the getPermissions method on the wrapped object
const permissions = this.user.getPermissions();
// Add admin permissions to the existing permissions
permissions.push('manage users');
// Return the updated permissions
return permissions;
}
}

// Usage
// Create an instance of the concrete component
const basicUser = new BasicUser(['read', 'write']);
// Wrap the concrete component with a decorator
const adminUser = new AdminUser(basicUser);

// Output the permissions of both objects
console.log(`Basic user permissions: ${basicUser.getPermissions()}`);
console.log(`Admin user permissions: ${adminUser.getPermissions()}`);

In this example, we first define the component interface User which declares the method getPermissions().
We then define the concrete component BasicUser which implements this method and stores the permissions in a private field.

Next, we define the decorator abstract class UserDecorator which implements the same User interface and has a reference to a User object. The getPermissions() method is declared but not implemented, as this is left to the concrete decorators.

Finally, we define the concrete decorator AdminUser which extends UserDecorator and implements the getPermissions() method.
In this implementation, we first call the getPermissions() method on the wrapped object, then add the admin permissions to the resulting array of permissions, and return the updated array.

In the usage section, we create an instance of the concrete component basicUser, which has the permissions [‘read’, ‘write’]. We then wrap this object with the AdminUser decorator, which adds the admin permissions [‘manage users’] to the permissions array.

When we call the getPermissions() method on both objects and output the results, we can see that the basicUser object has only the original permissions [‘read’, ‘write’], while the adminUser object has the original permissions plus the admin permissions [‘read’, ‘write’, ‘manage users’].

Logging

// Component interface
interface Logger {
log(message: string): void;
}

// Concrete Component
class ConsoleLogger implements Logger {
// Method implementation
log(message: string) {
console.log(`[CONSOLE LOG]: ${message}`);
}
}

// Decorator abstract class
abstract class LoggerDecorator implements Logger {
protected logger: Logger;

constructor(logger: Logger) {
this.logger = logger;
}

// Method implementation
log(message: string) {
// Call the log method on the wrapped object
this.logger.log(message);
}
}

// Concrete Decorator
class FileLogger extends LoggerDecorator {
// Method implementation
log(message: string) {
// Call the log method on the wrapped object
super.log(message);
// Append the message to a file
console.log(`[FILE LOG]: ${message}`);
}
}

// Usage
// Create an instance of the concrete component
const consoleLogger = new ConsoleLogger();
// Wrap the concrete component with a decorator
const fileLogger = new FileLogger(consoleLogger);

// Output logs to both console and file
fileLogger.log('This message should be logged to both console and file.');

In this example, we first define the component interface Logger which declares the method log(). We then define the concrete component ConsoleLogger which implements this method and logs the message to the console.

Next, we define the decorator abstract class LoggerDecorator which implements the same Logger interface and has a reference to a Logger object. The log() method is implemented to simply call the log() method on the wrapped object, as this is left to the concrete decorators.

Finally, we define the concrete decorator FileLogger which extends LoggerDecorator and overrides the log() method to add the file logging functionality. In this implementation, we first call the log() method on the wrapped object using super.log(), then append the message to a file.

In the usage section, we create an instance of the concrete component consoleLogger, which logs messages to the console. We then wrap this object with the FileLogger decorator, which adds the file logging functionality to the log() method.

When we call the log() method on the fileLogger object and pass it a message, the message is logged to both the console and the file.

Validation Data

// Component interface
interface Validator {
validate(): boolean;
}

// Concrete Component
class FormValidator implements Validator {
private fields: string[];

constructor(fields: string[]) {
this.fields = fields;
}

// Method implementation
validate() {
// Check that all fields are non-empty
return this.fields.every((field) => field.trim() !== '');
}
}

// Decorator abstract class
abstract class ValidatorDecorator implements Validator {
protected validator: Validator;

constructor(validator: Validator) {
this.validator = validator;
}

// Method implementation
validate() {
// Call the validate method on the wrapped object
return this.validator.validate();
}
}

// Concrete Decorator
class RequiredFieldValidator extends ValidatorDecorator {
private requiredFields: string[];

constructor(validator: Validator, requiredFields: string[]) {
super(validator);
this.requiredFields = requiredFields;
}

// Method implementation
validate() {
// Call the validate method on the wrapped object
const isValid = super.validate();
// Check that all required fields are present
const hasAllRequiredFields = this.requiredFields.every(
(field) => this.validator.hasOwnProperty(field)
);
return isValid && hasAllRequiredFields;
}
}

// Usage
// Create an instance of the concrete component
const formValidator = new FormValidator(['firstName', 'lastName']);
// Wrap the concrete component with a decorator
const requiredFieldValidator = new RequiredFieldValidator(
formValidator,
['firstName', 'lastName']
);

// Validate the form
const isValid = requiredFieldValidator.validate();
console.log(isValid); // false, as the required fields are missing

In this example, we first define the component interface Validator which declares the method validate(). We then define the concrete component FormValidator which implements this method and checks that all fields are non-empty.

Next, we define the decorator abstract class ValidatorDecorator which implements the same Validator interface and has a reference to a Validator object. The validate() method is implemented to simply call the validate() method on the wrapped object, as this is left to the concrete decorators.

Finally, we define the concrete decorator RequiredFieldValidator which extends ValidatorDecorator and adds the required field validation functionality. In this implementation, we first call the validate() method on the wrapped object using super.validate(), then check that all required fields are present by iterating over the requiredFields array and using hasOwnProperty() to check if the validator has the property.

In the usage section, we create an instance of the concrete component formValidator, which checks that all fields are non-empty. We then wrap this object with the RequiredFieldValidator decorator, which adds the required field validation functionality to the validate() method.

When we call the validate() method on the requiredFieldValidator object, the method first calls the validate() method on the formValidator object, which checks that all fields are non-empty. It then adds the required field validation functionality, and returns false as the required fields are missing in this example.

Photo by Brett Jordan on Unsplash

FAQS

Q1: What’s the difference between the Decorator and Adapter Design Patterns?
While the Adapter Design Pattern adapts the interface of an existing class to satisfy the requirements of a new class, the Decorator Design Pattern adds functionality to an individual object.

Q2:What is the difference between the Decorator and Composite Design Patterns?
While the Composite Design Pattern is used to group objects into tree structures and handle them consistently, the Decorator Design Pattern adds functionality to an individual object.

Q3: Is it possible to add more than one decorator to a component?
Yes, we can add numerous decorators to a component as long as they all implement the same interface.

Q4: What are the advantages of using the Decorator Design Pattern?

  • It enables you to add functionality to a single object without affecting the behaviour of other objects in the same class.
  • It allows adding or removing functionality dynamically at runtime.
  • It adheres to the Open-Closed Principle, which says that a class should be extensible but not modifiable.

Q5: What are the disadvantages of employing the Decorator Design Pattern?

  • It can generate a large number of small classes, making the code more complex and difficult to manage.
  • As similar decorators may need to be implemented for various components, it can result in a lot of code duplication.
Photo by Patrick Perkins on Unsplash

Conclusion

In this article, we looked at the Decorator Design Pattern, a structural class design pattern that enables you to add functionality to a single object without affecting the behaviour of other objects in the same class. We went over its definition, structure, TypeScript implementation, and usage in real-world situations.
In addition, we have addressed some commonly asked questions regarding the Decorator Design Pattern.
I’m sure it will come in handy for you in other situations. If you’ll use it let me know! 😎

Happy coding 🎉 Cheers 🍻

Andrea

Did you like this article? Consider buying me a coffee!

Offer me a coffee 🚀

Check out all the other patterns in the series here:
https://medium.com/@andreagernone/list/design-patterns-f4d1a893d3c4

--

--

Andrea Gernone 🫀🥷

I am a full stack professional software developer at @apuliasoft. Based in Italy and passionate about anything that enriches my soul. Also I love to eat