Back to blog
Backend Systemsbeginner

Decorator Pattern in C#

Add behavior to objects dynamically without modifying their class. Learn the Decorator pattern with C# examples: caching, logging, validation, and retry decorators.

Asma HafeezApril 17, 20263 min read
csharpdesign-patternsdecoratordotnetsolid
Share:𝕏

Decorator Pattern

Decorator wraps an object with additional behavior without changing the wrapped class. Decorators implement the same interface as the object they wrap — callers can't tell the difference.


The Core Idea

IProductService
    ↑
ProductService         ← core implementation
    ↑ wrapped by
CachingProductService  ← adds caching (calls core)
    ↑ wrapped by
LoggingProductService  ← adds logging (calls caching)

The caller uses IProductService and never knows what's underneath.


Basic Decorator

C#
public interface IProductService
{
    Task<Product?> GetByIdAsync(int id);
    Task<IEnumerable<Product>> SearchAsync(string query);
}

// Core implementation
public class ProductService(AppDbContext db) : IProductService
{
    public async Task<Product?> GetByIdAsync(int id)
        => await db.Products.FindAsync(id);

    public async Task<IEnumerable<Product>> SearchAsync(string query)
        => await db.Products.Where(p => p.Name.Contains(query)).ToListAsync();
}

// Caching decorator
public class CachingProductService(IProductService inner, IMemoryCache cache) : IProductService
{
    public async Task<Product?> GetByIdAsync(int id)
    {
        var key = $"product:{id}";
        if (cache.TryGetValue(key, out Product? cached)) return cached;

        var product = await inner.GetByIdAsync(id);  // delegate to inner
        if (product is not null)
            cache.Set(key, product, TimeSpan.FromMinutes(5));
        return product;
    }

    public async Task<IEnumerable<Product>> SearchAsync(string query)
        => await inner.SearchAsync(query);  // no caching for search
}

// Logging decorator
public class LoggingProductService(IProductService inner, ILogger<LoggingProductService> logger) : IProductService
{
    public async Task<Product?> GetByIdAsync(int id)
    {
        logger.LogInformation("Getting product {Id}", id);
        var sw = Stopwatch.StartNew();
        var result = await inner.GetByIdAsync(id);
        logger.LogInformation("Got product {Id} in {Ms}ms, found={Found}", id, sw.ElapsedMilliseconds, result is not null);
        return result;
    }

    public async Task<IEnumerable<Product>> SearchAsync(string query)
    {
        logger.LogInformation("Searching products: {Query}", query);
        return await inner.SearchAsync(query);
    }
}

Register Decorators in ASP.NET Core (Scrutor)

Manual registration is verbose. Scrutor makes it one line.

Bash
dotnet add package Scrutor
C#
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.Decorate<IProductService, CachingProductService>();
builder.Services.Decorate<IProductService, LoggingProductService>();
// Order: Logging → Caching → ProductService (outer to inner)

Manual DI Registration

C#
builder.Services.AddScoped<ProductService>();
builder.Services.AddScoped<IProductService>(provider =>
{
    var core    = provider.GetRequiredService<ProductService>();
    var cache   = provider.GetRequiredService<IMemoryCache>();
    var logger  = provider.GetRequiredService<ILogger<LoggingProductService>>();

    IProductService service = new CachingProductService(core, cache);
    service = new LoggingProductService(service, logger);
    return service;
});

Retry Decorator

C#
public class RetryingProductService(IProductService inner, ILogger<RetryingProductService> logger) : IProductService
{
    private const int MaxRetries = 3;

    public async Task<Product?> GetByIdAsync(int id)
    {
        for (int attempt = 1; attempt <= MaxRetries; attempt++)
        {
            try { return await inner.GetByIdAsync(id); }
            catch (HttpRequestException ex) when (attempt < MaxRetries)
            {
                logger.LogWarning("Attempt {Attempt} failed: {Message}. Retrying...", attempt, ex.Message);
                await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt));
            }
        }
        return await inner.GetByIdAsync(id);  // last attempt — let it throw
    }

    public Task<IEnumerable<Product>> SearchAsync(string query)
        => inner.SearchAsync(query);
}

Validation Decorator

C#
public interface IOrderService
{
    Task<Order> PlaceOrderAsync(PlaceOrderRequest request);
}

public class ValidatingOrderService(IOrderService inner) : IOrderService
{
    public async Task<Order> PlaceOrderAsync(PlaceOrderRequest request)
    {
        if (request.Items.Count == 0)
            throw new ArgumentException("Order must have at least one item");
        if (request.Items.Any(i => i.Quantity <= 0))
            throw new ArgumentException("All quantities must be positive");

        return await inner.PlaceOrderAsync(request);
    }
}

Decorator vs Inheritance

| | Decorator | Inheritance | |---|---|---| | Composition | Runtime | Compile-time | | Multiple behaviors | Stack decorators | Multiple inheritance (fragile) | | Single Responsibility | Each decorator has one job | Superclass grows | | Flexibility | Add/remove at DI level | Code change required |


Key Takeaways

  1. Decorators wrap the same interface — callers are unaware of the wrapping
  2. Each decorator has one responsibility — don't combine caching + logging in one decorator
  3. Use Scrutor (Decorate<>) to register decorators cleanly in ASP.NET Core
  4. Stack order matters: LoggingDecorator → CachingDecorator → CoreService logs every call, even cache hits
  5. Decorator is the open/closed principle in action — add behavior without modifying existing classes

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.