Skip to content

About

Microsoft's Dependency Injection enhancement library

Topics

Resources

Contributing

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

Mammoth.Extensions.DependencyInjection

Build Status

.NET

Introduction

This package offers extensions for the Microsoft.Extensions.DependencyInjection library. It requires Microsoft.Extensions.DependencyInjection version 10.0.0 or later. This minimum includes the upstream fix for keyed enumerable and open-generic resolver cache identities (dotnet/runtime#113343, issue #46).

Installation

Install the package from NuGet:

dotnet add package Mammoth.Extensions.DependencyInjection

The library requires Microsoft.Extensions.DependencyInjection >=10.0.0 and Microsoft.Bcl.AsyncInterfaces >=10.0.0. Consumers pinning DI 8/9 must update their package references; do not suppress a NuGet downgrade conflict. DI 10 contains the upstream cache identity fix: DI 9.0.0 and 9.0.20 can corrupt reused keyed/unkeyed enumerable accessors after background compilation. Fresh-provider tests cannot establish safety. See the native reproduction.

Library compile target Verified test/consumer runtime
netstandard2.0 .NET Framework application targeting net472 on Windows
net8.0 .NET 8
net9.0 .NET 9
net10.0 .NET 10

netstandard2.0 is a library compatibility target, not a runtime. The DI package upgrade does not require retargeting the verified applications. An SDK supporting your application's C# syntax is still required; the examples use C# 12 collection expressions and primary constructors.

Usage

Import Microsoft.Extensions.DependencyInjection and Mammoth.Extensions.DependencyInjection. DependsOn examples also need Mammoth.Extensions.DependencyInjection.Configuration; inspectors need Mammoth.Extensions.DependencyInjection.Inspector, and HostBuilder examples need Microsoft.Extensions.Hosting (and its package). Each example starts with an IServiceCollection services = new ServiceCollection() unless it uses a host or serviceCollection explicitly. Type definitions and registration blocks in each example belong together.

Decorator

Use Decorate<TService, TDecorator>() to wrap an existing service with a decorator.

Decorators must not dispose their injected inner service. Never forward Dispose() or DisposeAsync() to the inner service. DI disposes every container-created original and decorator independently; instances supplied through an instance registration remain caller-owned. A decorator releases only resources it creates and owns itself.

See decorator disposal and ownership for a safe implementation, and the detailed design for native activation, scoped holders, factory layers, and the reasons for this registration strategy.

Both interface-based and class-based services can be decorated. The following examples demonstrate how to decorate services.

Interface-based decoration

public interface ITestService { }

public class TestService : ITestService { }

public class DecoratorService1 : ITestService
{
    private readonly ITestService _service;

    public DecoratorService1(ITestService service)
    {
        _service = service;
    }
}

public class DecoratorService2 : ITestService
{
    private readonly ITestService _service;

    public DecoratorService2(ITestService service)
    {
        _service = service;
    }
}
services.AddTransient<ITestService, TestService>();
services.Decorate<ITestService, DecoratorService1>(); // innermost decorator
services.Decorate<ITestService, DecoratorService2>(); // outermost decorator

Class-based decoration

public class ConcreteService
{
    public virtual string GetValue() => "ConcreteService";
}

public class ConcreteServiceDecorator : ConcreteService
{
    private readonly ConcreteService _inner;

    public ConcreteServiceDecorator(ConcreteService inner)
    {
        _inner = inner;
    }

    public override string GetValue() => $"Decorated({_inner.GetValue()})";
}
services.AddTransient<ConcreteService>();
services.Decorate<ConcreteService, ConcreteServiceDecorator>();

Decorators preserve Singleton, Scoped and Transient lifetimes and native keys for type, instance and factory registrations. Decorate<TService, TDecorator>() wraps only the last exact service-type registration, retaining its position. Repeated calls add outer layers; this API does not register open-generic decorators.

Original implementation-type registrations retain native constructor graph planning: invalid nested, generic, enumerable or cyclic graphs fail before dependency factories run. ValidateOnBuild can reject them at startup, including inherited-key dependencies validated under AnyKey. Factory registrations and non-empty dependency maps remain opaque to native graph inspection. See the registration design.

Each occurrence in the collection is a separate registration, even when the same ServiceDescriptor instance is added more than once. Only the last occurrence is decorated:

IServiceCollection services = new ServiceCollection();
var descriptor = ServiceDescriptor.Transient<ITestService, TestService>();
services.Add(descriptor);
services.Add(descriptor);
services.Decorate<ITestService, DecoratorService1>();

using var provider = services.BuildServiceProvider();
var all = provider.GetServices<ITestService>().ToArray(); // TestService, DecoratorService1
var last = provider.GetRequiredService<ITestService>(); // DecoratorService1

The same interface example can decorate a keyed registration:

services.AddKeyedScoped<ITestService, TestService>("one");
services.Decorate<ITestService, DecoratorService1>();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var decorated = scope.ServiceProvider.GetRequiredKeyedService<ITestService>("one");

Decorator disposal and ownership

Decorators must not dispose their injected inner service, including through DisposeAsync(). DI already owns container-created inner services and every decorator. Forwarding disposal can dispose an inner layer twice; for caller-supplied instances, it violates caller ownership. A decorator that owns no resources does not need to implement IDisposable or IAsyncDisposable merely because its inner service does.

This decorator disposes its own buffer only:

public interface IBufferedService { void Run(); }

public sealed class BufferedServiceDecorator(IBufferedService inner)
    : IBufferedService, IDisposable, IAsyncDisposable
{
    private readonly MemoryStream _buffer = new(); // Owned by this decorator.
    public void Run() { _buffer.WriteByte(1); inner.Run(); }
    public void Dispose() => _buffer.Dispose(); // Never dispose inner here.
    public ValueTask DisposeAsync()
    {
        Dispose(); // Release only the owned buffer; do not call inner.DisposeAsync().
        return default;
    }
}

Dispose the owning scope/provider to release container-owned layers. Use async scope/provider disposal for async-only resources. For AddSingleton<IService>(existingInstance) or its keyed instance overload, the caller remains responsible for existingInstance; a factory returning an object gives DI ownership of its result. See the complete keyed ownership example and the design's runnable scoped example.

DependsOn (requires Keyed Services support)

Use the DependsOn extensions to register a service that depends on specific instances of other services. For example:

public interface ITestService { }

public class TestService : ITestService { }

public class DependentService
{
    private readonly ITestService _service;

    public DependentService(ITestService service)
    {
        _service = service;
    }
}
services.AddKeyedTransient<ITestService, TestService>("one");
services.AddKeyedTransient<ITestService, TestService>("two");
services.AddTransient<DependentService>(dependsOn: new Dependency[] {
  Parameter.ForKey("service").Eq("one")
});

Internally, DependsOn creates a factory function to resolve necessary services and build dependent ones.

The current design is limited to some common use case and it's very similar to the one offered by Castle.Windsor, from which we took inspiration:

  • Inject a specific instance of a service that will be resolved:

    services.AddTransient<DependentService>(dependsOn: new Dependency[] {
      Parameter.ForKey("service").Eq("one")
    });
  • Inject a value matching an actual constructor parameter:

    public class ValueDependentService
    {
        public string Label { get; }
    
        public ValueDependentService(string label)
        {
            Label = label;
        }
    }
    services.AddTransient<ValueDependentService>(dependsOn: new Dependency[] {
      Dependency.OnValue("label", "val1")
    });

This extension supports Singleton, Scoped, Transient and Keyed registrations. Maps match constructor parameter names, not service types; Eq accepts string keys and mapped values must be assignable. Unused map names are ignored, so check names carefully.

Non-empty maps choose a constructor at resolution time. A single [ActivatorUtilitiesConstructor] constructor must be satisfiable; otherwise the unique longest satisfiable public constructor wins. Equal-length ambiguity fails. Named overrides take precedence over explicit-key [FromKeyedServices(key)], [ServiceKey] and ordinary injection; optional defaults apply only to unregistered dependencies, including null and non-null nullable-enum defaults. Rejected constructors do not create dependencies. Empty maps use native DI behavior. Mapped activation needs ordinary/keyed service probes and keyed resolution. [FromKeyedServices] inherits the current key; [FromKeyedServices(null)] uses unkeyed lookup. With AnyKey registrations, resolve a concrete key when inheriting dependencies. Ordinary decoration needs no keyed availability probe for native original type activation; contextual decorator constructors and mapped factories keep their probe requirements.

[ServiceKey] injects only a non-null service key. In unkeyed or null-key registrations it is ignored, allowing another effective keyed attribute, an ordinary service or an optional default to bind the parameter. When both [FromKeyedServices] and [ServiceKey] appear on an untouched parameter, metadata order determines the first effective non-null key binding; a null-key lookup does not suppress a later [ServiceKey]. Named overrides still take precedence.

Constructor failures retain the original exception instance and stack trace; dependency-resolution failures pass through unchanged, including application-thrown TargetInvocationException instances.

For example, use a key attribute and an optional default without registering the optional value:

public class AttributeDependentService
{
    public ITestService Service { get; }
    public int Attempts { get; }

    public AttributeDependentService(
        [FromKeyedServices("one")] ITestService service, int attempts = 3)
    {
        Service = service;
        Attempts = attempts;
    }
}
services.AddKeyedTransient<ITestService, TestService>("one");
services.AddTransient<AttributeDependentService>(dependsOn: new Dependency[] {
    Dependency.OnValue("attempts", 7)
});

Registration Helpers

A set of extension methods provide ways to verify component registrations and manage assemblies for service registration.

ServiceCollection

  • GetServiceDescriptors(Type, bool? isKeyedService = null): returns assignable service descriptors, with keyed/unkeyed filtering; null includes both.
  • IsServiceRegistered: checks whether the specified service type is registered in the service collection (keyed or not).
  • IsKeyedServiceRegistered: checks whether the specified service type is registered as keyed in the service collection.
  • IsTransientServiceRegistered: checks whether the specified service type is registered as transient in the service collection.
  • IsScopedServiceRegistered: checks whether the specified service type is registered as scoped in the service collection.
  • IsSingletonServiceRegistered: checks whether the specified service type is registered as singleton in the service collection.
  • IsKeyedTransientServiceRegistered: checks whether the specified service type is registered as transient in the service collection (keyed services).
  • IsKeyedScopedServiceRegistered: checks whether the specified service type is registered as scoped in the service collection (keyed services).
  • IsKeyedSingletonServiceRegistered: checks whether the specified service type is registered as singleton in the service collection (keyed services).
services.AddTransient<ITestService, TestService>();
services.AddKeyedScoped<ITestService, TestService>("one");
bool any = services.IsServiceRegistered<ITestService>();
bool transient = services.IsTransientServiceRegistered<ITestService>();
bool keyedScoped = services.IsKeyedScopedServiceRegistered<ITestService>("one");
var descriptors = services.GetServiceDescriptors(typeof(ITestService));

ServiceProvider

To use these extensions, build the ServiceProvider with our custom ServiceProviderFactory. It captures authoritative registration metadata even when diagnostics are disabled. Native BuildServiceProvider() supports decoration/DependsOn, but does not install this metadata.

new HostBuilder().UseServiceProviderFactory(new ServiceProviderFactory(new ExtendedServiceProviderOptions()));

// - or -

var serviceProvider = ServiceProviderFactory.CreateServiceProvider(serviceCollection, new ExtendedServiceProviderOptions());

Each build enriches a private copy of the collection. Building repeatedly leaves the caller's descriptors unchanged, and each provider keeps its own registration snapshot. Later registration changes apply only to providers built afterward. Normal DI ownership still applies: caller-supplied singleton instances are shared, and the caller owns their disposal.

var services = new ServiceCollection();
services.AddSingleton<ITestService, TestService>();
using var first = ServiceProviderFactory.CreateServiceProvider(services);
// services.Count is still 1; no internal support registrations were appended.

services.AddKeyedSingleton<ITestService, TestService>("later");
using var second = ServiceProviderFactory.CreateServiceProvider(services);
bool firstHasLater = first.IsKeyedServiceRegistered("later");   // false
bool secondHasLater = second.IsKeyedServiceRegistered("later"); // true
Detect Incorrect Usage of Transient Disposables

Enable detection of transient disposable services resolved by the root scope:

new HostBuilder().UseServiceProviderFactory(new ServiceProviderFactory(
  new ExtendedServiceProviderOptions
  {
    DetectIncorrectUsageOfTransientDisposables = true,
    ValidateOnBuild = true,
    AllowSingletonToResolveTransientDisposables = true,
    ThrowOnOpenGenericTransientDisposable = true,
    DetectIncorrectUsageOfTransientDisposablesExclusionPatterns = ["service", "service2"]
  }));

Ordinary implementation-type registrations retain native DI constructor preference and ambiguity rules when diagnostics are enabled, including keyed attributes and optional defaults. The [ActivatorUtilitiesConstructor] attribute does not override native type-registration selection; non-empty DependsOn maps retain their separate preferred-constructor rules.

Diagnostics require ValidateOnBuild = true. Setting DetectIncorrectUsageOfTransientDisposables = true with ValidateOnBuild = false (including its default) throws ArgumentException when creating the provider. Native DI validates the original registrations before diagnostics wrap them, so constructor cycles such as CycleA -> CycleB -> CycleA fail at startup with AggregateException, before services are activated. Unused invalid registrations also fail validation. Diagnostics-disabled providers continue to support deferred validation.

This uses native build-time validation, with its normal limits: factories are opaque and open-generic registrations are not validated at build time. Dependencies resolved inside user factories, constructor bodies or DependsOn/decorator factories are outside this check; diagnostics do not add runtime cycle detection for them.

Native validation can reject AnyKey implementation-type registrations whose inherited-key dependencies are available only for concrete keys; register those services under concrete keys when using diagnostics. For explicit keyed registrations, build validation can cache the registered key object before an equal request key is supplied, so [ServiceKey] injection follows native effective-key identity rather than always returning the request object.

Diagnostic messages format service keys that implement IFormattable with invariant culture on every library target. For example, a decimal key prints 1234.5 even under fr-FR, including when using the netstandard2.0 library. Keys that supply only their own ToString() retain that method's formatting.

Rejected transient factory results remain owned by the root provider until it is disposed. Results already captured by that root are not captured again. Dispose the provider even after a diagnostic failure; use DisposeAsync for async-only resources.

WARNING: Use this only in debug/development because it relies on reflection and can affect performance. Instead of re-implementing a new ServiceProvider from scratch, this approach modifies each ServiceDescriptor to track resolution context and throw exceptions if required.

Limitations:

  • Open generic transient disposable services cannot be checked, a ServiceDescriptor cannot be created with an Open Generic as ServiceType and an ImplementationFactory (we cannot "rewrite" service registrations), so no error is thrown if they are resolved by the root scope.
  • Open generic resolution context cannot be tracked, a ServiceDescriptor cannot be created with an Open Generic as ServiceType and an ImplementationFactory, so no error is thrown if they are transient and disposable but resolved by the root scope.

Options:

  • AllowSingletonToResolveTransientDisposables: Defaults to false. If true, permits the transient when the tracked ancestor chain contains a singleton. Resolution frames are isolated between execution-context branches. A task inherits the ancestry captured when it is scheduled, even if it outlives the originating factory; that inherited singleton ancestor still grants this exemption.
  • ThrowOnOpenGenericTransientDisposable: Rejects recognized disposable open-generic type registrations at build time, naming the implementation type in the exception for both keyed and unkeyed registrations. Applies to IDisposable and IAsyncDisposable implementations. When false, these registrations instead produce best-effort warnings through the root ILoggerFactory, if registered (normally via AddLogging).
  • DetectIncorrectUsageOfTransientDisposablesExclusionPatterns: list of Regex patterns matched against the registered public service type's full name. Matching registrations and all their decorator layers bypass the diagnostic check; dependencies registered under other service types are still checked unless separately excluded. All diagnostic flags default to false. Factory-created disposable objects can exist before a diagnostic throws; normal scope disposal remains necessary.

Open-generic startup warnings use the Mammoth.Extensions.DependencyInjection.ServiceProviderFactory logging category and event ID 1. They do not create a temporary scope or resolve ILogger<ServiceProviderFactory> from DI. A direct registration of that logger alone no longer supplies these warnings; configure standard ILoggerFactory logging instead. Failures while resolving the logging factory, creating the logger or writing a warning are ignored, so a warning may be lost. Provider construction, validation and strict open-generic rejection still propagate their errors. Services created while attempting warning delivery remain owned by the returned provider; dispose it normally, using DisposeAsync when its services require asynchronous disposal.

IsRegistered extension methods

Additional methods for IServiceProvider:

  • GetAllServices: resolves all keyed and non-keyed services of a given service type.
  • IsServiceRegistered: checks whether the specified service type is registered in the service provider (keyed or non-keyed).
  • IsKeyedServiceRegistered(key): checks whether a key is present globally, across service types; it is not a typed key query.
  • IsTransientServiceRegistered: checks whether the specified service type is registered as transient in the service provider (non keyed services).
  • IsScopedServiceRegistered: checks whether the specified service type is registered as scoped in the service provider (non keyed services).
  • IsSingletonServiceRegistered: checks whether the specified service type is registered as singleton in the service provider (non keyed services).
  • IsKeyedTransientServiceRegistered: checks whether the specified service type is registered as transient in the service provider (keyed services).
  • IsKeyedScopedServiceRegistered: checks whether the specified service type is registered as scoped in the service provider (keyed services).
  • IsKeyedSingletonServiceRegistered: checks whether the specified service type is registered as singleton in the service provider (keyed services).

Lifetime checks return false for missing registrations. Keyed and unkeyed identities are independent; provider lookup prefers an exact closed registration before its generic definition within the requested key. Type discovery recognizes closed types from registered generic definitions. Collection assignability matching and provider generic lookup serve different questions.

var serviceProvider = ServiceProviderFactory.CreateServiceProvider(
    services, new ExtendedServiceProviderOptions());
using (serviceProvider)
using (var scope = serviceProvider.CreateScope())
{
    bool providerHasService = serviceProvider.IsServiceRegistered<ITestService>();
    bool keyExists = serviceProvider.IsKeyedServiceRegistered("one");
    bool providerHasKeyedScoped = serviceProvider.IsKeyedScopedServiceRegistered<ITestService>("one");
    var all = scope.ServiceProvider.GetAllServices<ITestService>();
}

GetAllServices<T>() and the Type overload enumerate native unkeyed services first, then merge closed-service and generic-definition keys once per key. Keyed group order is unspecified; native registration order is preserved within each group. Do not use discovery order as a single-service selection rule.

The provider reads a private snapshot: editing the original collection or mutable public ServiceTypes, ServiceKeys, ServiceKeys<T> and ServiceLifetimes compatibility copies does not reconfigure queries, enumeration or diagnostics. Keys should have stable equality/hash behavior; arbitrary mutable key objects are not deep-cloned. See the generic and snapshot example.

Inspectors

AssemblyInspector inspects assemblies for classes to register.

It is once again inspired by the syntax used in Castle.Windsor to inspect and register services.

It looks for classes and offers a series of methods that are pretty self explanatory to output one or more ServiceDescriptor that will be registered in the ServiceCollection.

WithServiceAllInterfaces() excludes interfaces in System and its child namespaces (such as System.Collections.Generic), plus interfaces from the assembly named exactly mscorlib. Comparisons are ordinal and case-sensitive. IDisposable and IAsyncDisposable are excluded on every supported runtime, regardless of their defining assembly. Application assembly names such as Systematic.Contracts do not affect selection; namespaces such as Systematic and Systems are not children of System. Use BasedOn<T>().WithServiceBase() to explicitly register a framework interface.

Implementations with unbound generic parameters are skipped when the selected service is not a generic type definition. This happens before Configure is called, so an ordinary marker scan can safely share an assembly with generic implementations:

using Mammoth.Extensions.DependencyInjection.Inspector;
using Microsoft.Extensions.DependencyInjection;

IServiceCollection services = new ServiceCollection();
var descriptors = new AssemblyInspector()
    .FromAssemblyContaining<Marker>()
    .BasedOn<IMarker>()
    .WithServiceAllInterfaces() // WithServiceBase() has the same marker-scan behavior.
    .LifestyleTransient();
foreach (var descriptor in descriptors)
{
    services.Add(descriptor);
}

using var provider = services.BuildServiceProvider();
var marker = provider.GetRequiredService<IMarker>(); // Marker

public interface IMarker { }
public class Marker : IMarker { }
public class MarkerGeneric<T> : IMarker { } // Skipped: IMarker cannot supply T.

Closed implementations and their closed generic interfaces still register normally. Native open-generic self type registrations are preserved, including BasedOn(typeof(Repository<>)).WithServiceSelf() and WithServiceBase() for that same concrete definition. The inspector does not infer open-generic interface mappings or extend BasedOn assignability. Open-generic registrations require type-based construction; nonempty DependsOn maps use factories and remain unsupported for open-generic services.

It supports DependsOn for keyed services:

public class ServiceWithKeyedDep
{
    public ITestService Service { get; }

    public ServiceWithKeyedDep(ITestService keyedService)
    {
        Service = keyedService;
    }
}
IServiceCollection serviceCollection = new ServiceCollection();
serviceCollection.AddKeyedSingleton<ITestService, TestService>("one");
var descriptors = new AssemblyInspector()
    .FromAssemblyContaining<ServiceWithKeyedDep>()
    .BasedOn<ServiceWithKeyedDep>()
    .WithServiceSelf()
    .Configure((registration, type) => registration.DependsOn = new Dependency[]
    {
        Parameter.ForKey("keyedService").Eq("one")
    })
    .LifestyleSingleton();
foreach (var descriptor in descriptors)
{
    serviceCollection.Add(descriptor);
}

Assign constructor maps through Configure before the parameterless lifestyle method. Use precise filters and inspect descriptors when scanning multiple implementations.

Skill for coding agents

The canonical mammoth-di skill teaches application integration, including version checks, disposal, keyed constructor maps, query semantics and diagnostic limits. It follows the Agent Skills format, with one entrypoint and optional recipes loaded only when needed.

Copy the whole mammoth-di folder, including references, from this repository into your consumer application's repository. Choose one supported location per agent; don't maintain duplicate copies for the same agent:

Agent Project destination Invocation
Codex .agents/skills/mammoth-di/ $mammoth-di, or automatic selection from its description; official discovery docs
Claude Code .claude/skills/mammoth-di/ /mammoth-di, or automatic selection; official skill docs
GitHub Copilot .github/skills/mammoth-di/; .agents/skills and .claude/skills are also supported Ask to use mammoth-di; selection depends on the supported client; official skill docs

These project discovery conventions were checked on October 1, 2026. Claude Code's documented project directory differs from Codex's; a standard SKILL.md does not imply identical search paths in every agent/client. Other agents can read the folder explicitly if they support Agent Skills or local instructions. Copying instructions does not install Mammoth or change package references. Keep the skill aligned with the library version your application actually uses.

The repository includes no global agent configuration changes or automatic system-wide installer.

Architecture

Keyed built-in registration probing explains why constructor selection uses Mammoth's immutable registration snapshot and a guarded native DI reflection fallback, how this preserves native-provider support without activating dependencies, and the alternatives and compatibility limits.

About

Microsoft's Dependency Injection enhancement library

Topics

Resources

Contributing

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages