Decorator Design Pattern

The Decorator Design Pattern is a structural design pattern used to dynamically extend the functionality of objects without modifying their original implementation. It allows wrapping objects with additional behaviour at runtime.

Key Characteristics

  1. Extends functionality dynamically without modifying the base class.

  2. Follows the open/closed principle - new functionality can be added without modifying existing code.

  3. Encapsulates behaviour in layers, making the code more modular and flexible.

  4. Supports multiple decorators that can be applied in any order.

We will discuss a use-case API Request Handling using Typescript code to understand this design pattern.

We need to send a POST request using fetch(), but we want to add additional functionalities like:

  • Logging the request and response.

  • Retrying failed requests a few times before giving up.

  • Adding a timeout to cancel long-running requests.

Instead of modifying the core API request class, we will use decorators to wrap and enhance its behaviour.

Step 1 : Define the Interface

We will first define the interface that all request handlers (including decorators) must follow.

interface APIRequestHandler {
  sendRequest(url: string, data: any): Promise<string>;
}

Every API request handler must implement the method sendRequest() which takes the URL and data, then returns a Promise<string>

Step 2 : Implement the Base Request Handler

This class performs the actual HTTP request using fetch()

class BasicAPIRequestHandler implements APIRequestHandler {
   async sendRequest(url: string, data: any): Promise<string> {
        try {
            const response = await fetch(url, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify(data),
            });

            if (!response.ok) {
              throw new Error(`HTTP error! Status: ${response.status}`);
            }

            return await response.text();
          } catch (error) {
            if (error instanceof Error) {
              throw new Error(`Request failed: ${error.message}`);
            } else {
              throw new Error('Request failed with an unknown error');
            }
          }
    }
}
//Sends a POST request to the given URL

//Converts data to a JSON string

//Throws an error if request fails

Step 3 : Implements the Base Decorator class

All decorators will extend this base class. It implements APIRequestHandler and wraps another APIRequestHandler instance.

class APIRequestDecorator implements APIRequestHandler {
    protected apiRequestHandler: APIRequestHandler;

    constructor(apiRequestHandler: APIRequestHandler) {
        this.apiRequestHandler = apiRequestHandler;
    }

    async sendRequest(url: string, data: any): Promise<string> {
        return this.apiRequestHandler.sendRequest(url, data);
    }
}
// What this class APIRequestDecorator does:
// Wraps another request handler so we can add extra functionality.
// Passes requests to the wrapped handler (default behavior).
//  Allows us to extend behavior without modifying BasicAPIRequestHandler.

Step 4 : Logging Decorator

This decorator logs API requests and responses.

class LoggingDecorator extends APIRequestDecorator {
    async sendRequest(url: string, data: any): Promise<string> {
        console.log(`POST Request made to ${url} with ${data}`);
        try {
            const response = await this.apiRequestHandler.sendRequest(url, data);
            console.log(`Response from ${url}:`, response);
            return response;
          } catch (error) {
            if (error instanceof Error) {
                console.error(`Error from ${url}:`, error.message);
            } else {
                console.error(`Error from ${url}:`, error);
            }
            throw error;
          }
    }
}
// What this LoggingDecorator class does:
//Logs before sending the request.
// Logs response or error.
// Does not change the actual API request.

Step 5 : Retry Decorator

This decorator retries the request if it fails

class RetryDecorator extends APIRequestDecorator {
private maxRetries: number;

constructor(apiRequestHandler: APIRequestHandler, maxRetries: number = 3) {
  super(apiRequestHandler);
  this.maxRetries = maxRetries;

}

async sendRequest(url: string, data: any): Promise<string> {
  let retries = 0;
  while (retries < this.maxRetries) {
    try {
      return await this.apiRequestHandler.sendRequest(url, data);
    } catch (error) {

      retries++;
      console.warn(`Retrying (${retries}/${this.maxRetries})...`);
      if (retries === this.maxRetries) throw error;
    }
  }
  throw new Error(`Request failed after ${this.maxRetries} retries`);

}

}

//What this does:
// Tries up to 3 times if the request fails.
// Stops retrying after 3 failed attempts.
// Does not modify the original sendRequest() logic.

Step 6 : Timeout Decorator class

class TimeoutDecorator extends APIRequestDecorator {
  private timeout: number; // in milliseconds

  constructor(apiRequestHandler: APIRequestHandler, timeout: number = 5000) {
    super(apiRequestHandler);
    this.timeout = timeout;
  }

  async sendRequest(url: string, data: any): Promise<string> {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        reject(new Error(`Request timed out after ${this.timeout} ms`));
      }, this.timeout);

      this.apiRequestHandler.sendRequest(url, data)
        .then(response => {
          clearTimeout(timer);
          resolve(response);
        })
        .catch(error => {
          clearTimeout(timer);
          reject(error);
        });
    });
  }
}

//What this class does:
// If request takes too long, it cancels the request.
// Uses setTimeout() to track time.
//  Calls reject() if the request exceeds the limit.

Step 7: Applying all Decorators step-by-step

async function main() {
  let apiHandler: APIRequestHandler = new BasicAPIRequestHandler();

  // Apply Decorators
  apiHandler = new LoggingDecorator(apiHandler);
  apiHandler = new RetryDecorator(apiHandler, 3);
  apiHandler = new TimeoutDecorator(apiHandler, 15000); // 15 sec timeout

  try {
    const apiUrl = "https://api.example.com/data"; // Replace with actual API
    const payload = { userId: 830, name: "Yamie Yaha" };

    const response = await apiHandler.sendRequest(apiUrl, payload);
    console.log("Final Response:", response);
  } catch (error) {
    console.error("Final Error:", error.message);
  }
}
main();

After you replace with your API, you can see the results.

Final Flow

  1. Logging Decorator → Logs API request and response.

  2. Retry Decorator → Retries 3 times if the request fails.

  3. Timeout Decorator → Cancels the request if it exceeds 15 sec.

Key Benefits of the Decorator Pattern

  • Flexible → Can combine multiple behaviours dynamically.

  • Reusable → Each decorator can be reused independently.

  • Open/Closed Principle → We can add new features without modifying existing classes.

  • Avoids subclass explosion → Instead of creating multiple subclasses (LoggingApiHandler, RetryApiHandler etc.), we compose behaviours.

*****************************************************************************************

ADDENDUM

Let us dig deep and decipher what is happening here. Let us look into a step-by-step breakdown.

1. Start with the base handler (the "core" API request logic)

let apiHandler: APIRequestHandler = new BasicAPIRequestHandler();

Here apiHandler is a simple object that just makes an API request.

  1. Wrapping it with a Logging Decorator
apiHandler = new LoggingDecorator(apiHandler);
  • Now, apiHandler is not just a BasicAPIRequestHandler.

  • Instead, it is a LoggingDecorator that contains the BasicAPIRequestHandler.

  • When we call apiHandler.sendRequest(), it first logs the request, then calls the actual API request.

  1. Wrapping it with a RetryDecorator
apiHandler = new RetryDecorator(apiHandler, 3);
  • Now, apiHandler is a RetryDecorator that contains the LoggingDecorator which in turn contains the BasicAPIRequestHandler.

  • When we call apiHandler.sendRequest(), it first applies the retry logic, followed by logging the request and finally calls the actual API request.

  1. Wrapping it with a TimeOutDecorator
apiHandler = new TimeoutDecorator(apiHandler,15000);
  • apiHandler is a TimeoutDecorator that contains the RetryDecorator which in turn is a LoggingDecorator, which contains the BasicAPIRequestHandler.

  • When we call apiHandler.sendRequest(), it first applies the timeout logic, then fits in the retry logic, followed by logging the request, and finally makes the actual API request.

We can see that each step the apiHandler keeps adding a layer. It now resembles an “onion” with multiple layers of “peels”. Each “peel” or each decorator adds extra behaviour before passing the request to the next layer.

  1. Final Execution Flow
apiHandler.sendRequest("https://api.example.com/data", { key: "value" });

Key Takeaways

  • apiHandler is always the latest decorated version of the original object.

  • Each decorator adds extra behaviour before calling the previous version.

  • Final apiHandler.sendRequest() goes through all decorators before reaching the core request logic.

SOME MORE EXPLANATION

To understand how “onion” like layers are added, we will use the basic concepts of objects and wrapping.

The onion-like layering in the Decorator pattern is made possible by polymorphism and composition.

The Key Concept : Wrapping one object inside another.

Each decorator object holds a reference to another previously created object (handler).

class LoggingDecorator {
  constructor(private handler: BasicAPIRequestHandler) {}

  sendRequest(url: string, data: any) {
    console.log(`LOG: Requesting ${url} with data:`, data);
    this.handler.sendRequest(url, data); // Calls the wrapped handler
  }
}
/** 
 Key thing here: The handler inside LoggingDecorator is another object that 
 implements the same method (sendRequest).

 This allows us to pass a previously created object into a new decorator.
*/

Each new decorator wraps an existing object (like wrapping an onion layer).

For example:

let apiHandler = new BasicAPIRequestHandler(); // Core object
apiHandler = new LoggingDecorator(apiHandler); // First layer
apiHandler = new RetryDecorator(apiHandler,3); // Second layer
apiHandler = new TimeoutDecorator(apiHandler,15000); // Third layer

Now, the final object apiHandler is not a single object anymore. Instead, it’s a chain of objects, where each holds a reference to the previous one.

This chain of references is what creates the onion-like layering.

What Happens When sendRequest() is Called?

Each decorator first does its own work, then calls the wrapped object's method, which goes deeper through the layers.

apiHandler.sendRequest("https://api.example.com", { key: "value" });

The method call follows these steps, layer by layer:

  1. TimeoutDecorator.sendRequest() runs first (outer layer).

    • Checks timeout for 15 seconds.

    • Calls RetryDecorator.sendRequest().

  2. RetryDecorator.sendRequest() runs next.

    • retries the request - 3 times maximum.

    • Calls LoggingDecorator.sendRequest().

  3. LoggingDecorator.sendRequest() runs next.

    • Logs request details.

    • Calls BasicAPIRequestHandler.sendRequest().

  4. BasicAPIRequestHandler.sendRequest() runs last (core).

    • Sends the actual API request.

The execution starts at the outermost layer and moves inward, like peeling an onion.

What Actually Enables This?

  1. Composition (Wrapping Objects in Objects)

    1. Each decorator stores a reference to another object of the same type.

    2. This allows chaining objects together dynamically.

  2. Polymorphism (Same Method in Different Objects)

    1. Each object implements the same method sendRequest()

    2. This ensures every object can call the next one, no matter what it is.

  3. Recursion-Like Execution (Calling the Next Layer)

    1. Each decorator calls the wrapped object’s method.

    2. This makes the method calls flow through all the layers.

Final Summary

Why do decorators form onion-like layers?

  1. Because each decorator "wraps" the previous object by holding a reference to it.

  2. Because each decorator calls the next layer inside its method.

  3. Because polymorphism ensures they all follow the same structure, so we can wrap dynamically.

The GITHUB code is here

2
Subscribe to my newsletter

Read articles from Ganesh Rama Hegde directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ganesh Rama Hegde
Ganesh Rama Hegde

Passionate Developer | Code Whisperer | Innovator Hi there! I'm a senior software developer with a love for all things tech and a knack for turning complex problems into elegant, scalable solutions. Whether I'm diving deep into TypeScript, crafting seamless user experiences in React Native, or exploring the latest in cloud computing, I thrive on the thrill of bringing ideas to life through code. I’m all about creating clean, maintainable, and efficient code, with a strong focus on best practices like the SOLID principles. My work isn’t just about writing code; it’s about crafting digital experiences that resonate with users and drive impact. Beyond the code editor, I’m an advocate for continuous learning, always exploring new tools and technologies to stay ahead in this ever-evolving field. When I'm not coding, you'll find me blogging about my latest discoveries, experimenting with side projects, or contributing to open-source communities. Let's connect, share knowledge, and build something amazing together!