SsalKit.DependencyInjection
0.0.2
See the version list below for details.
dotnet add package SsalKit.DependencyInjection --version 0.0.2
NuGet\Install-Package SsalKit.DependencyInjection -Version 0.0.2
<PackageReference Include="SsalKit.DependencyInjection" Version="0.0.2" />
<PackageVersion Include="SsalKit.DependencyInjection" Version="0.0.2" />
<PackageReference Include="SsalKit.DependencyInjection" />
paket add SsalKit.DependencyInjection --version 0.0.2
#r "nuget: SsalKit.DependencyInjection, 0.0.2"
#:package SsalKit.DependencyInjection@0.0.2
#addin nuget:?package=SsalKit.DependencyInjection&version=0.0.2
#tool nuget:?package=SsalKit.DependencyInjection&version=0.0.2
SsalKit.DependencyInjection
Compile-time DI auto-registration for .NET, powered by a Roslyn source generator — no reflection, no assembly scanning, no runtime cost.
Why SsalKit.DependencyInjection?
Most "automatic" DI registration libraries work by scanning loaded assemblies with reflection at startup. That approach has real costs: startup latency that grows with your assembly count, metadata that trimming and Native AOT can't reason about, and failures that only surface at runtime.
SsalKit.DependencyInjection takes a different approach:
- Compile-time, not runtime. A source generator inspects your code during the build and emits the registration calls directly as C# — there is no scanning step to run when your app starts.
- Zero reflection. The generated code is a plain, readable
IServiceCollectionextension method. Nothing is discovered dynamically. - AOT & trimming friendly. Because registrations are ordinary generated code rather than reflection-driven discovery, they survive trimming and Native AOT compilation without extra annotations.
- Compile-time diagnostics. Mistakes like registering an abstract class or an unimplemented interface are caught by the compiler, not by a runtime exception in production.
Installation
dotnet add package SsalKit.DependencyInjection
The package contains both the [Service] attribute and the source generator — no separate analyzer package to install.
Quick Start
Decorate the classes (or record classes) you want registered:
using Microsoft.Extensions.DependencyInjection;
using SsalKit.DependencyInjection;
// Registered under every interface it implements (or itself, if it implements none).
[Service(ServiceLifetime.Singleton)]
public class CacheService : ICacheService { }
// Registered only as the given type.
[Service(ServiceLifetime.Scoped, As = typeof(IUserRepository))]
public class UserRepository : IUserRepository, IDisposable { }
// Defaults to ServiceLifetime.Singleton.
[Service]
public class EmailSender { }
// Registered as a keyed service (.NET 8+ keyed DI).
[Service(ServiceLifetime.Singleton, Key = "redis")]
public class RedisCache : ICache { }
// Only registered if IClock hasn't already been registered.
[Service(ServiceLifetime.Singleton, Mode = RegistrationMode.TryAdd)]
public class DefaultClock : IClock { }
At build time, the generator emits one extension method per assembly, named after the assembly itself. For example, an assembly called MyApp.Web gets AddMyAppWebServices(), roughly equivalent to:
// <auto-generated/>
namespace Microsoft.Extensions.DependencyInjection;
public static class MyAppWebServiceCollectionExtensions
{
public static IServiceCollection AddMyAppWebServices(this IServiceCollection services)
{
services.AddSingleton<CacheService>();
services.AddSingleton<ICacheService>(sp => sp.GetRequiredService<CacheService>());
services.AddScoped<IUserRepository, UserRepository>();
services.AddSingleton<EmailSender>();
services.AddKeyedSingleton<ICache, RedisCache>("redis");
services.TryAddSingleton<IClock, DefaultClock>();
return services;
}
}
Wire it up in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMyAppWebServices();
var app = builder.Build();
Multi-interface
Singleton/Scopedregistrations are generated as a concrete registration plus factory forwarding, so every interface resolves to the same shared instance.
Forwarding Registrations and Their Limits
A Singleton or Scoped service that implements multiple interfaces is registered once as its concrete type, with each interface forwarded to it via services.Add<TLifetime><TInterface>(sp => sp.GetRequiredService<TImpl>()). This guarantees every interface resolves to the same shared instance, but it's the standard forwarding pattern and comes with two well-known limitations:
Disposemay run more than once. If the interfaces are resolved and disposed independently — for example across different scopes — the same underlying instance'sDispose()can be invoked once per forwarded registration.IDisposable.Dispose()is expected to be idempotent, so this is usually harmless, but a non-idempotentDisposeimplementation should be avoided on services registered under multiple interfaces.- The last registration wins. The forwarding factory looks up the concrete type from the container at resolution time rather than capturing an instance. If application code later re-registers the concrete type manually, every forwarded interface follows that new registration instead of staying pinned to the one the generator emitted.
TryAddEnumerable does not forward
Mode = RegistrationMode.TryAddEnumerable cannot use the forwarding pattern above, because Microsoft.Extensions.DependencyInjection has no way to inspect the implementation type behind a factory-based descriptor when checking for an existing duplicate. Instead, the generator emits an independent descriptor per interface (ServiceDescriptor.<Lifetime><TService, TImpl>) — which means interfaces registered this way do not share an instance.
Open generics
[Service] also works on open generic classes. Instead of the closed generic-argument overloads, the generator emits the Type-based (typeof(...)) registration overloads:
[Service(ServiceLifetime.Singleton)]
public class Repository<T> : IRepository<T> { }
emits:
services.AddSingleton(typeof(IRepository<>), typeof(Repository<>));
Exact-match rule
An open generic class C<T1, ..., Tn> can only be registered against:
- itself (self registration, when it implements no interfaces), or
- an implemented interface or base type
S<T1, ..., Tn>whose type arguments are exactlyC's own type parameters, in the same declaration order.
Anything else — a closed service type (IRepository<string>), a reordered (IPair<V, K>) or partial (IPair<T>) type argument list, a non-generic interface, or a service type that wraps the type parameter (IRepository<IEnumerable<T>>) — is rejected at compile time (SSAL009). As = typeof(IRepository<>) (an unbound generic typeof) is supported as an explicit override and is checked against the same rule.
Keyed services (Key = ...) and all four RegistrationMode values (Add, TryAdd, TryAddEnumerable, Replace) are supported for open generics exactly as for closed types.
Microsoft.Extensions.DependencyInjection cannot use a factory delegate for an open generic registration, so the forwarding pattern described above isn't available here. An open generic class registered under two or more service types as
SingletonorScopedgets one independenttypeof-based registration per service type — resolving different service types does not yield the same shared instance (SSAL010warns about this).
A class nested inside a generic type is not supported as a [Service] target, even when the nested class has no type parameters of its own (SSAL003).
Factory methods
[Service] can also construct the implementation instance through a static factory method instead of letting the container call a constructor directly — handy when construction needs to run a bit of logic, or when the class deliberately hides its constructor:
public interface IApiClient { }
[Service(ServiceLifetime.Scoped, Factory = nameof(Create))]
public sealed class ApiClient : IApiClient
{
private ApiClient(HttpClient httpClient) => HttpClient = httpClient;
public HttpClient HttpClient { get; }
public static ApiClient Create(IServiceProvider sp) =>
new(sp.GetRequiredService<HttpClient>());
}
emits:
services.AddScoped<IApiClient, ApiClient>(sp => ApiClient.Create(sp));
Use nameof(...) (e.g. Factory = nameof(Create)) instead of a string literal, so a rename of the method is caught by the compiler.
Method requirements
The named method must be:
- declared directly on the decorated class — not inherited from a base class, even one in the same assembly;
staticand non-generic;- either parameterless or taking a single
IServiceProviderparameter (noref/out/params); - returning exactly the decorated class — not a base type or an interface it implements;
- at least
internal— the generated registration code calls it from a separate file in the same assembly.
When both a parameterless overload and an IServiceProvider-accepting overload exist, the IServiceProvider-accepting one is used — deterministically, not as an ambiguity error.
Factory composes with every other [Service] option — Lifetime, Key, As, and every RegistrationMode — and only changes how the implementation instance is constructed. When the class implements 2+ interfaces and would otherwise share one instance across them via the forwarding pattern described above, the factory is invoked exactly once (by the self-registration statement); every forwarded interface resolves to that same, factory-constructed instance.
Factory is not supported on an open generic decorated class: Microsoft.Extensions.DependencyInjection has no factory-based registration API for open generics (SSAL013).
Attribute Reference
[Service(lifetime, As = ..., Mode = ..., Key = ..., Factory = ...)]
| Property | Type | Default | Description |
|---|---|---|---|
Lifetime |
ServiceLifetime |
ServiceLifetime.Singleton |
Constructor argument. Singleton, Scoped, or Transient. |
As |
Type? |
null |
Registers only as this type instead of every implemented interface (or itself). |
Mode |
RegistrationMode |
RegistrationMode.Add |
Add, TryAdd, TryAddEnumerable, or Replace — how the call is applied to the collection. |
Key |
object? |
null |
Registers as a keyed service (.NET 8+) using AddKeyed* instead of Add*. |
Factory |
string? |
null |
Names a static factory method (declared on the decorated class) that constructs the implementation instance. See Factory methods. |
A class can carry multiple [Service] attributes to register it several ways (different lifetimes, As targets, or keys) at once.
Diagnostics
The generator validates your [Service] usage at compile time:
| ID | Severity | Description |
|---|---|---|
SSAL001 |
Error | [Service] was applied to an abstract or static class. |
SSAL002 |
Error | The type specified in As is not implemented by the decorated class. |
SSAL003 |
Error | [Service] was applied to a class nested inside a generic type; open generic support requires all of a class's generic context to be its own type parameters. |
SSAL004 |
Warning | The same service registration appears to be duplicated. |
SSAL005 |
Error | Key was combined with RegistrationMode.TryAddEnumerable, which is not a supported combination. |
SSAL006 |
Error | RegistrationMode.TryAddEnumerable cannot register a type as itself — MS DI can't tell duplicates apart without a distinct service type; implement an interface or set As explicitly. |
SSAL007 |
Error | Every type the generated code would need to reference — the decorated class, its service type(s), a typeof(...) Key value, and any generic type arguments, including all containing types — must be at least internal and not file-local. Also rejected: a type only reachable through an extern alias (no global alias), and another assembly's protected internal type without [InternalsVisibleTo]. |
SSAL008 |
Error | An undefined ServiceLifetime or RegistrationMode value (e.g. (ServiceLifetime)42) was supplied to [Service]. |
SSAL009 |
Error | An open generic class can only be registered as itself or as an implemented interface/base type whose type arguments are exactly its own type parameters, in order; closed, reordered, partial, or wrapped type arguments are rejected. |
SSAL010 |
Warning | An open generic class is registered under two or more service types as Singleton or Scoped; each service type resolves to a separate instance because open generic registrations can't use forwarding factories. |
SSAL011 |
Error | 'Factory' method not found — no ordinary method with the name given to Factory is declared directly on the decorated class (this also covers an empty-string Factory value). |
SSAL012 |
Error | 'Factory' method has an unusable signature — one or more methods with that name exist, but none is static, non-generic, parameterless-or-single-IServiceProvider-parameter, and returning exactly the decorated class. |
SSAL013 |
Error | 'Factory' cannot be used on an open generic class — Microsoft.Extensions.DependencyInjection has no factory-based registration API for open generics. |
SSAL014 |
Error | 'Factory' method is not accessible to generated code — the chosen factory method must be at least internal so the generated registration code (in a different file, same assembly) can call it. |
License
MIT — see LICENSE.
AI disclosure: This project was built with AI assistance (Claude).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.