DependencyModules

August 27, 2026 ยท View on GitHub

NuGet build coverage License: MIT

Your DI registrations, written as attributes and compiled into your assembly. No reflection, no assembly scanning, no startup cost โ€” and Native AOT works, because there is nothing left to trim away.

๐Ÿ“– Documentation ยท Getting started ยท Conventions ยท Decorators ยท Testing ยท AOT

The whole trick

You mark a class:

[SingletonService]
public class SmtpEmailSender : IEmailSender;

At build time the generator writes the registration you would have written yourself:

// ApplicationModule.Dependencies.g.cs
services.AddSingleton(
    typeof(global::MyApp.IEmailSender),
    typeof(global::MyApp.SmtpEmailSender)
);

That is the entire mechanism. The output is ordinary C# that you can read, grep, set a breakpoint in, and check into a review. Nothing inspects your assembly at run time, so there is no startup scan to pay for and nothing for the trimmer to guess about.

Why not a runtime scanner?

If you have used Scrutor, Autofac modules, or hand-written AddScoped lists, this is what changes:

Runtime scanningDependencyModules
When registration is decidedFirst request to the containerdotnet build
A convention that matches nothingSilentDM0005 at build
A service that cannot be constructedInvalidOperationException, eventuallyDM0002 at build
Trimming / Native AOTTypes disappear; scanner finds nothingLiteral typeof(), so the trimmer keeps them
Startup costProportional to assembly sizeNone
What actually got registeredDebugger, at run timeA file you can open

The interesting half is the third row. A trimmer removes CreateOrderHandler because nothing statically references it โ€” a scanner that would have found it by reflection does not count. Emitting typeof(CreateOrderHandler) into your assembly is a static reference, which is why this approach and Native AOT get along.

Install

dotnet add package DependencyModules.Runtime
dotnet add package DependencyModules.SourceGenerator

Requires .NET 8.0 or later. The packages ship net8.0 and net10.0 assemblies, so a project on either LTS gets one built against its own framework. Console applications also want Microsoft.Extensions.DependencyInjection.

Quick start

Mark the services, declare a module, load it once:

// Services.cs
using DependencyModules.Runtime.Attributes;

namespace MyApp;

[SingletonService]
public class SmtpEmailSender : IEmailSender;

[ScopedService]
public class OrderRepository : IOrderRepository;
// Program.cs
using MyApp;                       // the generated module lives in your root namespace
using DependencyModules.Runtime;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddModule<ApplicationModule>();

var provider = services.BuildServiceProvider();

ApplicationModule is generated for you in a project whose entry point is a top-level Program.cs. Anywhere else โ€” a class library, or a project that wants more than one module โ€” declare your own:

[DependencyModule]
public partial class ApplicationModule;

Declaring one in a project that already gets a generated ApplicationModule merges with it rather than colliding โ€” and to add a ConfigureServices to the generated one, declare the partial without [DependencyModule] and implement IServiceCollectionConfiguration.

A module must be partial, and must be declared directly in a namespace rather than nested inside another type. Services marked with [SingletonService] and friends may be nested freely.

Coming from top-level statements? The generated module takes your project's RootNamespace, and top-level statements sit in the global namespace โ€” so Program.cs needs using YourRootNamespace; before it can name ApplicationModule.

Registering forty things without writing forty attributes

Declare the rule once. It is matched by the compiler, against the types that exist at build time:

[DependencyModule]
public partial class HandlerModule : IConventionModule {
    void IConventionModule.Conventions(IConventionDefinitions conventions) {
        conventions.RegisterAll(typeof(IRequestHandler<,>)).AsScoped();

        conventions.RegisterAll(typeof(IValidator<>))
            .IncludeBaseClasses()
            .AlsoAsSelf()
            .AsScoped();
    }
}

Every handler in the project is registered against the closed interface it implements. Add a handler tomorrow and it joins; delete one and the registration goes with it. A convention that stops matching anything is a build warning rather than a runtime surprise.

The body of Conventions is never executed โ€” it is read from source at compile time, which is why only the documented calls can appear in it. See the conventions guide.

Composing modules

A module generates an attribute of the same name, so modules compose by attribute:

[DependencyModule]
[DomainModule]
[InfrastructureModule(useInMemory: true, ConnectionName = "primary")]
public partial class ApiModule;

