~/handys11
Browse the docs

dotnet/Design Patterns

Decorator via DI

intermediate

TL;DR

public interface IOrderService
{
    Task PlaceAsync(Order order);
}

public sealed class OrderService : IOrderService
{
    public Task PlaceAsync(Order order) => /* the real work */ Task.CompletedTask;
}

// The decorator implements the same interface and wraps "inner".
public sealed class LoggingOrderService(
    IOrderService inner,
    ILogger<LoggingOrderService> log) : IOrderService
{
    public async Task PlaceAsync(Order order)
    {
        log.LogInformation("Placing order {OrderId}", order.Id);
        await inner.PlaceAsync(order);
        log.LogInformation("Placed order {OrderId}", order.Id);
    }
}

// Manual decoration with the built-in container:
services.AddScoped<OrderService>();
services.AddScoped<IOrderService>(sp => new LoggingOrderService(
    sp.GetRequiredService<OrderService>(),
    sp.GetRequiredService<ILogger<LoggingOrderService>>()));

// Or with Scrutor, registration order = wrapping order:
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, LoggingOrderService>();

How it works

The decorator pattern is classic Gang-of-Four: same interface as the thing it wraps, composition instead of inheritance. LoggingOrderService implements IOrderService exactly like OrderService does, but instead of doing the real work itself, it holds a reference to another IOrderServiceinner — and delegates to it.

Each decorator owns exactly one cross-cutting concern: logging, caching, retries, metrics, authorization. It does its bit, calls inner, optionally does more after the call returns (or in a catch, for retry and error-handling decorators), and otherwise gets out of the way. Nothing about the decorator’s own logic depends on what OrderService.PlaceAsync actually does — that’s what makes it composable.

Consumers never see any of this. Code that takes an IOrderService dependency keeps injecting and calling IOrderService — it has no idea whether it’s talking to the bare OrderService, or OrderService wrapped in logging, wrapped in caching, wrapped in a retry policy. That’s the whole point of depending on the interface rather than the concrete type.

Stacking order

Order matters, and it’s determined by registration, not by reading the file top to bottom. With Scrutor, services.Decorate<IOrderService, LoggingOrderService>() wraps whatever IOrderService is currently registered — call Decorate a second time with a different decorator and that one wraps the first decorator, becoming the new outermost layer. The last Decorate call registered is the outermost decorator; the first thing registered (AddScoped<IOrderService, OrderService>()) is always the innermost — the one that does the real work.

It’s a pipeline, one service at a time

A decorator chain is conceptually the same shape as ASP.NET Core middleware or a MediatR IPipelineBehavior<TRequest, TResponse> pipeline: each layer wraps the next and gets a chance to act before and after. The difference is scope — middleware wraps the whole HTTP request, pipeline behaviors wrap a single MediatR request, and a decorator wraps one specific service. Reach for whichever matches how broadly the cross-cutting concern needs to apply.

Gotchas

  • The built-in container has no first-class Decorate. Microsoft.Extensions.DependencyInjection gives you constructor injection and lifetimes, not decoration. You either hand-write the factory lambda shown above — verbose, but zero extra dependencies — or bring in Scrutor, which adds Decorate on top of the built-in container.
  • Hand-rolling? Register the concrete inner type, not the interface. services.AddScoped<OrderService>() — not AddScoped<IOrderService, OrderService>() — before wiring the factory. If you register IOrderServiceOrderService and then resolve IOrderService inside the decorator’s factory, you resolve the decorator itself and recurse.
  • A decorator can’t safely outlive what it wraps. Register LoggingOrderService as a singleton around a scoped OrderService and the container resolves that scoped instance exactly once, at the singleton’s construction — every call after the first reuses it, a classic captive-dependency bug (and DbContext-backed services throw once the scope that created them disposes it). Keep the decorator’s lifetime at or below the lifetime of the thing it wraps.
  • Open generics are painful to decorate by hand. A factory lambda needs a closed Func<IServiceProvider, TService>, which doesn’t exist for an unbound IValidator<>. Scrutor’s Decorate(typeof(IValidator<>), typeof(LoggingValidator<>)) handles the open-generic case through reflection; the manual approach mostly doesn’t.
  • Every service needing the same three decorators is a signal, not a coincidence. N services times M decorators registered by hand doesn’t scale past a handful. That’s what a pipeline — ASP.NET Core middleware, MediatR behaviors — is for: write the cross-cutting concern once, apply it everywhere, instead of decorating every service individually.

Further reading