Frontend

Why Smart Developers Love the Decorator Pattern: A Friendly Introduction

The old system worked. But the client wanted more. Add a feature here. Extend functionality there. Each request meant changing code that already ran in production. Each change brought risk.

Then I remembered the Decorator Pattern. A way to add without breaking.

The Essence

The Decorator Pattern lets you attach new behaviors to objects by placing them inside wrapper objects.

These wrappers implement the same interface as what they wrap. They pass on requests to the wrapped object, but add their own behavior before or after.

The key: you add new functionality without touching existing code.

The Coffee Shop App

A coffee shop application demonstrates the problem:

class Coffee {
  cost() {
    return 5;
  }
  
  description() {
    return "Black coffee";
  }
}

Simple. Works well. Then come the requests:

  • Add options for milk.
  • Add options for sugar.
  • Add options for whipped cream.
  • Add options for caramel.

You could modify the original class. Add methods. Add flags. But that makes the class bigger. More complex. More prone to bugs.

You could create subclasses. But how many? With four toppings, you need sixteen subclasses to cover every combination.

Too many classes. Too much repetition. Too rigid.

The Decorator Solution

Start with the core interface:

interface Beverage {
  cost(): number;
  description(): string;
}

// The core component
class SimpleCoffee implements Beverage {
  cost() {
    return 5;
  }
  
  description() {
    return "Black coffee";
  }
}

Next, create an abstract decorator:

abstract class AddOnDecorator implements Beverage {
  constructor(protected beverage: Beverage) {}
  
  abstract cost(): number;
  abstract description(): string;
}

Then implement concrete decorators:

class MilkDecorator extends AddOnDecorator {
  cost() {
    return this.beverage.cost() + 0.5;
  }
  
  description() {
    return this.beverage.description() + ", milk";
  }
}

class WhipDecorator extends AddOnDecorator {
  cost() {
    return this.beverage.cost() + 1;
  }
  
  description() {
    return this.beverage.description() + ", whipped cream";
  }
}

class CaramelDecorator extends AddOnDecorator {
  cost() {
    return this.beverage.cost() + 1.2;
  }
  
  description() {
    return this.beverage.description() + ", caramel";
  }
}

Now the magic:

// Start with a simple coffee
let coffee: Beverage = new SimpleCoffee();
console.log(coffee.description()); // "Black coffee"
console.log(coffee.cost()); // 3

// Add milk
coffee = new MilkDecorator(coffee);
console.log(coffee.description()); // "Black coffee, milk"
console.log(coffee.cost()); // 3.5

// Add whipped cream
coffee = new WhipDecorator(coffee);
console.log(coffee.description()); // "Black coffee, milk, whipped cream"
console.log(coffee.cost()); // 4.5

// Add caramel
coffee = new CaramelDecorator(coffee);
console.log(coffee.description()); // "Black coffee, milk, whipped cream, caramel"
console.log(coffee.cost()); // 5.7

Each addition is wrapped around the previous object. Each decorator adds its own behavior and passes on requests to what it wraps.

This structure gives us unlimited combinations without modifying existing code.

Logger Example

Logging shows another clear use of decorators:

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

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(message);
  }
}

abstract class LoggerDecorator implements Logger {
  constructor(protected logger: Logger) {}
  
  abstract log(message: string): void;
}

class TimestampDecorator extends LoggerDecorator {
  log(message: string): void {
    const timestamp = new Date().toISOString();
    this.logger.log(`[${timestamp}] ${message}`);
  }
}

class SourceDecorator extends LoggerDecorator {
  constructor(logger: Logger, private source: string) {
    super(logger);
  }
  
  log(message: string): void {
    this.logger.log(`[${this.source}] ${message}`);
  }
}

class ErrorHighlightDecorator extends LoggerDecorator {
  log(message: string): void {
    if (message.includes("ERROR")) {
      this.logger.log(`*** ${message} ***`);
    } else {
      this.logger.log(message);
    }
  }
}

Now configure logging based on needs:

// Basic logger
let logger: Logger = new ConsoleLogger();

// Add timestamps in production
if (isProduction) {
  logger = new TimestampDecorator(logger);
}

// Add source info in development
if (isDevelopment) {
  logger = new SourceDecorator(logger, "UserService");
}

// Always highlight errors
logger = new ErrorHighlightDecorator(logger);

// Use the decorated logger
logger.log("User logged in");
logger.log("ERROR: Failed to connect to database");

Each decorator adds one responsibility. Each can be added or removed without changing existing code.

Authentication Example

Let’s build a simple authentication system with decorators:

Now we can compose these decorators based on context:

// Start with the basic request
let request: HttpRequest = new BasicHttpRequest();

// Add caching for GET requests
if (method === "GET") {
  request = new CacheDecorator(request);
}

// Add authentication if user is logged in
if (userToken) {
  request = new AuthDecorator(request, userToken);
}

// Add logging in development
if (isDevelopment) {
  request = new LoggingDecorator(request);
}

// Make the request
const response = await request.process("https://api.example.com/data", { 
  method: "GET",
  headers: { "Content-Type": "application/json" }
});

Each decorator adds functionality while maintaining the same interface. The core request handling never changes.

When to Use Decorators

Use the Decorator Pattern when:

  1. You need to add responsibilities to objects dynamically and transparently
  2. You want to add features without modifying existing code
  3. You need to combine multiple behaviors in flexible ways
  4. Extension by subclassing would lead to an explosion of classes

When Not to Use Decorators

Avoid decorators when:

  1. The added complexity isn’t justified for simple extensions
  2. You need to fundamentally change an object’s behavior, not just extend it
  3. You’re working with code that relies on concrete component types

The Power of Composition

Decorators show the power of composition over inheritance. Rather than building complex class hierarchies, we compose objects at runtime.

This follows a core principle: favor composition over inheritance. Composition provides more flexibility and less coupling.

The Stacking Order Matters

Remember that with decorators, the order of decoration matters:

// These produce different results
const coffee1 = new CaramelDecorator(new WhipDecorator(new SimpleCoffee()));
const coffee2 = new WhipDecorator(new CaramelDecorator(new SimpleCoffee()));

This can be a strength or a weakness. Be mindful of the order when it affects behavior.

A Simpler Approach with Composition

For simple cases, direct composition can be clearer than inheritance-based decorators:

class Beverage {
  constructor(
    private description: string = "Unknown beverage",
    private cost: number = 0
  ) {}
  
  getDescription(): string {
    return this.description;
  }
  
  getCost(): number {
    return this.cost;
  }
}

function withMilk(beverage: Beverage): Beverage {
  return new Beverage(
    beverage.getDescription() + ", milk",
    beverage.getCost() + 0.5
  );
}

function withWhip(beverage: Beverage): Beverage {
  return new Beverage(
    beverage.getDescription() + ", whipped cream",
    beverage.getCost() + 1
  );
}

// Usage
let coffee = new Beverage("Black coffee", 3);
coffee = withMilk(coffee);
coffee = withWhip(coffee);

This functional approach can be more concise for straightforward cases.

Final Thoughts

The Decorator Pattern is about adding without changing and extending without breaking. Growing without painful refactoring.

It follows the Open/Closed Principle: open for extension, closed for modification.

Like a writer adding details to a story without changing the plot, decorators add features without changing the core functionality.

Next time requirements grow but you can’t touch working code, remember the Decorator Pattern. It might save your project. And your sanity.

The client still wants more. But now you’re ready

Back to top button