SsalKit.DependencyInjection
0.0.3
See the version list below for details.
dotnet add package SsalKit.DependencyInjection --version 0.0.3
NuGet\Install-Package SsalKit.DependencyInjection -Version 0.0.3
<PackageReference Include="SsalKit.DependencyInjection" Version="0.0.3" />
<PackageVersion Include="SsalKit.DependencyInjection" Version="0.0.3" />
<PackageReference Include="SsalKit.DependencyInjection" />
paket add SsalKit.DependencyInjection --version 0.0.3
#r "nuget: SsalKit.DependencyInjection, 0.0.3"
#:package SsalKit.DependencyInjection@0.0.3
#addin nuget:?package=SsalKit.DependencyInjection&version=0.0.3
#tool nuget:?package=SsalKit.DependencyInjection&version=0.0.3
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).
Enum-keyed service factories
Keyed services solve "several implementations of one interface", but resolving them means reaching for IServiceProvider (or [FromKeyedServices]) at every call site. [ServiceFactory] turns that into an ordinary, strongly typed interface — and generates the implementation for you:
public enum PaymentMethod { Card, Bank }
[Service(ServiceLifetime.Singleton, As = typeof(IPaymentProcessor), Key = PaymentMethod.Card)]
public sealed class CardPaymentProcessor : IPaymentProcessor { }
[Service(ServiceLifetime.Singleton, As = typeof(IPaymentProcessor), Key = PaymentMethod.Bank)]
public sealed class BankPaymentProcessor : IPaymentProcessor { }
[ServiceFactory]
public interface IPaymentProcessorFactory
{
IPaymentProcessor Create(PaymentMethod method);
}
You write no implementation at all. The generator emits one, into the reserved SsalKit.DependencyInjection.Generated namespace:
// <auto-generated/>
namespace SsalKit.DependencyInjection.Generated.MyApp
{
internal sealed class IPaymentProcessorFactoryImplementation : IPaymentProcessorFactory
{
private readonly IServiceProvider _provider;
public IPaymentProcessorFactoryImplementation(IServiceProvider provider) => _provider = provider;
public IPaymentProcessor Create(PaymentMethod method)
=> _provider.GetRequiredKeyedService<IPaymentProcessor>(method);
}
}
and adds it to the assembly's Add{Assembly}Services() method as a singleton, registered as the interface:
services.AddSingleton<IPaymentProcessorFactory, SsalKit.DependencyInjection.Generated.MyApp.IPaymentProcessorFactoryImplementation>();
so IPaymentProcessorFactory can be constructor-injected anywhere, with no wiring of your own.
Interface requirements
A [ServiceFactory] interface must:
- be non-generic, and not nested inside a generic type (
SSAL019); - declare exactly one member — one more method, a property, an event, or a nested type is rejected (
SSAL017); - and that member must be an ordinary, non-static, non-generic method taking exactly one by-value
enumparameter and returning a non-void, non-refservice type (SSAL018).
The interface, the enum, and the return type must all be at least internal and not file-local, because the generated implementation names all three (SSAL020). The generated class itself is always internal sealed, so a public factory interface is fine — nothing outside the assembly ever names the implementation.
Anything the generator can't honour is reported as an error and produces no implementation for that interface; other factories and every [Service] registration in the assembly are unaffected.
Unregistered keys
The generated method delegates straight to GetRequiredKeyedService<TReturn>(key) — no lookup table, no caching, no fallback. Calling it with an enum value nothing is registered under throws exactly what GetRequiredKeyedService throws (an InvalidOperationException for the built-in container), from the Create call itself. A fallback/TryCreate shape may be added in a later version; today the exception is the contract.
By the same token, the factory adds no lifetime of its own: it is a stateless wrapper registered as a Singleton, and whether two calls with the same key return the same instance is decided entirely by the keyed registration's own lifetime.
Cross-assembly registrations
No diagnostic is reported when the compilation that declares the factory contains no [Service(Key = SomeEnum.X)] registration for a given enum value. Declaring the factory interface in one assembly and registering the keyed implementations in another (or by hand, in Program.cs) is a supported, ordinary arrangement — the generator has no way to see the other assembly's registrations, so it does not guess. The trade-off is that a genuinely missing registration surfaces at resolution time rather than at compile time.
Registering every implementation of a contract
Some registrations read better as a rule than as an attribute repeated on every class: every IRequestHandler<,> is Scoped, every IStartupTask is a Singleton. [assembly: RegisterImplementationsOf] states that rule once, and the generator resolves it at compile time — still no reflection, still nothing to scan at startup:
using Microsoft.Extensions.DependencyInjection;
using SsalKit.DependencyInjection;
[assembly: RegisterImplementationsOf(typeof(IRequestHandler<,>), ServiceLifetime.Scoped)]
[assembly: RegisterImplementationsOf(typeof(IStartupTask))]
// None of these carries an attribute of its own — the two lines above register all three.
public sealed class PingHandler : IRequestHandler<Ping, Pong> { }
public sealed class MigrateDatabase : IStartupTask { }
public sealed class WarmCaches : IStartupTask { }
emits:
services.TryAddEnumerable(ServiceDescriptor.Scoped<IRequestHandler<Ping, Pong>, PingHandler>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupTask, MigrateDatabase>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupTask, WarmCaches>());
so IEnumerable<IStartupTask> can be constructor-injected directly.
The scan only sees the current compilation
The scan runs over the types declared in the assembly the attribute is applied to. Classes in referenced assemblies are never discovered, even through a project reference — the generator compiles one assembly at a time and cannot reach into another's source. To register a referenced assembly's implementations, declare the attribute in that assembly too and call its own generated Add{Assembly}Services() method.
What matches
The contract must be an interface (SSAL021).
- A non-generic or closed generic contract (
typeof(IStartupTask),typeof(IRequestHandler<Ping, Pong>)) matches every class that implements it. - An unbound generic contract (
typeof(IRequestHandler<,>)) matches every class implementing any instantiation of it, and registers one(instantiation, class)pair per implemented instantiation — a class implementing bothIRequestHandler<A, B>andIRequestHandler<C, D>is registered twice, once under each. - An open generic class (
Validator<T> : IValidator<T>) is registered as thetypeof-based(IValidator<>, Validator<>)pair, provided it satisfies the same exact-match rule that governs an open generic[Service]. A partially-applied shape such asHandler<T> : IHandler<T, Unit>cannot be expressed as an open generic registration and is skipped.
Inherited implementations count: a class matches when the contract is anywhere in its interface set, including via a base class.
What is skipped, silently
A convention scan describes a shape, not a specific type, so a class that simply does not fit is passed over rather than reported: abstract and static classes, types that are not classes at all, classes not accessible from the generated code (a private nested or file-local class, and anything nested inside one), classes nested inside a generic type (see SSAL003), and open generic classes whose implemented instantiation is not an exact match.
Only mistakes in the declaration are diagnosed — including a contract that ends up matching nothing (SSAL022), so a typo or a namespace mix-up cannot silently register nothing at all.
[Service] wins over the convention
A class carrying at least one [Service] attribute is excluded from every convention scan in the assembly, so its explicit registration is never duplicated or contradicted by one. That doubles as the per-class opt-out: give the class the [Service] registration you actually want — a different lifetime, As type, Key, or Mode — and the scan leaves it alone.
[assembly: RegisterImplementationsOf(typeof(IStartupTask))]
public sealed class WarmCaches : IStartupTask { } // TryAddEnumerable Singleton, from the scan
[Service(ServiceLifetime.Transient)]
public sealed class PersistStep : IStartupTask { } // Transient Add, from [Service] alone
Why the default Mode is TryAddEnumerable
Registering "every implementation of X" is by nature a multi-implementation pattern whose result is consumed as IEnumerable<X>. TryAddEnumerable is the mode that makes that work without implementations shadowing one another, and it is the one mode SSAL015 never reports as a conflict — so it is this attribute's default, unlike [Service], whose default is Add. Set Mode explicitly for the rarer case where the scan is meant to bind a single implementation:
[assembly: RegisterImplementationsOf(typeof(IClock), Mode = RegistrationMode.TryAdd)]
Each matched service type gets its own independent registration. Unlike a multi-interface [Service], a convention-scanned class is never registered once as its concrete type with the service types forwarded to it, so two service types matched on the same class do not resolve to a shared instance.
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.
[ServiceFactory] takes no arguments and can only be applied to an interface, at most once. See Enum-keyed service factories.
[assembly: RegisterImplementationsOf(contract, lifetime, Mode = ...)]
| Property | Type | Default | Description |
|---|---|---|---|
Contract |
Type |
(required) | Constructor argument. The interface to scan for implementations of — non-generic, closed generic, or an unbound typeof(IHandler<,>). |
Lifetime |
ServiceLifetime |
ServiceLifetime.Singleton |
Constructor argument. The lifetime every matched implementation is registered with. |
Mode |
RegistrationMode |
RegistrationMode.TryAddEnumerable |
How each matched registration is applied — note the default differs from [Service]'s. |
The attribute is assembly-scoped and can be applied any number of times, once per contract. See Registering every implementation of a contract.
Diagnostics
The generator validates your [Service], [ServiceFactory], and [assembly: RegisterImplementationsOf] 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. |
SSAL015 |
Warning | The same service type (and Key) is registered with two or more different implementation types. A single-instance resolution returns the last registration, and the generator emits registrations ordered by implementation type name — so renaming a class can silently change the winner. Use RegistrationMode.TryAddEnumerable on every implementation if they are meant to be injected together as IEnumerable<T> (such a group is never reported), give them distinct Key values, or suppress the warning if one deliberately overrides the other. |
SSAL016 |
Error | [ServiceFactory] was applied to something other than an interface. Normally pre-empted by the compiler's own CS0592 (the attribute's AttributeUsage is Interface); reported as a defence in depth. |
SSAL017 |
Error | A [ServiceFactory] interface must declare exactly one member, and that member must be an ordinary, non-static method. Zero members, a second method, a property, an event, or a nested type are all rejected. |
SSAL018 |
Error | The [ServiceFactory] method's signature is unusable: it is generic, takes anything other than exactly one by-value enum parameter (ref/out/in included), returns void, or returns by reference. |
SSAL019 |
Error | [ServiceFactory] was applied to a generic interface, or to one nested inside a generic type — the generated implementation is a non-generic singleton registered against one closed service type. |
SSAL020 |
Error | The factory interface, its enum key type, or its return type is not accessible from the generated implementation (a separate file in the SsalKit.DependencyInjection.Generated namespace, same assembly); each must be at least internal and not file-local. |
SSAL021 |
Error | The [assembly: RegisterImplementationsOf] contract is not an interface. A class, struct, enum, delegate, or array type has no set of "implementations" to discover. |
SSAL022 |
Warning | An [assembly: RegisterImplementationsOf] contract matched no class at all, and therefore registered nothing. Usual causes: a misspelled or wrong-namespace interface, an expectation that a referenced assembly's classes would be discovered (they are not), or every candidate having been skipped for being abstract, static, inaccessible, nested inside a generic type, or already decorated with [Service]. |
SSAL023 |
Error | The same contract is declared by two or more [assembly: RegisterImplementationsOf] attributes. Only the first is used; two declarations of one contract have no combined meaning. |
SSAL024 |
Error | An undefined ServiceLifetime or RegistrationMode value (e.g. (ServiceLifetime)42) was supplied to [assembly: RegisterImplementationsOf]. |
SSAL025 |
Error | The contract (or one of its generic type arguments) is not accessible from the generated registration code; it must be at least internal and not file-local. A file-local interface can be named at the attribute application site but never from the generated file. |
SSAL026 |
Warning | Two overlapping contracts — typically an unbound typeof(IHandler<>) alongside a closed typeof(IHandler<int>) — match the same class under the same service type but disagree on Lifetime/Mode, so both registrations are emitted and which one wins is decided by Microsoft.Extensions.DependencyInjection rather than by the declarations. Overlapping contracts that agree are collapsed into a single registration and reported nothing. |
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.