Truly compile-time dependency injection for .NET. Services are declared with attributes and the Roslyn generator emits plain, readable C# resolvers — no reflection, no expression trees, no runtime container.
[ServiceProvider]
[Singleton<IClock, SystemClock>]
[Scoped<DbSession>]
[Transient<Handler>]
public partial class AppContainer { }
using var container = new AppContainer();
var handler = container.CreateScope().Handler;Members are strongly typed by default, so an unresolvable service is a compile error. Opt
into the generic IServiceProvider surface with [ServiceProvider(genericApi: true)] when
the container has to be handed to code written against IServiceProvider: it emits real
GetService<T>() / GetRequiredService<T>() members, keyed and async variants included,
that dispatch by runtime type.
Full documentation lives in SourceCrafter.DependencyInjection/README.md —
attribute reference, generated code walkthrough, cancellation semantics and the SCDI
diagnostic table.
| Package | What it is |
|---|---|
SourceCrafter.DependencyInjection |
The generator. This is the one you usually want. |
SourceCrafter.DependencyInjection.Metadata |
Attributes only, no generator. For libraries that want to declare contracts without pulling in the generator. |
SourceCrafter.DependencyInjection.MsConfiguration |
The generator plus Microsoft.Extensions.Configuration support ([JsonSetting<T>]). Use instead of the core generator. |
SourceCrafter.DependencyInjection.MsConfiguration.Metadata |
Attributes only, configuration flavour. |
SourceCrafter.DependencyInjection/— the generator (netstandard2.0 is deliberately not targeted)SourceCrafter.DependencyInjection.Metadata/— attribute definitionsSourceCrafter.DependencyInjection.MsConfiguration*/— configuration-aware variantsSourceCrafter.DependencyInjection.Tests/— the test suiteBenchmarks/— BenchmarkDotNet harness
dotnet build SourceCrafter.DependencyInjection.slnxThe test project targets .NET 10 and runs through the xUnit v3 in-process runner. dotnet test
is currently unreliable with the .NET 10 SDK here, so run the produced executable directly:
dotnet build SourceCrafter.DependencyInjection.Tests/SourceCrafter.DependencyInjection.Tests.csproj
SourceCrafter.DependencyInjection.Tests/bin/Debug/net10.0/SourceCrafter.DependencyInjection.Tests.exeFilter with -method "*SomePattern*".
Changing the generator means keeping five different kinds of test honest:
Tests.cs/Server.cs— behaviour. A realistic container is compiled by the real generator during the build, then exercised at runtime.GeneratedCodeTests.cs— emission. Runs the generator in memory viaGeneratorHarnessand asserts on the text it produces: lock scoping, the constructor, disposal ordering, file shape, and determinism across repeated passes.RootPropagationTests.cs— theRoot/CreateScopevirtual-dispatch contract.LockingStrategyTests.cs— the locking scheme itself, modelled by hand with no generator involved. Shows why one lock per lifetime deadlocks on cross-lifetime dependencies, why a single lock is correct but serializing, and why an instance lock cannot guard a static field.EqualityContractTests.cs— guards every generator model type against declaringEqualswithout a matching==, which silently breaks incremental caching.
If you touch the emitter, GeneratedCodeTests is where a regression will surface first.
dotnet build -p:EmitCompilerGeneratedFiles=true -p:CompilerGeneratedFilesOutputPath=obj/generatedEmit to a directory outside the project folder, or the generated files get picked up by the next compilation and every type ends up declared twice.
Benchmarks/ compares this generator against every actively maintained compile-time DI
container — Jab,
Pure.DI,
StrongInject and
MrMeeseeks.DIE — following the scenarios that
.NET Matrix has made the de facto standard for
this category. Results are in the package README.
dotnet run -c Release --project Benchmarks -- --filter "*"Two rules the harness must keep:
- Every branch does exactly the same work. An earlier version of this harness only built the container in the SourceCrafter branch while the others created a scope and resolved a service, so the published numbers compared nothing.
Hand Codedis the baseline, not another container. Nestednew(...)is the floor a compile-time container is trying to reach.
And one rule for reading the output:
- Do not publish a timing ratio from a single run. These scenarios resolve in tens of
nanoseconds, where per-process JIT and heap-layout luck is a large fraction of the
measurement. A single-launch run once showed all three containers jumping from ~38 ns to
~80 ns while the baseline held still — three independent libraries do not regress in
lockstep.
HarnessConfigtherefore pins three process launches plus memory randomization, and even then the complex-graph scenario has been seen to vary 0.95x–2.10x across runs of the same binary with byte-identical allocations. Allocation counts are reproducible; sub-100 ns timings are only approximately so.
Where a library genuinely lacks a feature (StrongInject and MrMeeseeks.DIE have no request scope), the row is left empty rather than approximated: a made-up equivalent would measure something no user of that library could write.
Each container declaration lives in its own Containers.*.cs file, because the libraries ship
attributes with colliding names ([Transient<,>], [Singleton<,>], [Scoped<,>],
[Register]).
Issues and pull requests are welcome. A few conventions that are not obvious from the code:
- The generator project sets
EnableDefaultCompileItems=false. Any new.csfile must be added to the.csprojby hand, or it will silently not compile. - Parsing and emission are separated on purpose. Anything reachable from a cached delegate
must be free of
ISymbol,SyntaxNode,SemanticModelandCompilation, otherwise the incremental pipeline keeps whole Roslyn compilations alive.ResolverRendereris the frozen, symbol-free projection that the emitter renders from. - New diagnostics go in
ServiceContainerDiagnostics.cswith a fresh, uniqueSCDIid, and aDiagnosticDescriptor.Titlethat is static text (dynamic titles break IDE grouping). - Generated code is normalized to tabs, CRLF and no trailing whitespace before it is emitted.
See LICENSE.txt.