Decorator

Decorator

Stacking toppings on a pizza is a Decorator in everyday life. Start with plain dough, add sauce, add cheese, add pepperoni, add mushrooms. Each topping wraps the previous pizza without changing what’s underneath, and you can add or remove any topping independently. A pepperoni pizza and a mushroom pizza share the same base — the toppings are layered on, not baked in.

The Decorator pattern works the same way: it attaches additional responsibilities to an object dynamically by wrapping it in decorator objects that implement the same interface. Each decorator holds a reference to the wrapped object, calls it, and adds behavior before or after the call. Decorators compose freely — you can stack LoggingHandler(ValidationHandler(MetricsHandler(CoreHandler))) in any order. Each decorator is independently testable and deployable. The client sees a single IOrderHandler and doesn’t know (or care) how many decorators are wrapping the core.

classDiagram
    class IOrderHandler {

        +HandleAsync(order) OrderResult
    }
    class CoreOrderHandler {
        +HandleAsync(order) OrderResult
    }
    class LoggingDecorator {
        -inner IOrderHandler
        +HandleAsync(order) OrderResult
    }
    class ValidationDecorator {
        -inner IOrderHandler
        +HandleAsync(order) OrderResult
    }
    class MetricsDecorator {
        -inner IOrderHandler
        +HandleAsync(order) OrderResult
    }
    IOrderHandler <|.. CoreOrderHandler
    IOrderHandler <|.. LoggingDecorator
    IOrderHandler <|.. ValidationDecorator
    IOrderHandler <|.. MetricsDecorator
    LoggingDecorator --> IOrderHandler : wraps
    ValidationDecorator --> IOrderHandler : wraps
    MetricsDecorator --> IOrderHandler : wraps
Decorator vs Proxy

Both wrap the same interface. Decorator ADDS new behavior — logging, caching, validation. Proxy CONTROLS ACCESS to the real object — lazy loading, auth checks, remote calls. The structural difference is intent: Decorator enriches; Proxy restricts or defers.

Problem

OrderProcessor.ProcessOrder() has growing cross-cutting concerns mixed with core logic:

public class OrderProcessor
{
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrderProcessor> _logger;
    private readonly IMetricsCollector _metrics;

    public async Task<OrderResult> ProcessOrderAsync(Order order)
    {
        // ⚠️ Logging, metrics, validation, and core logic all interleaved
        _logger.LogInformation("Processing order {OrderId}", order.Id);
        var stopwatch = Stopwatch.StartNew();

        try
        {
            // ⚠️ Validation mixed with processing
            if (order.Items.Count == 0)
                throw new InvalidOperationException("Order has no items");
            if (order.Total <= 0)
                throw new InvalidOperationException("Order total must be positive");

            // ⚠️ Audit trail mixed with processing
            await _auditLog.RecordAsync($"Order {order.Id} processing started by {order.Customer.Id}");

            var result = await _repository.SaveAndProcessAsync(order);

            stopwatch.Stop();
            _metrics.RecordOrderProcessingTime(stopwatch.ElapsedMilliseconds);
            _logger.LogInformation("Order {OrderId} processed in {Ms}ms", order.Id, stopwatch.ElapsedMilliseconds);

            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Order {OrderId} processing failed", order.Id);
            _metrics.RecordOrderFailure();
            throw;
        }
        // ⚠️ Adding a new concern (rate limiting, idempotency check) means editing this method
    }
}

Here's what breaks when requirements change: adding idempotency checking (skip duplicate orders) requires editing ProcessOrderAsync — touching code that already works and risking regressions in logging, metrics, and validation.

Solution

Each concern becomes a decorator that wraps the next handler:

// Component interface
public interface IOrderHandler
{
    Task<OrderResult> HandleAsync(Order order);
}

// Core handler — pure business logic, no cross-cutting concerns
public class CoreOrderHandler(IOrderRepository repository) : IOrderHandler
{
    public Task<OrderResult> HandleAsync(Order order) =>
        repository.SaveAndProcessAsync(order);
}

// Decorator: validation
public class ValidationOrderHandler(IOrderHandler next) : IOrderHandler
{
    public async Task<OrderResult> HandleAsync(Order order)
    {
        // ✅ Validation isolated — can be tested independently
        if (order.Items.Count == 0)
            throw new InvalidOperationException("Order has no items");
        if (order.Total <= 0)
            throw new InvalidOperationException("Order total must be positive");

        return await next.HandleAsync(order); // ✅ delegates to next in chain
    }
}

