dotnet / dotnet/runtime

[API Proposal] Add Typed Registration Factories

Open
#128,833 1 comment 2 reactions 0 assignees View on GitHub
api-suggestion area-Extensions-DependencyInjection
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

`Microsoft.Extensions.DependencyInjection` currently has two ways to register a service: a typed implementation (auto-wired by reflecting the constructor) or a `Func` factory. The factory route has two problems:
1. **Performance.** The legacy factory delegate calls user code with an `IServiceProvider`. To get its dependencies, the body re-enters the container via `GetService` per parameter. That defeats every optimisation available for auto-wired registrations: no IL emit of a direct constructor call, no compiled `Expression.Lambda`, no fast-path scope cache lookup of pre-computed call sites. Bench: legacy factory is **1.85× to 3.99× slower** and allocates **11× to 62× more** than auto-wiring.
2. **No validatable parameters.** The container cannot see what the factory body will resolve. Validation (`ValidateOnBuild`, captive-dependency checks, missing-dependency diagnostics) is silently bypassed for every factory registration.

The fix is a third registration form: a *typed factory*. The user passes any delegate whose return type matches the service and whose parameters declare the dependencies. The container reads the parameter list once at registration time, builds normal call sites for each dependency, and emits the same IL/Expression that auto-wired registrations get. The user delegate is called directly with the resolved arguments. No `IServiceProvider` round-trip per parameter. Bench (after this change): typed factory is **1.00× to 1.06×** of auto-wired across N=1..16, within noise. Same allocation profile as auto-wired. The same parameter list, exposed as a public `Dependencies` property, is what a future validator reads. Source-generator emitters can also pre-populate `Dependencies` directly via a new `params` constructor, eliminating any reflection on the user delegate at registration time.

