[API Proposal] DI Source Generator
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
Today every classic registration walks reflection at first resolution: discover the public constructor, validate parameter shapes, build an expression tree (or fall back to per-call reflection on Mono/AOT/profiled builds). That cost is paid once per service, but it is real, allocates, breaks AOT cleanly, and hides invalid registrations until runtime. Moreover, validating the classic registration usually means another round of reflection, so services only have `ValidateOnBuild` enabled in testing environments (or not at all) and then are annoyed by discovering a missing dependency after production deployment, which often means rolling back to the last known correct version and other dev-ops costs.
The typed factories and the `TypedServiceDescriptor` overloads remove that cost at the container level (#128833), but they are uncomfortable to write by hand. The generator pays that cost at compile time so the user keeps writing `services.AddSingleton()`.
- **Zero reflection at registration**: the generated `ServiceDescriptor` carries a strongly-typed delegate and a literal `ServiceDependency[]`. The container never inspects constructors.
- **AOT-clean**: no `Activator.CreateInstance`, no `Type.GetConstructors`, no `Expression.Compile`. The generated factory is plain C# the trimmer fully reasons about.
- **Errors at build time**: missing public ctor, ambiguous ctor, structs, abstract types, open generics surface as `SYSLIB12xx` diagnostics during compilation instead of `InvalidOperationException` at first resolve.
- **Granular opt-in**: only registrations made inside members marked with `[GeneratedServiceRegistrations]` (method or class) are rewritten. Everything else stays on the classic reflection path. A project can route its hot composition root through the generator while leaving ad-hoc or third-party registrations untouched.
- **Explicit activator**: opt in to a specific constructor or static factory method via `[ServiceActivator]`; the generator emits a typed invocation of either form.
### Intercepted call shapes
For each of `AddSingleton`, `AddScoped`, `AddTransient` and their three `AddKeyed*` siblings, the generator rewrites:
1. `services.Add()` — two-arg generic.
2. `services.Add()` — single-arg generic (service == implementation).
3. `services.Add(typeof(TService), typeof(TImpl))` — non-generic, two `typeof` literals.
4. `services.Add(typeof(T))` — non-generic, single `typeof` literal (service == implementation).
5. Keyed variants of (1)–(4) with a literal or constant key expression.
…subject in every case to the call being lexically inside a scope marked with `[GeneratedServiceRegistrations]`.
## What the user sees
Source compiled with the generator enabled:
```csharp
[GeneratedServiceRegistrations]
public static IServiceCollection AddMyServices(this IServiceCollection services)
{
services.AddSingleton();
services.AddScoped();
return services;
}
```
Or, equivalently, on the whole class:
```csharp
[GeneratedServiceRegistrations]
internal static class ServiceComposition
{
public static IServiceCollection AddCore(this IServiceCollection s) =>
s.AddSingleton();
public static IServiceCollection AddClocks(this IServiceCollection s) =>
s.AddScoped();
}
```
Or, for projects that simply want every registration site rewritten, applied once at assembly scope (typically in `AssemblyInfo.cs` or at the top of a single file):
```csharp
[assembly: GeneratedServiceRegistrations]
```
Each annotated registration call continues to behave identically (same lifetime, same instance identity, same resolution order). Internally the compiler routes that exact call site to a generated interceptor:
```csharp
// generated; one method per (lifetime, keyed-ness, service+impl, ctor parameter list) bucket.
[InterceptsLocation(1, "...packed location...")]
internal static IServiceCollection AddSingleton_0(this IServiceCollection services)
{
services.Add(ServiceDescriptor.Create(
typeof(global::Ns.IGreeter),
typeof(global::Ns.Greeter),
ServiceLifetime.Singleton,
new ServiceDependency[]
{
new ServiceDependency(typeof(global::Ns.ILogger)),
},
new Func(static logger => new global::Ns.Greeter(logger))));
return services;
}
```
Multiple call sites with the identical bucket key (same lifetime / keyed-ness / generic vs typeof / service+impl / parameter list) share a single interceptor method via multiple `[InterceptsLocation]` attributes; the per-call overhead is purely the attribute, not duplicated factory IL. The container then takes the fast typed-factory path: no `ConstructorInfo`, no `ParameterInfo[]`, no `Expression.Compile`.
The implementation type can also point the generator at a non-default constructor or at a static factory method via `[ServiceActivator]`:
```csharp
public sealed class HttpFooClient
{
private HttpFooClient(HttpClient http, FooOptions options) { /* ... */ }
[ServiceActivator]
public static HttpFooClient Create(HttpClient http, IOptions options)
=> new HttpFooClient(http, options.Value);
}
[GeneratedServiceRegistrations]
public static IServiceCollection AddFoo(this IServiceCollection s)
=> s.AddSingleton();
```
…rewrites to:
```csharp
[InterceptsLocation(1, "...packed location...")]
internal static IServiceCollection AddSingleton_1(this IServiceCollection services)
{
services.Add(ServiceDescriptor.Create(
typeof(global::Ns.HttpFooClient),
typeof(global::Ns.HttpFooClient),
ServiceLifetime.Singleton,
new ServiceDependency[]
{
new ServiceDependency(typeof(global::System.Net.Http.HttpClient)),
new ServiceDependency(typeof(global::Microsoft.Extensions.Options.IOptions)),
},
new Func, object>(
static (h, o) => global::Ns.HttpFooClient.Create(h, o))));
return services;
}
```
Bucketing applies equally to both forms: a static-factory and a ctor-based registration with the same impl type and same parameter list collapse into one shared interceptor method.
## Public surface
1. The marker attribute in `Microsoft.Extensions.DependencyInjection.Abstractions` (new):
```csharp
namespace Microsoft.Extensions.DependencyInjection;
///
/// Marks a method or class as a scope in which classic
/// registration calls (AddSingleton, AddScoped, AddTransient and their AddKeyed* siblings)
/// should be rewritten by the source generator into typed, reflection-free
/// s at compile time.
///
///
/// When applied to a class, the attribute cascades to every invocation lexically
/// contained in the class body, including methods, property accessors, local
/// functions, lambda bodies and nested types. Partial declarations need only carry
/// the attribute on one part.
///
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Assembly,
Inherited = false,
AllowMultiple = false)]
public sealed class GeneratedServiceRegistrationsAttribute : Attribute
{
public GeneratedServiceRegistrationsAttribute() { }
}
```
2. The activator-selection attribute in `Microsoft.Extensions.DependencyInjection.Abstractions` (new):
```csharp
namespace Microsoft.Extensions.DependencyInjection;
///
/// Marks a constructor or a public static factory method as the entry point
/// the source generator should use to construct the annotated type. Honoured
/// only by the generator; at runtime the container continues to use its
/// existing constructor-selection rules.
///
[AttributeUsage(AttributeTargets.Constructor | AttributeTargets.Method,
Inherited = false,
AllowMultiple = false)]
public sealed class ServiceActivatorAttribute : Attribute
{
public ServiceActivatorAttribute() { }
}
```
3. The generator type itself, Roslyn-discovered and not normally referenced by users:
```csharp
// ref/Microsoft.Extensions.DependencyInjection.Abstractions.SourceGeneration.cs
namespace Microsoft.Extensions.DependencyInjection.SourceGeneration
{
[Generator]
public sealed partial class DependencyInjectionGenerator : IIncrementalGenerator
{
public const string GeneratorName = "Microsoft.Extensions.DependencyInjection.SourceGeneration";
public DependencyInjectionGenerator();
public void Initialize(IncrementalGeneratorInitializationContext context);
}
}
```
Everything else (the captured-candidate type, the parse model, the emitter) is `internal` and not part of the contract; the only stable promises are the two attributes, the generator type's identity (for `` scenarios) and the diagnostic IDs.
### Selecting the entry point
When the generator chooses how to construct a type inside an annotated scope, it applies, in order:
1. The unique member carrying `[ServiceActivator]`, if any (constructor or static factory method).
2. Otherwise, the unique constructor carrying `[ActivatorUtilitiesConstructor]`.
3. Otherwise, the unique public constructor.
4. Otherwise, `SYSLIB1240`, whose message suggests applying `[ServiceActivator]` to disambiguate.
Validity rules for `[ServiceActivator]`, each enforced by a diagnostic:
- On a constructor: must be accessible to the assembly emitting the interceptor.
- On a method: must be `public static`, non-generic, and the return type must be assignable to the registered implementation type. Instance methods on a "factory object" are out of scope; `services.AddSingleton(sp => sp.GetRequiredService().Create(...))` already covers that case.
- At most one `[ServiceActivator]` per type.
`[ServiceActivator]` is honoured **only by the generator**. The runtime `ActivatorUtilities` and `ServiceProvider` reflection paths continue to honour `[ActivatorUtilitiesConstructor]` and the single-public-constructor rule exactly as today. As a consequence, if a registration call site for the same type appears both inside and outside `[GeneratedServiceRegistrations]` scopes, and `[ServiceActivator]` points at a member that the runtime would not have chosen on its own, the two call sites will use different entry points. `SYSLIB1250` surfaces the most common version of that trap before the project ships.
### Diagnostic catalogue
| ID | Title | Fires when |
|--------------|----------------------------------------------------------------------|--------------------------------------------------------------|
| `SYSLIB1240` | Ambiguous constructors. | There is more than one public constructor and no `[ServiceActivator]` / `[ActivatorUtilitiesConstructor]` disambiguates. The message suggests applying `[ServiceActivator]`. |
| `SYSLIB1242` | Type is not accessible from the call site. | Service or impl is not visible to the assembly emitting the interceptor. |
| `SYSLIB1243` | Unsupported parameter shape. | `in` / `ref` / `out` / pointer / `__arglist` parameter. |
| `SYSLIB1244` | Required members without `[SetsRequiredMembers]`. | Impl uses `required` and the chosen ctor lacks the attribute. |
| `SYSLIB1245` | Classic registration is AOT-fragile. | A supported registration call sits outside any `[GeneratedServiceRegistrations]` scope, and `IsAotCompatible` or `IsTrimmable` (or `PublishAot` / `PublishTrimmed` at publish time) is true. **Info** severity. |
| `SYSLIB1246` | Implementation has no public constructor. | All ctors are non-public. |
| `SYSLIB1247` | Implementation type is unsupported. | Impl is abstract / static / interface / struct. |
| `SYSLIB1248` | `[ServiceActivator]` member is not eligible. | Method is non-static, non-public, generic, returns an incompatible type, or has an unsupported parameter shape; or constructor is inaccessible from the call-site assembly. |
| `SYSLIB1249` | Multiple `[ServiceActivator]` members on the same type. | More than one constructor or static method on a type carries the attribute. |
| `SYSLIB1250` | `[ServiceActivator]` and `[ActivatorUtilitiesConstructor]` disagree. | The type's `[ServiceActivator]` member and an `[ActivatorUtilitiesConstructor]` member are not the same constructor; generator and runtime will pick different activators. |
All `Info` severity, all enabled by default, all suppressible per call site with `#pragma warning disable SYSLIBxxxx`. None fail the build. (Concrete IDs subject to allocation in the central SYSLIB registry; the numbers above are placeholders.)
### MSBuild contract
One user-facing knob:
| Property | Default | Effect |
|---|---|---|
| `EnableDependencyInjectionGenerator` | `true` once the package ships the analyzer | Loads the analyzer dll and opts the project into `InterceptorsPreviewNamespaces=Microsoft.Extensions.DependencyInjection.SourceGeneration` (TFM ≥ net11.0). Setting `false` removes both the analyzer and the namespace append; `[GeneratedServiceRegistrations]` then has no effect. |
Activation is otherwise entirely source-driven: presence of the analyzer enables the pipeline; presence of `[GeneratedServiceRegistrations]` on a scope determines which call sites are rewritten. There is no automatic flip under `PublishAot` / `PublishTrimmed`; what those modes do change is that un-annotated registrations become eligible for the `SYSLIB1245` Info hint suggesting the user annotate the containing scope. The generator reads `build_property.IsAotCompatible`, `build_property.IsTrimmable`, `build_property.PublishAot` and `build_property.PublishTrimmed` via `AnalyzerConfigOptions` to gate that diagnostic.
### Generated-code contract
- Emitted compilation-wide file: `MicrosoftExtensionsDependencyInjectionInterceptors.g.cs`.
- One `file`-scoped `InterceptsLocationAttribute` shim per compilation (to be removed once the BCL ships it publicly).
- One `internal static class Interceptors` in namespace `Microsoft.Extensions.DependencyInjection.SourceGeneration`.
- One method per `(lifetime, keyed, generic-vs-typeof, single-vs-two-arg, ServiceTypeFqn, ImplementationTypeFqn, parameter list)` bucket; shared by all in-scope call sites with the same shape, regardless of which annotated member they live in.
- All emitted types are `internal`, all generated identifiers are deterministic for caching.
The user never imports, references, or names anything in this namespace; the compiler routes calls into it via interceptor attributes.
### Why interceptors (and not analyzer code fixes)
Interceptors keep the rewrite invisible to the user: their source stays as `services.AddSingleton()`, debuggers step into the original call, and the generated code lives entirely in `obj/`. A code-fix-based approach would force the user to commit the typed-factory form and lose source-level brevity, defeating the point of the abstraction.
## Roll-out
1. **API proposal**: `GeneratedServiceRegistrationsAttribute` and `ServiceActivatorAttribute` go through API review together (~20 lines of public surface in `Microsoft.Extensions.DependencyInjection.Abstractions`).
2. **This PR (runtime)**: ship the attributes, the gen project, MSBuild glue, diagnostics, xlf, unit + runtime tests.
3. **Next PR (sdk)**: default `EnableDependencyInjectionGenerator=true` once the analyzer ships, gate the interceptor-namespace append to TFM ≥ net11.0 (patch already drafted under `files/sdk-enablement.diff`).
4. **Follow-up (validation)**: a validator generator that walks `ServiceDescriptor.Dependencies` across an `IServiceCollection` to surface unresolved or captive dependencies at compile time. Strictly additive on top of this design.
Contributor guide
Assessment
This issue has not been assessed yet.