Decorators Without a Library
Wrap a service in caching or logging decorators with factory registrations, control their order, and know when a scanning library is worth it.
services.AddScoped<SqlProductCatalog>();
services.AddScoped<IProductCatalog>(sp => new CachingProductCatalog(
sp.GetRequiredService<SqlProductCatalog>(), sp.GetRequiredService<IMemoryCache>()));A decorator implements the same interface as the service it wraps and adds behaviour around the calls: caching, logging, metrics, retries, authorisation checks. Callers depend on IProductCatalog and never know whether they got the real catalog or a chain of wrappers. The built-in container has no Decorate method, but it does not need one: a factory registration builds the chain explicitly.
The registration
- Register the real implementation under its concrete type:
AddScoped<SqlProductCatalog>(). It is not registered as the interface, so nobody resolves it by accident. - Register the interface with a factory that resolves the inner one and wraps it:
AddScoped<IProductCatalog>(sp => new CachingProductCatalog(sp.GetRequiredService<SqlProductCatalog>(), sp.GetRequiredService<IMemoryCache>())). - For more than one layer, nest them. The outermost wrapper runs first.
ActivatorUtilities.CreateInstance<T>(sp, inner) builds the decorator while resolving its other constructor parameters from the container, so the factory does not need to list every dependency. A small generic Decorate<TService, TDecorator>() extension built on it replaces the existing descriptor and wraps whatever was registered.
Ordering matters
Logging(Caching(Sql)) logs every call, including cache hits. Caching(Logging(Sql)) logs only database calls. Decide what each layer is meant to observe and write the order down in one place, not spread over several extension methods.
Lifetimes
A decorator's lifetime must not exceed its inner service's. A singleton caching decorator around a scoped repository is a captive dependency. ValidateOnBuild cannot see it because the factory hides the graph; ValidateScopes catches it only when the singleton is first resolved, as a Cannot resolve scoped service from root provider error, and Production usually has it off. Keep the decorator at the inner service's lifetime and put long-lived state (the cache) in a singleton it depends on, such as IMemoryCache.
Failure modes
- Resolving
IProductCataloginside its own factory recurses until the process dies with a stack overflow, which cannot be caught and leaves no exception log. Always resolve the concrete inner type or a keyed registration. - Caching data that is per user in a shared cache, with a key that does not include the user or tenant.
- Decorating an interface with many members, where the decorator must forward each one; forgotten members silently bypass the wrapper.
| Option | Choose when |
|---|---|
| Factory registration | A few decorated services; explicit and dependency-free |
Own Decorate helper | The same wrapping repeated across many services |
| Scrutor-style library | Assembly scanning plus decoration of open generics such as every ICommandHandler<T> |
DelegatingHandler | Cross-cutting behaviour for outgoing HttpClient calls; already a decorator chain |
Example
When to use it
- Put a 10-minute IMemoryCache in front of a product catalog repository without changing a single endpoint or handler
- Log duration and outcome of every IPaymentGateway.Charge call by wrapping the gateway once in a logging decorator
- Add an authorisation check around IInvoiceService so only the invoice's own customer can download its PDF
More examples
A reusable Decorate helper
The helper swaps the existing descriptor in place for a factory with the same lifetime. <code>ActivatorUtilities.CreateInstance</code> passes the inner instance explicitly and resolves the rest (here the logger) from the container. One caveat: an inner type built through <code>ActivatorUtilities</code> is not tracked for disposal unless it is resolved from the container, so keep disposable inner services registered under their concrete type.
using Microsoft.Extensions.Logging;
public static class DecorationExtensions
{
// Wraps the most recent registration of TService in TDecorator, keeping its lifetime.
public static IServiceCollection Decorate<TService, TDecorator>(this IServiceCollection services)
where TService : class
where TDecorator : class, TService
{
var index = -1;
for (var i = services.Count - 1; i >= 0; i--)
{
if (services[i].ServiceType == typeof(TService) && !services[i].IsKeyedService)
{
index = i;
break;
}
}
if (index < 0)
throw new InvalidOperationException($"{typeof(TService).Name} must be registered before it can be decorated.");
var inner = services[index];
services[index] = ServiceDescriptor.Describe(
typeof(TService),
sp => ActivatorUtilities.CreateInstance<TDecorator>(sp, CreateInner(sp, inner)),
inner.Lifetime);
return services;
}
private static object CreateInner(IServiceProvider sp, ServiceDescriptor d) =>
d.ImplementationInstance
?? d.ImplementationFactory?.Invoke(sp)
?? ActivatorUtilities.GetServiceOrCreateInstance(sp, d.ImplementationType!);
}
public interface IInvoiceService
{
Task IssueAsync(string number, decimal amount);
}
public sealed class InvoiceService : IInvoiceService
{
public Task IssueAsync(string number, decimal amount) => Task.CompletedTask;
}
public sealed class LoggingInvoiceService(IInvoiceService inner, ILogger<LoggingInvoiceService> logger) : IInvoiceService
{
public async Task IssueAsync(string number, decimal amount)
{
logger.LogInformation("Issuing invoice {Number} for {Amount}", number, amount);
await inner.IssueAsync(number, amount);
}
}
// Usage: services.AddScoped<IInvoiceService, InvoiceService>().Decorate<IInvoiceService, LoggingInvoiceService>();Inner service as a keyed registration
Registering the inner implementation under a key keeps the decorator a normal constructor-injected class, so <code>ValidateOnBuild</code> can check its whole graph, which a factory lambda hides. The key name <code>"inner"</code> is local to this feature; nothing else should resolve it.
using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
// The real implementation lives under a key; only the decorator answers unkeyed requests.
builder.Services.AddKeyedScoped<ICustomerDirectory, DbCustomerDirectory>("inner");
builder.Services.AddScoped<ICustomerDirectory, CachingCustomerDirectory>();
var app = builder.Build();
app.MapGet("/customers/{id:int}/name", (int id, ICustomerDirectory customers) => customers.NameOf(id));
app.Run();
public interface ICustomerDirectory
{
string NameOf(int customerId);
}
public sealed class DbCustomerDirectory : ICustomerDirectory
{
public string NameOf(int customerId) => customerId == 7 ? "Ana Ruiz" : "unknown";
}
public sealed class CachingCustomerDirectory(
[FromKeyedServices("inner")] ICustomerDirectory inner,
IMemoryCache cache) : ICustomerDirectory
{
public string NameOf(int customerId) =>
cache.GetOrCreate($"customer-name:{customerId}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15);
return inner.NameOf(customerId);
})!;
}Unit test for the caching decorator
Decorators are easy to test because the inner service is just an interface: a substitute records calls and <code>Received(1)</code> proves the cache absorbed the second lookup. A real <code>MemoryCache</code> is cheaper and more honest than faking <code>IMemoryCache</code>, whose extension methods are hard to substitute.
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using Xunit;
public sealed record Product(string Sku, string Name, decimal Price);
public interface IProductCatalog
{
Task<Product?> FindAsync(string sku);
}
public sealed class CachingProductCatalog(IProductCatalog inner, IMemoryCache cache) : IProductCatalog
{
public async Task<Product?> FindAsync(string sku)
{
if (cache.TryGetValue($"product:{sku}", out Product? cached)) return cached;
var product = await inner.FindAsync(sku);
if (product is not null) cache.Set($"product:{sku}", product, TimeSpan.FromMinutes(10));
return product;
}
}
public class CachingProductCatalogTests
{
[Fact]
public async Task Second_lookup_does_not_reach_the_inner_catalog()
{
var inner = Substitute.For<IProductCatalog>();
inner.FindAsync("KETTLE-01").Returns(new Product("KETTLE-01", "Blue kettle", 39.90m));
using var cache = new MemoryCache(new MemoryCacheOptions());
var catalog = new CachingProductCatalog(inner, cache);
await catalog.FindAsync("KETTLE-01");
var second = await catalog.FindAsync("KETTLE-01");
Assert.Equal("Blue kettle", second?.Name);
await inner.Received(1).FindAsync("KETTLE-01");
}
}
Discussion