# API Proposal
All additions live in `Microsoft.Extensions.DependencyInjection.Abstractions`.
```csharp
public class TypedServiceDescriptor : ServiceDescriptor
{
public Delegate? TypedImplementationFactory { get { throw null; } }
public Delegate? TypedKeyedImplementationFactory { get { throw null; } }
public IReadOnlyList? Dependencies { get { throw null; } }

public ServiceDescriptor(ServiceLifetime lifetime, Type serviceType, Delegate factory) { }
public ServiceDescriptor(ServiceLifetime lifetime, Type serviceType, object? serviceKey, Delegate factory) { }
public ServiceDescriptor(ServiceLifetime lifetime, Type serviceType, Delegate factory, ServiceDependency[] dependencies) { }
public ServiceDescriptor(ServiceLifetime lifetime, Type serviceType, object? serviceKey, Delegate factory, ServiceDependency[] dependencies) { }
}

public readonly struct ServiceDependency : IEquatable
{
public Type ServiceType { get { throw null; } }
public object? ServiceKey { get { throw null; } }
public bool IsOptional { get { throw null; } }
public bool IsServiceKey { get { throw null; } }

public ServiceDependency(Type serviceType, object? serviceKey = null, bool isOptional = false) { throw null; }
}

public static partial class ServiceCollectionServiceExtensions
{
public static IServiceCollection AddSingleton(this IServiceCollection services, Type serviceType, Delegate factory) { throw null; }
public static IServiceCollection AddScoped (this IServiceCollection services, Type serviceType, Delegate factory) { throw null; }
public static IServiceCollection AddTransient(this IServiceCollection services, Type serviceType, Delegate factory) { throw null; }
public static IServiceCollection AddKeyedSingleton(this IServiceCollection services, Type serviceType, object? serviceKey, Delegate factory) { throw null; }
public static IServiceCollection AddKeyedScoped (this IServiceCollection services, Type serviceType, object? serviceKey, Delegate factory) { throw null; }
public static IServiceCollection AddKeyedTransient(this IServiceCollection services, Type serviceType, object? serviceKey, Delegate factory) { throw null; }
}
```
Generic `` overloads would *look* like the existing `Func` overloads (which check return types at compile time) but couldn't actually constrain the delegate, since `Delegate` has no return type in the type system. Excluding them avoids that footgun.
Registration extension methods check proactively if the delegate returns a type assignable to the service type. Besides, they check and reject closed-over instance method captured as a static (delegate's `Invoke.Length != Method.Length`) and several parameter features that don't make much sense in DI service resolution context like `ref`, `in`, `out`, pointer, function-pointer and byref-like parameters.
Two construction paths produce the `Dependencies` list, with different contracts:
- **Reflective (no `dependencies` argument).** The container reflects the delegate's `Invoke` parameters once at registration time and builds the list. Cached in a backing field; subsequent property reads return the same `IReadOnlyList` reference. The reflexion will respect meaningful parameter attributes like `[ServiceKey]` or `[FromKeyedServices(key)]`. We may consider explicit `[Optional]` attribute instead of relying on the `?` semantics.
- **Caller-supplied (`params ServiceDependency[] dependencies`).** The caller asserts the list. The container uses it verbatim and **skips reflection on the delegate's parameters**. In `Debug` configurations the container may run a cheap consistency check (`dependencies.Length` matches the number of non-passthrough parameters on `Invoke`); in release this check is elided. Mismatched data is undefined behaviour.
The caller-supplied path is the contract for source generators: the generator already knows the dependency list at build time, so it can emit it directly and avoid both reflection cost and any future trim risk on parameter attributes.

# Compatibility
- All existing `ServiceDescriptor`/`Add*` APIs unchanged.
- `Func` and `Func` factories continue to work exactly as before. The new typed overloads are picked only when the user passes a delegate type that is not one of those two `Func<>` shapes.
- TypedServiceDescriptor imherits ServiceDescriptor and returns shims on ImplementationFactory and KeyedImplementationFactory properties so even if containers don't understand TypedServiceDescriptor, they can use it as yet another ServiceDescriptor with opaque factory. The shims use IServiceProvider internally so containers are compatible and if they want to improve performance, they can still cover the TypedServiceDescriptor.

# Benchmarks
I made a spike implementing this proposal and got close to what auto-wiring can now offer (WarmBench, `Toolchain=InProcessEmitToolchain`, macOS/arm64, .NET 11 preview):
| N | AutoWired | Typed Factories | Ratio | Legacy Factories | Legacy ratio |
|---:|----------:|----------:|------:|---------:|-------------:|
| 1 | 6.82 ns | 7.22 ns | 1.06× | 12.64 | 1.85× |
| 3 | 9.72 ns | 10.25 ns | 1.06× | 26.38 | 2.72× |
| 5 | 12.12 ns | 12.38 ns | 1.02× | 37.60 | 3.10× |
| 8 | 16.23 ns | 16.15 ns | 1.00× | 54.13 | 3.34× |
| 16 | 25.86 ns | 26.95 ns | 1.04× | 103.17 | 3.99× |

Allocations: typed is identical to auto-wired (24–144 B per resolution depending on N). Legacy allocates 276–4961 B in the same cases.

# Outlook
- **A typed-factory validator** that walks `Dependencies`, applies the same captive-dependency and missing-dependency rules as `ValidateOnBuild` already does for auto-wired registrations, and runs *before* any instance is created.
- **A source generator** that, given a `services.AddSingleton(typeof(TService), (Dep1 a, Dep2 b) => new TService(a, b))` registration, emits the descriptor via the `params ServiceDependency[]` ctor with the dependency list pre-populated. The container skips its own reflection and goes straight to building call sites; in the long run, the generator can also emit closed-generic factory plumbing that bypasses `DynamicMethod` / `Expression.Compile` entirely, which is the only credible AOT-friendly fast path. The `Delegate` shape gives the generator everything it needs to read the registration; the `Dependencies` shape gives it everything it needs to emit replacement code.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.