.NETMedium
Decorator pattern in C# — cross-cutting concerns without inheritance
The Decorator pattern wraps an object in another object that implements the same interface, adding behavior without subclassing or modifying the original.
Canonical .NET use case — caching a service
public interface IProductRepository {
Task<Product?> GetAsync(Guid id);
}
public class SqlProductRepository(AppDb db) : IProductRepository {
public Task<Product?> GetAsync(Guid id) => db.Products.FindAsync(id).AsTask();
}
public class CachingProductRepository(IProductRepository inner, IMemoryCache cache)
: IProductRepository
{
public Task<Product?> GetAsync(Guid id) =>
cache.GetOrCreateAsync($"product:{id}", async e => {
e.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
return await inner.GetAsync(id);
});
}
Wiring with DI (manual)
builder.Services.AddScoped<SqlProductRepository>();
builder.Services.AddScoped<IProductRepository>(sp => {
var inner = sp.GetRequiredService<SqlProductRepository>();
var cache = sp.GetRequiredService<IMemoryCache>();
return new CachingProductRepository(inner, cache);
});
Wiring with Scrutor (one line)
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.Decorate<IProductRepository, CachingProductRepository>();
builder.Services.Decorate<IProductRepository, LoggingProductRepository>();
Order matters — last Decorate call is the outermost wrapper. Above: incoming call hits Logging then Caching then Sql.
Why decoration beats inheritance for cross-cutting concerns
| Subclassing | Decoration |
|---|---|
| One feature per subclass — combinatorial explosion | Compose features at runtime |
| Tied to a single base implementation | Works with any IFoo |
| Easy to break LSP | Each layer only adds behavior |
| Tight coupling | Each decorator depends only on the interface |
When NOT to use the decorator pattern
- You only need one fixed combination —
class X : Yis simpler. - The "added behavior" needs access to internal state of the wrapped object — extension or partial class instead.
- The interface is large (>10 methods) — every decorator has to implement them all. Consider abstract base decorator that forwards by default.
Real-world combos that actually appear
Logging(Retry(CircuitBreaker(Caching(Real))))— request goes through every layer; one fault domain at a time.Authorization(InputValidation(Caching(Real)))— defense in depth.
Polly.HttpClientFactory is the canonical decorator stack you already use.