Pizza toppings work as layers over one base. Each layer changes the result without changing the dough underneath, and different combinations can be assembled from the same pieces.

The Decorator pattern adds behavior by wrapping an object with another object that implements the same interface. A decorator keeps a reference to the wrapped component, delegates to it, and runs work before or after that call. Because the wrapper still satisfies the original contract, layers such as LoggingHandler(ValidationHandler(MetricsHandler(CoreHandler))) can be composed without changing the client. Order matters because each layer controls when delegation occurs.

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 behavior such as logging or validation. Software Architecture/Patterns/Design Patterns/Structural/Proxy controls access to the real object through mechanisms such as authorization or lazy loading. Their structure can look identical. Intent separates them.

Problem

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

public class OrderProcessor(
    IOrderRepository repository,
    ILogger<OrderProcessor> logger,
    IMetricsCollector metrics,
    IAuditLog auditLog)
{
    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
    }
}

Adding idempotency requires editing ProcessOrderAsync, where it can disturb unrelated processing concerns that already work.

Solution

Move each concern into a decorator around 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;
        }
    }
}
 
public interface IIdempotencyStore
{
    // Atomically coordinates concurrent calls for the same key.
    Task<OrderResult> ExecuteOnceAsync(Guid key, Func<Task<OrderResult>> operation);
}
 
// ✅ Adding idempotency = new decorator class, zero changes to existing decorators
public class IdempotencyOrderHandler(IOrderHandler next, IIdempotencyStore store) : IOrderHandler
{
    public Task<OrderResult> HandleAsync(Order order) =>
        store.ExecuteOnceAsync(order.Id, () => next.HandleAsync(order));
}
 
// Composition — order matters: validation runs first, then idempotency, then logging, then metrics, then core
IOrderHandler handler =
    new ValidationOrderHandler(
        new IdempotencyOrderHandler(
            new LoggingOrderHandler(
                new MetricsOrderHandler(
                    new CoreOrderHandler(repository),
                    metrics),
                logger),
            idempotencyStore));
 
// 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

Idempotency now lives in one IdempotencyOrderHandler. Existing decorators and the core handler stay unchanged.

Common .NET Examples

A Stream chain can layer buffering, encryption, or compression while every wrapper remains a Stream.

ASP.NET Core middleware composes delegates around the next RequestDelegate. Startup order determines request order and the reverse response path.

DelegatingHandler in HttpClient layers request and response behavior around an inner handler.

Scrutor Decorate<T>() registers a decorator around an existing service without manual object construction.

Pitfalls

Ordering changes behavior. Validation outside logging rejects invalid orders before they are logged. Reversing those layers records every attempt. The composition root should make the chosen semantics visible.

Deep wrapper stacks are harder to trace. Each layer adds another frame and another place where control may stop before delegation. Correlation identifiers help reconstruct one request, but a long chain is still a design smell worth inspecting.

Mutable state can leak between requests. A singleton decorator must not carry request-specific fields. Its lifetime must be compatible with the wrapped service and every injected dependency.

Tradeoffs

ConcernDecorator chainMonolithic methodAOP (PostSharp/Castle)
Adding a new concernNew class, zero changesEdit existing methodNew attribute/interceptor
Concern orderingExplicit at compositionImplicit in method bodyFramework-controlled
TestabilityEach decorator tested independentlyMust test all concerns togetherInterceptors tested separately
DebuggabilityDeep call stacksSingle method, easy to traceFramework magic, hard to trace
ComplexityMany small classesOne large classFramework dependency

Decorator fits optional behaviors that share a contract and need explicit composition. One small concern may be clearer inside the component. A concern spanning every request usually belongs in middleware rather than in a decorator for each service.

Questions

References