Constructor parameters and settable properties on a module are mirrored onto its generated attribute, so a module can be configured by whoever composes it. For anything the attributes cannot express, implement IServiceCollectionConfiguration and write the registrations by hand.

Decorators and interception

Wrap a service without touching it or its callers. The first constructor parameter is the wrapped instance; the rest resolve normally:

[Decorator(Order = 2000)]
public class CachingRepository(IRepository inner, IMemoryCache cache) : IRepository;

[Decorator(Order = 1000)]
public class TracingRepository(IRepository inner, ILogger<TracingRepository> log) : IRepository;

// resolves as CachingRepository(TracingRepository(SqlRepository))

Lower orders sit closer to the implementation. Ordering is global across every module in an AddModule(s) call, so an application's decorators can wrap those a library contributed โ€” by convention framework code uses 0โ€“999 and application code starts at 1000.

For cross-cutting behaviour across every member of a service, [Intercept] generates a typed wrapper rather than a dynamic proxy. See decorators and interception.

Testing

Tests receive their dependencies as method parameters, against the real registration graph:

[assembly: ApplicationModule]
[assembly: NSubstituteSupport]

public class OrderTests {
    [ModuleTest]
    public async Task PlaceOrder_PricesThroughTheChannel(
        IRequestHandler<PlaceOrder, Order> handler,
        [Mock] IBookRepository books) {

        books.Find("isbn-1", Arg.Any<CancellationToken>())
            .Returns(new Book("isbn-1", 20m));

        var order = await handler.Handle(new PlaceOrder("isbn-1", 10), default);

        Assert.Equal(140m, order.Total);
    }
}
dotnet add package DependencyModules.xUnit        # or DependencyModules.NUnit
dotnet add package DependencyModules.NSubstitute  # or .Moq, or .FakeItEasy

Each test gets its own provider, so singletons cannot leak between them. See the testing guide.

Native AOT

Verified end to end: a console application using conventions, keyed registrations, decorators, a static factory and an intercepted open generic publishes to a 2.2 MB self-contained binary with zero IL trim or AOT warnings, behaving identically to the JIT build.

The one limitation is not this library's to fix: the container cannot close an open generic over a value type without dynamic code, so IRepository<Order> resolves and IRepository<int> throws. Setting PublishAot makes that fail in an ordinary dotnet run rather than only after publishing. See the AOT guide.

Feature reference

[SingletonService] [ScopedService] [TransientService]Register with the matching lifetime
[CrossWireService]One instance shared across the implementation and its interfaces
As = typeof(IFoo)Choose the service type explicitly
Key = "primary"Keyed registration
Using = RegistrationType.TryAdd, Try, TryEnumerable or Replace
Realm = typeof(SomeModule)Restrict a registration to one module
Order = 10Where a registration sits in IEnumerable<T>
[IfEnvironment("Development")]Register only in named environments
[Decorator] [Decorate] [Intercept]Wrap a service, or one you do not own
A static method carrying a service attributeFactory, for types the container cannot build

Full details for each, with the rules and the edge cases, are in the documentation.

Samples

The integ-tests/ directory is a working sample gallery, built and tested on every commit:

SampleShows
SutProjectEvery registration shape, in one project
SutProject.TestsConventions, realms, keyed services, cross-wiring, factories, features, and all three mocking libraries
ConsoleTestProjectTop-level statements and the generated ApplicationModule
web/WebApiAppAn ASP.NET Core host, with its own test project

Reporting a problem

If services are not registered as you expect, these three steps produce almost everything needed to diagnose it:

  1. Read the generated code. Set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> and look under obj/. The registrations the generator produced are the ground truth. (Point CompilerGeneratedFilesOutputPath inside obj/ โ€” a folder in the project directory gets compiled as ordinary source on the next build.)
  2. Turn on the generator log, which records the configuration in effect, every module and service discovered, and anything skipped along with the reason:
    <PropertyGroup>
      <DependencyModules_LogOutputDirectory>$(MSBuildProjectDirectory)/dmlogs</DependencyModules_LogOutputDirectory>
    </PropertyGroup>
    
  3. Check for DM#### warnings in the build output. The generator reports these for mistakes it can detect โ€” see the diagnostics reference.

Please include the log and the generated file in any issue.

License

MIT. See LICENSE.txt and CHANGELOG.md.