.NET & C# Development · Lesson 48 of 92
Decorator Pattern — Add Caching Without Touching Business Logic
The Pattern in One Sentence
A decorator wraps an object with the same interface and adds behaviour before or after delegating to the wrapped object — without changing the original class.
Client → CachedOrderRepository → OrderRepository → Database
(checks cache first) (real implementation)The client doesn't know or care which layer it's talking to.
The Interface and Real Implementation
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default);
Task SaveAsync(Order order, CancellationToken ct = default);
}
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await _db.Orders.FindAsync(new object[] { id }, ct);
public async Task<List<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default)
=> await _db.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
public async Task SaveAsync(Order order, CancellationToken ct = default)
{
_db.Orders.Update(order);
await _db.SaveChangesAsync(ct);
}
}Decorating With a Cache
public class CachedOrderRepository : IOrderRepository
{
private readonly IOrderRepository _inner;
private readonly IMemoryCache _cache;
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
public CachedOrderRepository(IOrderRepository inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
var key = $"order:{id}";
if (_cache.TryGetValue(key, out Order? cached))
return cached;
var order = await _inner.GetByIdAsync(id, ct);
if (order is not null)
_cache.Set(key, order, Ttl);
return order;
}
public Task<List<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default)
// skip caching for list queries — cache invalidation is harder here
=> _inner.GetByCustomerAsync(customerId, ct);
public async Task SaveAsync(Order order, CancellationToken ct = default)
{
await _inner.SaveAsync(order, ct);
// Bust the cache on write
_cache.Remove($"order:{order.Id}");
}
}Decorating a Service With Logging
public interface IEmailService
{
Task SendAsync(string to, string subject, string body, CancellationToken ct = default);
}
public class SmtpEmailService : IEmailService
{
private readonly SmtpClient _client;
public SmtpEmailService(SmtpClient client) => _client = client;
public async Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
{
var message = new MailMessage("no-reply@example.com", to, subject, body);
await _client.SendMailAsync(message, ct);
}
}
public class LoggingEmailService : IEmailService
{
private readonly IEmailService _inner;
private readonly ILogger<LoggingEmailService> _logger;
public LoggingEmailService(IEmailService inner, ILogger<LoggingEmailService> logger)
{
_inner = inner;
_logger = logger;
}
public async Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
{
_logger.LogInformation("Sending email to {To} — subject: {Subject}", to, subject);
var sw = Stopwatch.StartNew();
try
{
await _inner.SendAsync(to, subject, body, ct);
_logger.LogInformation("Email sent in {ElapsedMs}ms", sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {To}", to);
throw;
}
}
}Registering With Scrutor
Without Scrutor, you'd have to wire decorators manually. With it, one line does it:
dotnet add package Scrutorbuilder.Services.AddMemoryCache();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.Decorate<IOrderRepository, CachedOrderRepository>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.Decorate<IEmailService, LoggingEmailService>();Scrutor resolves OrderRepository, wraps it in CachedOrderRepository, and registers that as the IOrderRepository implementation. The injection order follows the decoration order.
Chaining Multiple Decorators
Each .Decorate() call adds another layer. The last one registered is the outermost:
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.Decorate<IOrderRepository, CachedOrderRepository>(); // layer 1
builder.Services.Decorate<IOrderRepository, LoggingOrderRepository>(); // layer 2 (outermost)Call chain: Client → Logging → Cache → Real
The logging decorator sees every call. The cache may short-circuit before the real repository is hit.
Comparison With MediatR Pipeline Behaviors
MediatR behaviors and decorators solve related but different problems:
| Concern | Decorator | MediatR Behavior |
|---|---|---|
| Scope | One specific interface | All requests through the pipeline |
| Granularity | Per-service | Per-request type |
| When to use | Service-level cross-cutting (cache, retry) | Application-layer concerns (validation, logging, transactions) |
| Registration | Scrutor .Decorate() | AddTransient(typeof(IPipelineBehavior<,>), ...) |
A logging behavior in MediatR logs every command and query uniformly. A logging decorator on IEmailService logs only email operations with richer context (recipient, subject). Both have their place.
When Decorators Make Sense vs When They Don't
Use decorators when:
- You want to add behaviour (caching, retry, logging, metrics) to an existing service without editing it
- The added behaviour is optional or environment-specific (e.g., cache only in production)
- You're following Open/Closed Principle — the original class is closed for modification
- You need to compose behaviour at registration time based on configuration
Skip decorators when:
- The cross-cutting concern applies to every command/query — a MediatR behavior is cleaner
- You only have one implementation and no realistic reason to swap — it's over-engineering
- The decorator needs access to internals of the wrapped class — the abstraction is leaking
Decorators work best when the interface boundary is stable and the wrapped behaviour is truly additive.