Skip to content

[API Proposal]: Decorator support in Microsoft.Extensions.DependencyInjection #129177

@rosebyte

Description

@rosebyte

The decorator pattern has been a long-standing request (#36021, ~5 years open). Every other mainstream .NET DI container supports it natively: Autofac, LightInject, DryIoc, SimpleInjector, Lamar. Scrutor offers a conservative subset on top of MSDI but cannot express the parts that need container cooperation (open generics, applies-to-every-element of IEnumerable, applies-to-registrations-added-later).

In #36021 the request was framed as adding Decorate to IServiceCollection, which lives in Microsoft.Extensions.DependencyInjection.Abstractions. This proposal places the feature in MEDI (Microsoft.Extensions.DependencyInjection) which contains MSDI (the default container) and makes it explicitly MSDI-only. Other containers see an opaque descriptor whose ImplementationFactory throws if invoked; the descriptor type is internal.

API Proposal

 namespace Microsoft.Extensions.DependencyInjection;
 
 public static class DecoratorServiceCollectionExtensions
 {
     public static IServiceCollection Decorate<TService>(
         this IServiceCollection services,
         Func<IServiceProvider, TService, TService> decorator)
         where TService : class;
 
     public static IServiceCollection Decorate<TService,
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TDecorator>(
         this IServiceCollection services)
         where TService : class
         where TDecorator : TService;
 
     public static IServiceCollection Decorate(
         this IServiceCollection services,
         Type serviceType,
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type decoratorType);
 
     public static IServiceCollection DecorateKeyed<TService>(
         this IServiceCollection services,
         object? serviceKey,
         Func<IServiceProvider, object?, TService, TService> decorator)
         where TService : class;
 
     public static IServiceCollection DecorateKeyed<TService,
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TDecorator>(
         this IServiceCollection services,
         object? serviceKey)
         where TService : class
         where TDecorator : TService;
 
     public static IServiceCollection DecorateKeyed(
         this IServiceCollection services,
         object? serviceKey,
         Type serviceType,
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type decoratorType);
 }

Semantics

  1. Missing decoratee. Resolving IFoo when only decorators (and no underlying registration) exist for it returns null from GetService and throws from GetRequiredService, exactly as if no service were registered. The decorator descriptors are silently ignored on that resolution.
  2. Composition. Multiple decorators compose in registration order; the earlier one ends up nearer the decoratee, the later one wraps the result. Decorate<IFoo, A>(); Decorate<IFoo, B>(); resolves as B(A(Foo)).
  3. Lifetime. A decorator has no lifetime of its own. It lives exactly as long as its decoratee, is scoped exactly as its decoratee, and is disposed by the same scope. There is no API to override this. (Matches Autofac's explicit policy.)
  4. KeyedService.AnyKey. DecorateKeyed(KeyedService.AnyKey, …) decorates every keyed registration of TService. AnyKey decorators interleave with specific-key decorators by descriptor-list position when composing.
  5. IEnumerable. Each element of the enumerable is decorated independently with the full decorator chain. There is no way to decorate the enumerable as a whole.
  6. Open generics. Decorate(typeof(IFoo<>), typeof(LoggingFoo<>)) decorates every closed-generic resolution of IFoo. A closed-generic decorator over an open-generic decoratee applies only when the matching closed generic is resolved. Open and closed decorators compose in descriptor-list order.
  7. Constructor selection for typed decorators. Decorate<TService, TDecorator>() invokes the TDecorator constructor that takes a TService parameter; the TService argument is the inner decoratee, every other parameter is resolved from the container. Same algorithm and error conditions as ActivatorUtilities.CreateFactory(typeof(TDecorator), new[] { typeof(TService) }).
  8. Validation. CallSiteValidator treats the decoratee position on a decorator as already bound, so IFoo decorating IFoo is not a cycle. Cycles in the decorator's other (container-resolved) ctor parameters are validated normally.

Unsupported flow

If the IServiceCollection is consumed by a container that doesn't support the decorator mechanism, the decorator descriptors are seen as opaque factory descriptors whose ImplementationFactory (or KeyedImplementationFactory) throws InvalidOperationException on invocation. Decoratee registrations are untouched and resolve normally. The decorators contribute nothing to the decoratee resolution but loudly fail if anything in user code enumerates and invokes them.

API Usage

 // Factory form
 services.AddSingleton<IFoo, Foo>();
 services.Decorate<IFoo>((sp, inner) =>
     new LoggingFoo(inner, sp.GetRequiredService<ILogger<LoggingFoo>>()));
 
 // Typed form, composes with the above
 services.Decorate<IFoo, MetricsFoo>();
 // IFoo resolves as MetricsFoo(LoggingFoo(Foo))
 
 // Open generics
 services.AddScoped(typeof(ICommandHandler<>), typeof(CommandHandler<>));
 services.Decorate(typeof(ICommandHandler<>), typeof(TransactionalCommandHandler<>));
 
 // IEnumerable: every element is decorated
 services.AddSingleton<IBar, BarA>();
 services.AddSingleton<IBar, BarB>();
 services.Decorate<IBar, CachingBar>();
 // GetServices<IBar>() returns [CachingBar(BarA), CachingBar(BarB)]
 
 // Keyed
 services.AddKeyedSingleton<IFoo, Foo>("primary");
 services.DecorateKeyed<IFoo, LoggingFoo>("primary");
 
 // Keyed with AnyKey: decorate every keyed IFoo regardless of key
 services.AddKeyedSingleton<IFoo, FooA>("a");
 services.AddKeyedSingleton<IFoo, FooB>("b");
 services.DecorateKeyed<IFoo, LoggingFoo>(KeyedService.AnyKey);
 // GetKeyedService<IFoo>("a") -> LoggingFoo(FooA)
 // GetKeyedService<IFoo>("b") -> LoggingFoo(FooB)
 
 // Decorator constructor: first IFoo parameter is the decoratee,
 // the rest resolved from the container
 public sealed class LoggingFoo(IFoo inner, ILogger<LoggingFoo> logger) : IFoo;

Risks

  1. AnyKey decorator semantics are novel. No third-party container has a direct equivalent because MSDI's keyed-services + AnyKey model is itself newer than most of those containers' decorator stories. The proposed behaviour (registration-order-authoritative composition between AnyKey and specific-key decorators) is consistent with how AnyKey works on the registration side, but it does not have a precedent we can point to during API review.
  2. The tripwire throw is a sharp edge for users mixing containers. Anyone who pipes the same IServiceCollection through MSDI for some scenarios and through, say, Autofac for others, will hit the throw if any decorator descriptors are present at the non-MSDI path. The exception message should be explicit about cause and remediation. Silent partial degradation was considered worse than a loud throw.
  3. Surface growth in a hot path. A new descriptor type, a new CallSiteKind, and branches in the visitor / runtime resolver / expression resolver / IL-emit resolver. Performance regression testing required before completion.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions