Back to blog
Backend Systemsintermediate

Decorator Pattern — Add Caching and Logging Without Touching Business Logic

Wrap existing services with caching and logging decorators, register them with Scrutor, chain multiple decorators, and know when this pattern beats MediatR pipeline behaviors.

LearnixoApril 14, 20264 min read
.NETC#Design PatternsDependency InjectionScrutorCachingASP.NET Core
Share:𝕏

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

C#
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

C#
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

C#
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:

Bash
dotnet add package Scrutor
C#
builder.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:

C#
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.

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.