// Decorator: logging
public class LoggingOrderHandler(IOrderHandler next, ILogger<LoggingOrderHandler> logger) : IOrderHandler
{
    public async Task<OrderResult> HandleAsync(Order order)
    {
        logger.LogInformation("Processing order {OrderId} for customer {CustomerId}",
            order.Id, order.Customer.Id);
        try
        {
            var result = await next.HandleAsync(order);
            logger.LogInformation("Order {OrderId} processed successfully", order.Id);
            return result;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Order {OrderId} processing failed", order.Id);
            throw;
        }
    }
}

// Decorator: metrics
public class MetricsOrderHandler(IOrderHandler next, IMetricsCollector metrics) : IOrderHandler
{
    public async Task<OrderResult> HandleAsync(Order order)
    {
        var sw = Stopwatch.StartNew();
        try
        {
            var result = await next.HandleAsync(order);
            metrics.RecordOrderProcessingTime(sw.ElapsedMilliseconds);
            return result;
        }
        catch
        {
            metrics.RecordOrderFailure();
            throw;
        }
    }
}

// ✅ Adding idempotency = new decorator class, zero changes to existing decorators
public class IdempotencyOrderHandler(IOrderHandler next, IIdempotencyStore store) : IOrderHandler
{
    public async Task<OrderResult> HandleAsync(Order order)
    {
        if (await store.ExistsAsync(order.Id))
            return await store.GetResultAsync(order.Id);

        var result = await next.HandleAsync(order);
        await store.StoreAsync(order.Id, result);
        return result;
    }
}

// Composition — order matters: validation runs first, then idempotency, then logging, then metrics, then core
IOrderHandler handler =
    new ValidationOrderHandler(
        new IdempotencyOrderHandler(idempotencyStore,
            new LoggingOrderHandler(
                new MetricsOrderHandler(
                    new CoreOrderHandler(repository),
                    metrics),
                logger)));

// With Scrutor (DI-based decoration):
builder.Services.AddScoped<IOrderHandler, CoreOrderHandler>();
builder.Services.Decorate<IOrderHandler, MetricsOrderHandler>();
builder.Services.Decorate<IOrderHandler, LoggingOrderHandler>();
builder.Services.Decorate<IOrderHandler, ValidationOrderHandler>(); // outermost = runs first

Adding idempotency checking now means one new IdempotencyOrderHandler class — existing decorators and the core handler never change.

You Already Use This

Stream chain — the canonical .NET Decorator. new GZipStream(new CryptoStream(new BufferedStream(fileStream), encryptor, CryptoStreamMode.Write), CompressionMode.Compress) stacks three decorators. Each wraps the next, adding compression, encryption, and buffering. All implement Stream.

ASP.NET Core Middleware — each middleware is a decorator. app.UseAuthentication(), app.UseAuthorization(), app.UseRateLimiting() each wrap the next RequestDelegate. The pipeline is a decorator chain built at startup.

DelegatingHandler in HttpClient — each handler in the HttpClient pipeline is a decorator. Retry handlers, auth handlers, and logging handlers each wrap the inner handler, adding behavior to HTTP requests and responses.

Scrutor Decorate<T>() — a DI extension that registers decorators without manual wiring. services.Decorate<IOrderHandler, LoggingOrderHandler>() wraps the existing IOrderHandler registration with the logging decorator.

Pitfalls

Decorator ordering matters — validation before logging means invalid orders are rejected before being logged. Logging before validation means every invalid order attempt is logged. The order is a business decision, not a technical one. Document the intended order and enforce it in the composition root.

Debugging wrapped stacks — stack traces through a 5-layer decorator chain are hard to read. Each decorator adds a frame. Use structured logging with a correlation ID (set in the outermost decorator) so all log entries for one request share the same ID. In development, consider a single "all-in-one" decorator that's easier to step through.

Decorator state leaking between requests — if a decorator holds mutable state (e.g., a counter), it must be scoped correctly. Singleton decorators with request-scoped state cause concurrency bugs. Register decorators with the same lifetime as the component they wrap.

Tradeoffs

Concern Decorator chain Monolithic method AOP (PostSharp/Castle)
Adding a new concern New class, zero changes Edit existing method New attribute/interceptor
Concern ordering Explicit at composition Implicit in method body Framework-controlled
Testability Each decorator tested independently Must test all concerns together Interceptors tested separately
Debuggability Deep call stacks Single method, easy to trace Framework magic, hard to trace
Complexity Many small classes One large class Framework dependency

Decision rule: Use Decorator when you have 3+ cross-cutting concerns that need to be independently testable and composable in different orders. For 1-2 concerns, adding them directly to the class is simpler. For concerns that span many classes (not just one), AOP or middleware is more appropriate than per-class decorators.

Questions

References


Whats next