Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 021 / 100

Decorator pattern: a Design Patterns guide for developers

Add behavior by wrapping, not editing.

Expona Admin8 min read

Decorator pattern

The Decorator pattern is one of the most practical patterns you'll use in production systems because it solves a real problem: how do you add behavior to an object without modifying its source code? Instead of editing the original class, you wrap it in another object that implements the same interface and delegates calls while adding extra logic. I've leaned on this pattern countless times when building e-commerce platforms, trading systems, and APIs where cross-cutting concerns like caching, logging, and retry logic need to sit between the caller and the core business logic.

Why decorators matter in real systems

Early in my career, I'd add caching logic directly into repository methods, or sprinkle retry attempts all over my service layer. It worked, but the code became tangled. Each new concern—whether it's metrics collection, authentication checks, or circuit breaking—would force you to touch and test existing logic. The Decorator pattern solves this by treating each cross-cutting concern as its own wrapper. You compose them at runtime, test them independently, and swap them in or out without changing the core class.

On a recent ride-hailing platform we built, the product repository needed caching for frequently fetched vehicle types, logging for audit trails, and retry logic for transient database failures. With decorators, each concern became a separate, testable class. That separation meant the core repository stayed simple and focused.

Caching, logging and retries as wrappers

Let's walk through the three most common decorators I've used in production:

Caching wrapper

A cache decorator sits in front of an expensive operation—typically a database query or API call—and returns cached results when available.

typescript
interface ProductRepo {
  get(id: string): Promise<Product>;
  list(): Promise<Product[]>;
}

class CachedProductsDecorator implements ProductRepo {
  constructor(
    private inner: ProductRepo,
    private cache: Cache
  ) {}

  async get(id: string): Promise<Product> {
    const cacheKey = `product:${id}`;
    return this.cache.wrap(cacheKey, () => this.inner.get(id), {
      ttl: 3600
    });
  }

  async list(): Promise<Product[]> {
    const cacheKey = 'products:all';
    return this.cache.wrap(cacheKey, () => this.inner.list(), {
      ttl: 1800
    });
  }
}

The key insight: the decorator accepts the same interface as the object it wraps. The caller doesn't need to know whether they're hitting cache or the database—they call get() either way. I've found this pattern invaluable in SaaS platforms where database round trips directly impact response times and costs.

Logging wrapper

A logging decorator tracks what's happening without cluttering business logic:

typescript
class LoggingProductsDecorator implements ProductRepo {
  constructor(
    private inner: ProductRepo,
    private logger: Logger
  ) {}

  async get(id: string): Promise<Product> {
    const startTime = Date.now();
    this.logger.info(`Fetching product ${id}`);

    try {
      const result = await this.inner.get(id);
      const duration = Date.now() - startTime;
      this.logger.info(`Product ${id} fetched in ${duration}ms`);
      return result;
    } catch (error) {
      this.logger.error(`Failed to fetch product ${id}`, error);
      throw error;
    }
  }

  async list(): Promise<Product[]> {
    this.logger.info('Listing all products');
    const result = await this.inner.list();
    this.logger.info(`Listed ${result.length} products`);
    return result;
  }
}

In a CRM system I worked on, this decorator became our audit trail. Every data access was logged with timestamps and user context. The repository itself never had to worry about compliance requirements—the decorator handled it transparently.

Retry wrapper

A retry decorator handles transient failures gracefully:

typescript
class RetryProductsDecorator implements ProductRepo {
  private maxRetries = 3;
  private backoffMs = 100;

  constructor(private inner: ProductRepo) {}

  async get(id: string): Promise<Product> {
    return this.executeWithRetry(() => this.inner.get(id));
  }

  async list(): Promise<Product[]> {
    return this.executeWithRetry(() => this.inner.list());
  }

  private async executeWithRetry<T>(fn: () => Promise<T>): Promise<T> {
    let lastError: Error | undefined;

    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      try {
        return await fn();
      } catch (error) {
        lastError = error as Error;
        if (attempt < this.maxRetries) {
          const delay = this.backoffMs * Math.pow(2, attempt - 1);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }

    throw lastError;
  }
}

On a travel platform handling bookings, this decorator was critical. Network hiccups and database connection resets were common during high load, but exponential backoff meant most transient failures resolved without bothering the user.

Same interface as the wrapped object

The contract is straightforward: a decorator must implement the exact same interface as the object it wraps. This is why the pattern works so cleanly in TypeScript and other statically typed languages. Both ProductRepo implementations have identical method signatures, so the caller—whether it's a controller, service, or test—doesn't care which implementation it receives.

In practice, this means:

  • The decorator's constructor accepts an instance of the interface (the wrapped object).
  • Every method delegates to that wrapped instance via this.inner.
  • The decorator adds behavior before or after the delegation, or wraps the call entirely.

I've seen developers accidentally break this contract by adding extra methods to a decorator or changing method signatures. That defeats the point—you lose composability and the caller becomes aware of the specific decorator type. Keep decorators thin and faithful to the interface.

Stack decorators in any order

One of the most powerful aspects of the Decorator pattern is composition. You can wrap a repository in multiple decorators, and the order matters based on your needs.

typescript
// Option 1: Cache → Log → Retry
const repository = new LoggingProductsDecorator(
  new RetryProductsDecorator(
    new CachedProductsDecorator(
      new SqlProductRepository(),
      cache
    ),
    logger
  )
);

// Option 2: Retry → Cache → Log
const repository2 = new LoggingProductsDecorator(
  new CachedProductsDecorator(
    new RetryProductsDecorator(
      new SqlProductRepository()
    ),
    cache
  )
);

The order changes behavior significantly. In Option 1, we cache responses before logging them, so repeated cache hits don't generate log entries. In Option 2, every call is logged, even cache hits. On an e-commerce platform, I typically put retry closest to the database (so transient failures are handled early), then cache (so successful responses are cached), then log (so we see the final result).

You can even swap decorators based on configuration or environment:

typescript
let repository: ProductRepo = new SqlProductRepository();

if (config.enableRetry) {
  repository = new RetryProductsDecorator(repository);
}

if (config.enableCache) {
  repository = new CachedProductsDecorator(repository, cache);
}

if (config.enableLogging) {
  repository = new LoggingProductsDecorator(repository, logger);
}

This flexibility is why the pattern scales so well. Adding a new cross-cutting concern means writing one new decorator class and composing it—no changes to existing code.

Scrutor in .NET makes registration easy

If you're working in .NET, the Scrutor library automates decorator registration in the dependency injection container. Without it, you'd manually wire up every combination, which gets messy fast.

typescript
// Pseudo-C# to show the concept
services.Decorate<IProductRepository>(
  inner => new CachedProductsDecorator(inner, cache)
);

services.Decorate<IProductRepository>(
  inner => new LoggingProductsDecorator(inner, logger)
);

services.Decorate<IProductRepository>(
  inner => new RetryProductsDecorator(inner)
);

// The container now returns a fully decorated instance
// IProductRepository repo = serviceProvider.GetRequiredService<IProductRepository>();

In NestJS, you'd use similar composition patterns with module providers. The key is that your core business logic—the service or repository—doesn't know or care about decorators. They're purely infrastructure concerns, managed by the DI container.

On a SaaS platform I worked on, Scrutor allowed us to enable or disable features like caching and request logging via configuration flags. We'd register different decorator stacks for different tenants, all without touching the repository implementation.

Common mistakes

Losing the interface contract: Adding methods to a decorator that the wrapped object doesn't have breaks the pattern. Stick to the interface. If you need a new behavior, create a new interface and decorator pair.

Nesting too deeply: More than 3–4 decorators in a stack becomes hard to reason about. If you find yourself stacking 10 decorators, consider whether some of that logic belongs in a middleware layer or a separate service.

Forgetting to forward all parameters: It's easy to miss an optional parameter or configuration object when delegating to the inner object. Be thorough in your forwarding—use spread operators or explicit parameter passing.

Mixing concerns in one decorator: A decorator should do one thing well. If your caching decorator also logs and retries, it violates the Single Responsibility Principle. Split it into three decorators and compose them.

Ignoring performance in decorators: Decorators add overhead. A logging decorator that serializes large objects or a caching decorator with poor key generation can slow your system. Profile and optimize.

Key takeaways

  • The Decorator pattern wraps an object to add behavior without modifying its source code, keeping cross-cutting concerns separate and testable.
  • Decorators implement the same interface as the wrapped object, making them transparent to callers.
  • You can stack multiple decorators and compose them in different orders depending on the behavior you need.
  • In .NET, Scrutor simplifies decorator registration in the DI container; in NestJS, use similar composition patterns with module providers.
  • Keep decorators focused on a single concern—caching, logging, or retries—not all three.
  • Order matters: retry closer to I/O, cache results, then log the final outcome.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles