Injectable: Let the Class Own Its Lifetime
The developer who writes a singleton knows it's a singleton. The startup file doesn't.
Every .NET project has a composition root — a place where services are wired into the DI container. Over time, this file grows into a 200-line manifest of services.Add* calls that nobody fully understands. The person writing those lines has to guess — or read the source — whether a service should be singleton, scoped, or transient. That guessing is a bug waiting to happen.
Injectable moves the lifetime decision to where it belongs: on the class itself. A single [Injectable] attribute, a Roslyn source generator, and the compiler does the rest.
The Problem — DI Configuration Is Blind
Here's a real DI setup from Vos, a VM orchestration framework:
// Program.cs — who decided these lifetimes?
services.AddTransient<IVosProjectService, VosProjectService>();
services.AddTransient<IVosMachineService, VosMachineService>();
services.AddTransient<IVosMachineTypeService, VosMachineTypeService>();
services.AddTransient<IVosInstanceService, VosInstanceService>();
services.AddTransient<IVosNetworkService, VosNetworkService>();
services.AddTransient<IVosVmService, VosVmService>();
services.AddTransient<IVosBoxService, VosBoxService>();
services.AddTransient<IVosPackerService, VosPackerService>();
services.AddSingleton<IVosEventEmitter, VosEventEmitter>();// Program.cs — who decided these lifetimes?
services.AddTransient<IVosProjectService, VosProjectService>();
services.AddTransient<IVosMachineService, VosMachineService>();
services.AddTransient<IVosMachineTypeService, VosMachineTypeService>();
services.AddTransient<IVosInstanceService, VosInstanceService>();
services.AddTransient<IVosNetworkService, VosNetworkService>();
services.AddTransient<IVosVmService, VosVmService>();
services.AddTransient<IVosBoxService, VosBoxService>();
services.AddTransient<IVosPackerService, VosPackerService>();
services.AddSingleton<IVosEventEmitter, VosEventEmitter>();Three problems:
- Separation from the author. The developer who wrote
VosEventEmitterwith aConcurrentBagdesigned it as a singleton. That knowledge is 200 lines away in a different file. - No way to verify intent. Nothing prevents someone from changing
AddSingletontoAddTransient— the code compiles, the tests pass, and the bug hides until production. - Two-file ceremony. Every new service requires editing two files: the class itself and the registration file. Forget one, and the service silently doesn't exist.
Convention-based scanning (Scrutor, assembly scanning) trades one problem for another — you eliminate manual registration but gain invisible magic that's hard to debug and impossible to reason about statically.
The Solution — One Attribute, Zero Wiring
The entire Injectable API is one attribute and one enum:
namespace FrenchExDev.Net.Injectable.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface,
Inherited = false, AllowMultiple = true)]
public sealed class InjectableAttribute : Attribute
{
public Scope Scope { get; set; } = Scope.Transient;
public Type[]? As { get; set; }
public string? Key { get; set; }
public bool TryAdd { get; set; }
}
public enum Scope { Transient, Scoped, Singleton }namespace FrenchExDev.Net.Injectable.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface,
Inherited = false, AllowMultiple = true)]
public sealed class InjectableAttribute : Attribute
{
public Scope Scope { get; set; } = Scope.Transient;
public Type[]? As { get; set; }
public string? Key { get; set; }
public bool TryAdd { get; set; }
}
public enum Scope { Transient, Scoped, Singleton }Decorate a class:
[Injectable(Scope = Scope.Singleton, As = new[] { typeof(IVosEventEmitter) })]
public sealed class VosEventEmitter : IVosEventEmitter
{
private readonly ConcurrentBag<Action<VosEvent>> _handlers = [];
public void Emit(VosEvent evt)
{
foreach (var handler in _handlers)
handler(evt);
}
public IDisposable Subscribe<T>(Action<T> handler) where T : VosEvent
=> Subscribe(evt => { if (evt is T typed) handler(typed); });
}[Injectable(Scope = Scope.Singleton, As = new[] { typeof(IVosEventEmitter) })]
public sealed class VosEventEmitter : IVosEventEmitter
{
private readonly ConcurrentBag<Action<VosEvent>> _handlers = [];
public void Emit(VosEvent evt)
{
foreach (var handler in _handlers)
handler(evt);
}
public IDisposable Subscribe<T>(Action<T> handler) where T : VosEvent
=> Subscribe(evt => { if (evt is T typed) handler(typed); });
}And the composition root becomes:
// One call replaces 9 manual registrations
services.AddFrenchExDevNetVosLibInjectables();// One call replaces 9 manual registrations
services.AddFrenchExDevNetVosLibInjectables();Interface Contracts — The Scope Belongs to the Interface
Sometimes the lifetime isn't a class decision — it's an interface contract. Every implementation of IRequestContext must be scoped. Every cache must be a singleton. Put [Injectable] on the interface, and all implementations inherit the scope automatically:
[Injectable(Scope = Scope.Transient)]
public interface IVosMachineService { }
// Auto-registered as Transient — no attribute needed
public sealed class VosMachineService : IVosMachineService { }[Injectable(Scope = Scope.Transient)]
public interface IVosMachineService { }
// Auto-registered as Transient — no attribute needed
public sealed class VosMachineService : IVosMachineService { }If a class explicitly sets a different scope, analyzer INJECT004 reports a build error — not a warning, because violating the interface contract is always a bug:
[Injectable(Scope = Scope.Scoped)]
public interface IRequestContext { }
// INJECT004 error: Singleton violates interface contract Scoped
[Injectable(Scope = Scope.Singleton)]
public class RequestContext : IRequestContext { }[Injectable(Scope = Scope.Scoped)]
public interface IRequestContext { }
// INJECT004 error: Singleton violates interface contract Scoped
[Injectable(Scope = Scope.Singleton)]
public class RequestContext : IRequestContext { }How It Works — The Source Generator Pipeline
The generator runs in three pipelines:
- Classes with
[Injectable]— each class is transformed into anInjectableServiceModelwith implementation type, service types, scope, key, and flags. - Interfaces with
[Injectable]— each interface becomes a contract. The generator walks the assembly's type tree to find all non-abstract classes implementing the interface and auto-registers them. - Classes with
[InjectableDecorator]— decorator registrations ordered by priority.
Assembly-level [InjectableDefaults] sets the default scope. All models are collected, then the emitter produces a single InjectableExtensions.g.cs per assembly.
The generated output for Vos:
// <auto-generated />
#nullable enable
namespace Microsoft.Extensions.DependencyInjection;
public static class FrenchExDevNetVosLibInjectableExtensions
{
public static IServiceCollection AddFrenchExDevNetVosLibInjectables(
this IServiceCollection services)
{
services.AddSingleton<IVosEventEmitter, VosEventEmitter>();
services.AddTransient<IVosProjectService, VosProjectService>();
services.AddTransient<IVosMachineService, VosMachineService>();
services.AddTransient<IVosMachineTypeService, VosMachineTypeService>();
services.AddTransient<IVosInstanceService, VosInstanceService>();
services.AddTransient<IVosNetworkService, VosNetworkService>();
services.AddTransient<IVosVmService, VosVmService>();
services.AddTransient<IVosBoxService, VosBoxService>();
services.AddTransient<IVosPackerService, VosPackerService>();
return services;
}
}// <auto-generated />
#nullable enable
namespace Microsoft.Extensions.DependencyInjection;
public static class FrenchExDevNetVosLibInjectableExtensions
{
public static IServiceCollection AddFrenchExDevNetVosLibInjectables(
this IServiceCollection services)
{
services.AddSingleton<IVosEventEmitter, VosEventEmitter>();
services.AddTransient<IVosProjectService, VosProjectService>();
services.AddTransient<IVosMachineService, VosMachineService>();
services.AddTransient<IVosMachineTypeService, VosMachineTypeService>();
services.AddTransient<IVosInstanceService, VosInstanceService>();
services.AddTransient<IVosNetworkService, VosNetworkService>();
services.AddTransient<IVosVmService, VosVmService>();
services.AddTransient<IVosBoxService, VosBoxService>();
services.AddTransient<IVosPackerService, VosPackerService>();
return services;
}
}Architecture — Testable Without Roslyn
The emitter and its models live in SourceGenerator.Lib — a netstandard2.0 project with zero Roslyn dependency. The container-specific generators (Microsoft DI, DryIoc) are thin Roslyn adapters that parse attributes into models and call the shared emitter. Source files are linked, not referenced, to avoid loading Roslyn assemblies at runtime.
This means the emitter is unit-tested with plain xUnit:
[Fact]
public void Generates_singleton_with_interface()
{
var model = new InjectableEmitModel("TestApp", new[]
{
new InjectableServiceModel(
"global::TestApp.Foo",
new[] { "global::TestApp.IFoo" },
"Singleton")
});
var code = InjectableEmitter.EmitMicrosoft(model);
Assert.Contains("services.AddSingleton<global::TestApp.IFoo, global::TestApp.Foo>()", code);
Assert.Contains("AddTestAppInjectables", code);
}[Fact]
public void Generates_singleton_with_interface()
{
var model = new InjectableEmitModel("TestApp", new[]
{
new InjectableServiceModel(
"global::TestApp.Foo",
new[] { "global::TestApp.IFoo" },
"Singleton")
});
var code = InjectableEmitter.EmitMicrosoft(model);
Assert.Contains("services.AddSingleton<global::TestApp.IFoo, global::TestApp.Foo>()", code);
Assert.Contains("AddTestAppInjectables", code);
}No Roslyn workspace setup, no compilation fixtures, no test infrastructure. Build the model, call the emitter, assert on the string.
Container Agnostic — Microsoft DI and DryIoc
The same [Injectable] attribute works with both containers. You swap the NuGet package:
| Microsoft DI | DryIoc | |
|---|---|---|
| Package | Injectable.Microsoft.SourceGenerator |
Injectable.DryIoc.SourceGenerator |
Scope.Transient |
AddTransient<>() |
Reuse.Transient |
Scope.Scoped |
AddScoped<>() |
Reuse.ScopedOrSingleton |
Scope.Singleton |
AddSingleton<>() |
Reuse.Singleton |
| Keyed | AddKeyedSingleton<>("key") |
serviceKey: "key" |
| TryAdd | TryAddSingleton<>() |
ifAlreadyRegistered: Keep |
| Decorator | ActivatorUtilities replacement |
Setup.Decorator (native) |
Embracing Change — Features That Emerged from Real Usage
The attribute started with two properties: Scope and As. As it got used in real projects — Vos, Diem, the CMF — each codebase surfaced a need that the original design didn't anticipate. Rather than resisting scope creep, Injectable embraced each change by asking one question: does this decision belong on the class, or in a wiring file? If the answer was "on the class," the attribute grew.
Open Generics
The DDD repository pattern made this inevitable. Every bounded context has IRepository<T> with multiple implementations. The generator detects open generics automatically:
[Injectable(Scope = Scope.Scoped)]
public class Repository<T> : IRepository<T> where T : class { }
// → services.AddScoped(typeof(IRepository<>), typeof(Repository<>))[Injectable(Scope = Scope.Scoped)]
public class Repository<T> : IRepository<T> where T : class { }
// → services.AddScoped(typeof(IRepository<>), typeof(Repository<>))Keyed Services
Multi-tenant systems need named instances of the same interface. The Key property maps directly to .NET 8's keyed services, with conditional compilation for backward compatibility:
[Injectable(Scope = Scope.Singleton, Key = "primary")]
public class PrimaryDb : IDatabase { }
[Injectable(Scope = Scope.Singleton, Key = "readonly")]
public class ReadOnlyDb : IDatabase { }[Injectable(Scope = Scope.Singleton, Key = "primary")]
public class PrimaryDb : IDatabase { }
[Injectable(Scope = Scope.Singleton, Key = "readonly")]
public class ReadOnlyDb : IDatabase { }TryAdd — Library Defaults
Library authors need to provide default implementations that consumers can override. The class author knows it's a default — the wiring file doesn't:
[Injectable(Scope = Scope.Singleton, TryAdd = true)]
public class DefaultCache : ICache { }
// → services.TryAddSingleton<ICache, DefaultCache>()[Injectable(Scope = Scope.Singleton, TryAdd = true)]
public class DefaultCache : ICache { }
// → services.TryAddSingleton<ICache, DefaultCache>()Assembly-Level Defaults
When an entire assembly follows the same convention — all services scoped in a web API, for instance — repeating Scope = Scope.Scoped on every class is noise:
[assembly: InjectableDefaults(Scope = Scope.Scoped)][assembly: InjectableDefaults(Scope = Scope.Scoped)]Classes without an explicit Scope use Scoped instead of Transient. The convention is declared once, at the assembly level.
Decorators
Cross-cutting concerns — logging, validation, caching — are decorator decisions. The class author knows it wraps another service:
[InjectableDecorator(typeof(IMyService), Order = 0)]
public class ValidationDecorator : IMyService { }
[InjectableDecorator(typeof(IMyService), Order = 1)]
public class LoggingDecorator : IMyService { }[InjectableDecorator(typeof(IMyService), Order = 0)]
public class ValidationDecorator : IMyService { }
[InjectableDecorator(typeof(IMyService), Order = 1)]
public class LoggingDecorator : IMyService { }Each of these features emerged from a real problem in a real codebase. None were planned upfront. The design stayed coherent because the guiding principle never changed: if the developer made the decision, the developer should declare it.
Compile-Time Safety — The Analyzers
Injectable ships with Roslyn analyzers that catch DI mistakes at build time:
| ID | Severity | What It Catches |
|---|---|---|
| INJECT001 | Warning | Captive dependency — Singleton depends on Scoped or Transient |
| INJECT002 | Warning | [Injectable] on abstract class (silently skipped) |
| INJECT003 | Info | Class implements service interface but has no [Injectable] |
| INJECT004 | Error | Implementation scope violates interface [Injectable] contract |
INJECT001 is the one Injectable is uniquely positioned to catch. The generator already knows every service's scope at compile time. A Singleton that depends on a Scoped service? That's a captive dependency — the scoped instance is held alive for the application's lifetime, preventing refresh and garbage collection. Traditional DI containers only catch this at runtime (if ever). Injectable catches it at build time.
Real-World — DDD Service Layer in Vos
Vos is a VM orchestration framework built on Vagrant. Its architecture demonstrates how Injectable fits into clean DDD layering:
Every service follows the same pattern:
[Injectable(Scope = Scope.Transient, As = new[] { typeof(IVosMachineService) })]
public sealed class VosMachineService(
IVosFileReader fileReader,
IVosFileWriter fileWriter,
IVosEventEmitter emitter,
ILogger<VosMachineService> logger) : IVosMachineService
{
public async Task<Res.Result> AddAsync(string configPath, string name,
string machineType, int instances = 1, ...)
{
emitter.Emit(new MachineAdding(name, machineType, instances));
var loadResult = await fileReader.ReadAsync(configPath, ct);
if (loadResult.IsFailure) return Res.Result.Failure();
var mgr = new VosConfigManager(loadResult.Value!);
mgr.AddMachine(name, machineType, instances);
await fileWriter.WriteAsync(loadResult.Value!, configPath, ct);
emitter.Emit(new MachineAdded(name, machineType, instances));
return Res.Result.Success();
}
}[Injectable(Scope = Scope.Transient, As = new[] { typeof(IVosMachineService) })]
public sealed class VosMachineService(
IVosFileReader fileReader,
IVosFileWriter fileWriter,
IVosEventEmitter emitter,
ILogger<VosMachineService> logger) : IVosMachineService
{
public async Task<Res.Result> AddAsync(string configPath, string name,
string machineType, int instances = 1, ...)
{
emitter.Emit(new MachineAdding(name, machineType, instances));
var loadResult = await fileReader.ReadAsync(configPath, ct);
if (loadResult.IsFailure) return Res.Result.Failure();
var mgr = new VosConfigManager(loadResult.Value!);
mgr.AddMachine(name, machineType, instances);
await fileWriter.WriteAsync(loadResult.Value!, configPath, ct);
emitter.Emit(new MachineAdded(name, machineType, instances));
return Res.Result.Success();
}
}Load aggregate → execute domain logic → persist → emit events. The VosEventEmitter is the only singleton — a thread-safe event bus shared across all transient services. The CLI wiring is clear about what's infrastructure (manual) and what's domain (one-liner):
var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
// Infrastructure (not in Lib assembly)
services.AddSingleton<IFileSystem, PhysicalFileSystem>();
services.AddSingleton<IVosFileReader, VosFileReader>();
services.AddSingleton<IVosFileWriter, VosFileWriter>();
services.AddSingleton<IVosBackend, VagrantBackend>();
// All Lib services — one line, source-generated
services.AddFrenchExDevNetVosLibInjectables();var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
// Infrastructure (not in Lib assembly)
services.AddSingleton<IFileSystem, PhysicalFileSystem>();
services.AddSingleton<IVosFileReader, VosFileReader>();
services.AddSingleton<IVosFileWriter, VosFileWriter>();
services.AddSingleton<IVosBackend, VagrantBackend>();
// All Lib services — one line, source-generated
services.AddFrenchExDevNetVosLibInjectables();Conclusion
DI lifetime is a design decision. It belongs on the class — or on the interface when it's a contract. Source generators wire. Analyzers check.
The triad:
[Injectable]attribute → the developer decides- Source generator → the compiler wires
- Roslyn analyzers → the compiler checks
If your DI registration file is longer than your service classes, the responsibility is in the wrong place.
See also: Domain-Driven Design in .NET for the domain modeling patterns used in Vos, and The Result Pattern for how these services handle errors without exceptions.