The Decorator Pattern with Dependency Injection in .NET

The decorator pattern lets you add behaviour to an object without modifying it. Wrap the original implementation with a new class that adds functionality — caching, logging, retry logic, metrics — while delegating the core work to the inner implementation. In .NET, this pairs beautifully with dependency injection.

The Problem

You have a service that fetches products:

ProductService.cs
public interface IProductService
{
    Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct = default);
}

public class ProductService : IProductService
{
    private readonly AppDbContext _db;

    public ProductService(AppDbContext db) => _db = db;

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct) =>
        await _db.Products.FindAsync([id], ct);

    public async Task<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct) =>
        await _db.Products
            .Where(p => p.Name.Contains(query))
            .ToListAsync(ct);
}

Now you need caching. You could add caching logic directly to ProductService, but that violates the single responsibility principle and makes the code harder to test. Instead, create a decorator.

A Caching Decorator

CachedProductService.cs
public class CachedProductService : IProductService
{
    private readonly IProductService _inner;
    private readonly IMemoryCache _cache;
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);

    public CachedProductService(IProductService inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        var cacheKey = $"product:{id}";

        if (_cache.TryGetValue(cacheKey, out Product? cached))
            return cached;

        var product = await _inner.GetByIdAsync(id, ct);

        if (product is not null)
            _cache.Set(cacheKey, product, CacheDuration);

        return product;
    }

    public async Task<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct)
    {
        // Don't cache searches — delegate directly
        return await _inner.SearchAsync(query, ct);
    }
}

The decorator implements the same interface, wraps the original, and adds caching. ProductService remains untouched.

A Logging Decorator

LoggedProductService.cs
public class LoggedProductService : IProductService
{
    private readonly IProductService _inner;
    private readonly ILogger<LoggedProductService> _logger;

    public LoggedProductService(IProductService inner, ILogger<LoggedProductService> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        _logger.LogInformation("Fetching product {ProductId}", id);
        var sw = Stopwatch.StartNew();

        var product = await _inner.GetByIdAsync(id, ct);

        _logger.LogInformation(
            "Fetched product {ProductId} in {ElapsedMs}ms (found: {Found})",
            id, sw.ElapsedMilliseconds, product is not null);

        return product;
    }

    public async Task<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct)
    {
        _logger.LogInformation("Searching products with query '{Query}'", query);

        var results = await _inner.SearchAsync(query, ct);

        _logger.LogInformation(
            "Search for '{Query}' returned {Count} results", query, results.Count);

        return results;
    }
}

Registering Decorators

The built-in .NET DI container doesn't natively support decoration, but you can wire it manually:

Program.cs
builder.Services.AddScoped<ProductService>();
builder.Services.AddMemoryCache();

builder.Services.AddScoped<IProductService>(sp =>
{
    var inner = sp.GetRequiredService<ProductService>();
    var cache = sp.GetRequiredService<IMemoryCache>();
    var logger = sp.GetRequiredService<ILogger<LoggedProductService>>();

    // Order matters: logging wraps caching wraps the real service
    var cached = new CachedProductService(inner, cache);
    var logged = new LoggedProductService(cached, logger);

    return logged;
});

The execution order is: logging -> cache check -> database (if cache miss).

Using Scrutor for Cleaner Registration

The Scrutor library adds decoration support to Microsoft's DI container:

dotnet add package Scrutor
Example.cs
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.Decorate<IProductService, CachedProductService>();
builder.Services.Decorate<IProductService, LoggedProductService>();

Each Decorate call wraps the previous registration. This is much cleaner than manual wiring.

A Retry Decorator

Decorators are perfect for resilience patterns:

RetryProductService.cs
public class RetryProductService : IProductService
{
    private readonly IProductService _inner;
    private readonly ILogger<RetryProductService> _logger;
    private const int MaxRetries = 3;

    public RetryProductService(IProductService inner, ILogger<RetryProductService> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        for (var attempt = 1; attempt <= MaxRetries; attempt++)
        {
            try
            {
                return await _inner.GetByIdAsync(id, ct);
            }
            catch (Exception ex) when (attempt < MaxRetries)
            {
                _logger.LogWarning(ex,
                    "Attempt {Attempt} failed for product {ProductId}, retrying",
                    attempt, id);

                await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), ct);
            }
        }

        return await _inner.GetByIdAsync(id, ct);
    }

    // Similar for SearchAsync...
    public Task<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct)
        => _inner.SearchAsync(query, ct);
}

Stacking Decorators

You can layer multiple decorators:

Example.cs
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.Decorate<IProductService, RetryProductService>();
builder.Services.Decorate<IProductService, CachedProductService>();
builder.Services.Decorate<IProductService, LoggedProductService>();

The call chain: Logging -> Cache -> Retry -> ProductService. Each layer handles one concern, and the core service knows nothing about any of them.

When to Use Decorators

Decorators excel at cross-cutting concerns that apply to many services: caching, logging, metrics, retry logic, circuit breakers, and authorisation checks. They keep your core services focused on business logic while adding operational concerns at the composition root.

The pattern works best when you have a well-defined interface. If your service interface changes frequently, maintaining multiple decorators becomes tedious. For stable interfaces with common cross-cutting needs, decorators are one of the most useful patterns in your toolkit.