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.
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
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.
dotnet add package Scrutorbuilder.Services.AddScoped<IProductService, ProductService>();
builder.Services.Decorate<IProductService, CachingProductService>();
builder.Services.Decorate<IProductService, LoggingProductService>();
// Order: Logging → Caching → ProductService (outer to inner)Manual DI Registration
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
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
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
- Decorators wrap the same interface — callers are unaware of the wrapping
- Each decorator has one responsibility — don't combine caching + logging in one decorator
- Use Scrutor (
Decorate<>) to register decorators cleanly in ASP.NET Core - Stack order matters:
LoggingDecorator → CachingDecorator → CoreServicelogs every call, even cache hits - 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.