dotnet/Design Patterns
Decorator via DI
intermediateTL;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 IOrderService
— inner — 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.DependencyInjectiongives 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 addsDecorateon top of the built-in container. - Hand-rolling? Register the concrete inner type, not the interface.
services.AddScoped<OrderService>()— notAddScoped<IOrderService, OrderService>()— before wiring the factory. If you registerIOrderService→OrderServiceand then resolveIOrderServiceinside the decorator’s factory, you resolve the decorator itself and recurse. - A decorator can’t safely outlive what it wraps. Register
LoggingOrderServiceas a singleton around a scopedOrderServiceand 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 (andDbContext-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 unboundIValidator<>. Scrutor’sDecorate(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.