Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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>();

Three problems:

  1. Separation from the author. The developer who wrote VosEventEmitter with a ConcurrentBag designed it as a singleton. That knowledge is 200 lines away in a different file.
  2. No way to verify intent. Nothing prevents someone from changing AddSingleton to AddTransient — the code compiles, the tests pass, and the bug hides until production.
  3. 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 }

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); });
}

And the composition root becomes:

// 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 { }

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 { }

How It Works — The Source Generator Pipeline

Diagram

The generator runs in three pipelines:

  1. Classes with [Injectable] — each class is transformed into an InjectableServiceModel with implementation type, service types, scope, key, and flags.
  2. 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.
  3. 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;
    }
}

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);
}

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<>))

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 { }

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>()

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)]

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 { }

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:

Diagram

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();
    }
}

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();

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.