Remigiusz ZalewskiRemigiusz Zalewski

Mastering the Decorator Pattern in ASP .NET Core applications

decorator-pattern-aspnet-core-scrutor

Introduction

You have a StudentService that reads from the database. Now you want to log how long each call takes. The tempting move is to open StudentService and drop a Stopwatch into every method. Do that for caching too, and retry, and you end up with a class that is 20% domain logic and 80% cross-cutting concerns tangled together.

The decorator pattern keeps them apart. You write a second class that implements the same interface, takes the real service as a dependency, adds its behavior around the call, and delegates the actual work. The consumer keeps depending on the interface and never knows it got a decorated instance. This video builds a performance-logging decorator in ASP.NET Core on .NET 9 and wires it up with Scrutor.

🎬 Watch the full video here:


The shape of a decorator

Three ingredients: a shared interface, the real implementation, and a decorator that implements the interface and holds a reference to the interface.

public interface IStudentService
{
    Task<IEnumerable<Student>> GetStudentsAsync();
}
public class PerformanceLoggingStudentService : IStudentService
{
    private readonly IStudentService _inner;
    private readonly ILogger<PerformanceLoggingStudentService> _logger;

    public PerformanceLoggingStudentService(IStudentService inner, ILogger<...> logger)
        => (_inner, _logger) = (inner, logger);

    public async Task<IEnumerable<Student>> GetStudentsAsync()
    {
        var sw = Stopwatch.StartNew();
        var result = await _inner.GetStudentsAsync();
        sw.Stop();
        _logger.LogInformation("Elapsed ms: {ElapsedMs}", sw.ElapsedMilliseconds);
        return result;
    }
}

The decorator does not know or care whether _inner is the real StudentService or another decorator. That is what lets you stack them.

Wiring it without Scrutor is awkward

The built-in .NET DI container has no first-class decorator support. To do it by hand you register the concrete service, then register the interface with a factory that resolves the concrete one and passes it to the decorator's constructor:

builder.Services.AddScoped<StudentService>();
builder.Services.AddScoped<IStudentService>(sp =>
    new PerformanceLoggingStudentService(
        sp.GetRequiredService<StudentService>(),
        sp.GetRequiredService<ILogger<PerformanceLoggingStudentService>>()));

It works, but it is verbose, it leaks the concrete type into DI, and it gets worse with every decorator you add.

Scrutor makes it one line

Scrutor adds a Decorate extension method. Register the service normally, then decorate it:

builder.Services.AddScoped<IStudentService, StudentService>();
builder.Services.Decorate<IStudentService, PerformanceLoggingStudentService>();

Scrutor finds the existing IStudentService registration, wraps it, and re-registers so that anyone asking for IStudentService now gets the decorator with the original service injected as its inner dependency. The endpoint below never changes:

app.MapGet("/students", async (IStudentService studentService)
    => Results.Ok(await studentService.GetStudentsAsync()));

Stacking decorators

Call Decorate multiple times and the order matters - each call wraps whatever is currently registered:

builder.Services.AddScoped<IStudentService, StudentService>();
builder.Services.Decorate<IStudentService, CachingStudentService>();
builder.Services.Decorate<IStudentService, PerformanceLoggingStudentService>();

Here a request flows: logging decorator, then caching decorator, then the real service. The logging decorator measures the caching decorator's time (so a cache hit shows as near-zero), which is usually what you want. Reverse the two Decorate calls and logging would only measure genuine database calls. Decide which timing you actually care about and order accordingly.

Where this pays off

  • Caching - check a cache, call the inner service on a miss, store the result. The domain service stays cache-unaware.
  • Logging / metrics / tracing - time calls, count calls, record failures.
  • Retry / circuit breaking - wrap the inner call in a resilience policy.
  • Authorization - check permissions before delegating.

Each of these is a separate class with one job, testable in isolation, and added or removed by a single line in Program.cs.

Decorator vs middleware vs filters

ASP.NET Core already has cross-cutting mechanisms - middleware, action filters, endpoint filters. Those operate at the HTTP boundary. A decorator operates at any interface boundary, including services called by other services, background jobs, and code with no HTTP context at all. Use filters for request/response concerns; use decorators for concerns around a specific service contract.

Common pitfalls

  • Lifetime mismatch. The decorator and the inner service should share a lifetime. A singleton decorator holding a scoped inner service captures the wrong instance.
  • Decorating before registering. Decorate needs an existing registration to wrap; call it after Add....
  • Forgetting to delegate. A decorator that adds behavior but forgets to call _inner silently drops the real work.
  • Order confusion when stacking. Each Decorate wraps the current outermost layer. Read the calls bottom-up to see execution order.
  • Fat decorators. If a decorator does three things, it is three decorators.

Key Takeaways

  • A decorator implements the same interface as the service it wraps and holds a reference to that interface, adding behavior around a delegated call.
  • The consumer keeps depending on the interface and is unaware it received a decorated instance.
  • The built-in container has no decorator support; Scrutor's services.Decorate<TInterface, TDecorator>() does it in one line.
  • Stack decorators with multiple Decorate calls - order determines which layer wraps which.
  • Use decorators for concerns around a service contract (caching, logging, retry); use middleware and filters for HTTP-boundary concerns.
  • Keep decorator and inner-service lifetimes aligned, and keep each decorator single-purpose.

Get the Full Source Code

The complete runnable solution - the student service, the performance-logging decorator, the Scrutor wiring, and a seeded database to see real timings - is available to Patreon supporters. If you want to watch the elapsed-ms logs appear without touching the service instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources