Skip to content

Repository files navigation

logo DependencyModules

NuGetbuildcoverageLicense: 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]publicclassSmtpEmailSender:IEmailSender;

At build time the generator writes the registration into your assembly:

// ApplicationModule.Dependencies.g.csservices.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.

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.csusingDependencyModules.Runtime.Attributes;namespaceMyApp;[SingletonService]publicclassSmtpEmailSender:IEmailSender;[ScopedService]
public class OrderRepository :IOrderRepository;
// Program.csusingMyApp;// the generated module lives in your root namespaceusingDependencyModules.Runtime;usingMicrosoft.Extensions.DependencyInjection;varservices=newServiceCollection();services.AddModule<ApplicationModule>();varprovider=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]publicpartialclassApplicationModule;

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.

The generated module takes the project's RootNamespace, and top-level statements sit in the global namespace — so a top-level 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]publicpartialclassHandlerModule:IConventionModule{voidIConventionModule.Conventions(IConventionDefinitionsconventions){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")]publicpartialclassApiModule;

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)]publicclassCachingRepository(IRepositoryinner,IMemoryCachecache):IRepository;[Decorator(Order=1000)]
public class TracingRepository(IRepositoryinner,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]publicclassOrderTests{[ModuleTest]publicasyncTaskPlaceOrder_PricesThroughTheChannel(IRequestHandler<PlaceOrder,Order>handler,[Mock]IBookRepositorybooks){books.Find("isbn-1",Arg.Any<CancellationToken>()).Returns(newBook("isbn-1",20m));varorder=awaithandler.Handle(newPlaceOrder("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.

Compared with runtime scanning

The registration work that Scrutor, container modules, or a hand-written AddScoped list do when the application starts happens here at dotnet build:

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 third row is the mechanism behind the AOT results above: a trimmer keeps what is statically referenced, a type found only by reflection is not referenced, and an emitted typeof(CreateOrderHandler) is.

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

When a registration is missing or wrong, 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.

About

Attribute-driven dependency injection modules for .NET, generated at compile time. No reflection, no assembly scanning.

Topics

Resources

Stars

12 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages