Source generators in practice: auto-registering plugins at compile time
Adding a new plugin to this sample app is almost suspiciously easy. Drop a class into the project that implements ICommandPlugin or IStartupPlugin, build, and it just works. No services.AddSingleton<β¦>() to write. No attribute required. No manifest file. No reflection scan at startup.
That's not magic β it's a C# source generator doing what you'd otherwise do by hand: writing the DI registration code for you, at compile time, into a partial method the rest of the codebase already knows about.
This post walks through why that matters, using the SourceGeneratedDIRegistration sample as a hands-on example. It's small enough to read in an afternoon but hits every interesting corner of a real-world generator.

What a source generator actually is
A C# source generator is a Roslyn component that runs as part of the build. It inspects the syntax and semantic model of your code and can contribute additional C# files to that same compilation. Those generated files are real C# β the compiler sees them, IntelliSense sees them, you can step into them with the debugger.
The mindset shift that matters most:
A source generator doesn't run at runtime. By the time your program starts, the generated code is already compiled into your assembly.
That one property is what makes everything else below possible.
Why they matter
- Zero runtime reflection. Whatever the generator computed at build time is plain method calls in the IL. No
Assembly.GetTypes()scan at startup. - AOT- and trim-friendly. No reflection means the trimmer can see exactly what's used. NativeAOT doesn't have to guess.
- Errors at build time, not at startup. If something's wrong, the build breaks β you find out during
dotnet build, not the first time DI tries to resolve a missing service. - Inspectable. Generated files are normal C#. Read them and set breakpoints.
- Cheap extension points. The consumer's only job is implement the interface. The generator handles the rest.
The MyApp sample touches all of these.
The sample at a glance
MyApp is a small console app built around two plugin contracts:
public interface ICommandPlugin { string Name { get; } void Execute(); } public interface IStartupPlugin { void OnStartup(); }
Out of the box it ships three command plugins β hello, goodbye, and all β plus one startup plugin:
[PluginOrder(0)] public sealed class HelloCommandPlugin : ICommandPlugin { public string Name => "hello"; public void Execute() => Console.WriteLine("Hello from the plugin system!"); } [PluginOrder(1)] public sealed class GoodByeCommandPlugin : ICommandPlugin { public string Name => "goodbye"; public void Execute() => Console.WriteLine("Goodbye from the plugin system!"); } public sealed class AllCommandPlugin(IServiceProvider serviceProvider) : ICommandPlugin { public string Name => "all"; public void Execute() { foreach (var plugin in serviceProvider.GetServices<ICommandPlugin>()) { if (plugin == this) continue; plugin.Execute(); } } }
Program.cs pulls them straight from DI:
var services = new ServiceCollection().AddPlugins().BuildServiceProvider(); foreach (var startup in services.GetServices<IStartupPlugin>()) startup.OnStartup(); var commandName = args.FirstOrDefault() ?? "hello"; var command = services.GetServices<ICommandPlugin>() .FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase)); command?.Execute();
dotnet run -- hello, dotnet run -- goodbye, and dotnet run -- all all do what you'd expect β and not one of those plugins is registered by hand.
The pattern: partial classes meet partial methods
The DI entry point is ServiceCollectionExtensions.cs β a public static partial class that declares one static partial void Registerβ¦Plugins(IServiceCollection) stub per plugin interface:
public static partial class ServiceCollectionExtensions { public static IServiceCollection AddPlugins(this IServiceCollection services) { services.TryAddSingleton(TimeProvider.System); RegisterCommandPlugins(services); RegisterStartupPlugins(services); return services; } static partial void RegisterCommandPlugins(IServiceCollection services); static partial void RegisterStartupPlugins(IServiceCollection services); }
The hand-written file owns the shape. The generator fills in the bodies. Both halves compile into the same type:
flowchart LR
A["Hand-written<br/>ServiceCollectionExtensions.cs<br/><i>static partial void</i> stubs"] --> M((Compiler))
B["Generated<br/>*PluginRegistration.g.cs<br/>method bodies with<br/>AddSingleton<I, T>()"] --> M
M --> C["Single compiled type:<br/>ServiceCollectionExtensions"]
If a stub has no generated body, C# treats it as a no-op β the code still compiles cleanly even before the generator has run once. That's the partial-method contract working in your favour.
Wiring up the generator
For the generator to participate in the build, MyApp.csproj needs to reference it β but not as a normal dependency. It's an analyzer, not runtime code:
<ItemGroup> <ProjectReference Include="..\MyApp.SourceGeneration\MyApp.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" SetTargetFramework="TargetFramework=netstandard2.0" /> </ItemGroup>
Three attributes doing real work here:
OutputItemType="Analyzer"β MSBuild loads this as a Roslyn analyzer/generator rather than a regular assembly reference.ReferenceOutputAssembly="false"β the.dlldoesn't ship as a runtime dependency; only the generator participates in the build.SetTargetFramework="TargetFramework=netstandard2.0"β generators run inside the build host, which is typically .NET Framework-based, sonetstandard2.0keeps compatibility wide.
Once that's in place, every MyApp build automatically discovers the [Generator]-marked classes in MyApp.SourceGeneration, runs them, and feeds their output into the compilation. No manual steps, no registry entry. Pure convention.
How the generator finds the plugins
The generator lives in MyApp.SourceGeneration as an IIncrementalGenerator. It's driven by a small lookup table β one row per plugin contract:
private static readonly ImmutableArray<PluginRegistration> PluginRegistrations = [ new("MyApp.Plugins.ICommandPlugin", "global::MyApp.Plugins.ICommandPlugin", "RegisterCommandPlugins", "CommandPluginRegistration.g.cs"), new("MyApp.Plugins.IStartupPlugin", "global::MyApp.Plugins.IStartupPlugin", "RegisterStartupPlugins", "StartupPluginRegistration.g.cs"), ];
Each entry says: "for this interface, fill in this partial method, and write the result to this .g.cs file."
For each entry, the generator builds an incremental pipeline:
public void Initialize(IncrementalGeneratorInitializationContext context) { foreach (var pluginRegistration in PluginRegistrations) { var registration = pluginRegistration; var pluginImplementations = context.SyntaxProvider .CreateSyntaxProvider(IsCandidate, (ctx, token) => GetPluginImplementation(ctx, token, registration)) .Where(symbol => symbol is not null) .Select((symbol, _) => symbol!) .Collect(); context.RegisterSourceOutput( pluginImplementations, (productionContext, symbols) => EmitRegistrationSource(productionContext, registration, symbols)); } }
Two filters work together here. The syntactic filter is intentionally cheap β it runs on every keystroke and just asks "is this a class with a base list?".
private static bool IsCandidate(SyntaxNode node, CancellationToken _) => node is ClassDeclarationSyntax { BaseList: not null };
Only classes that pass that check go through the semantic filter, which resolves the symbol and confirms it actually implements the interface in question:
var pluginInterface = context.SemanticModel.Compilation .GetTypeByMetadataName(registration.InterfaceMetadataName); foreach (var implementedInterface in classSymbol.AllInterfaces) { if (SymbolEqualityComparer.Default.Equals(implementedInterface, pluginInterface)) { return classSymbol; } }
Visually, the per-interface pipeline looks like this:
flowchart LR
S[SyntaxProvider<br/>every SyntaxNode] --> F1{IsCandidate?<br/>ClassDeclaration with<br/>BaseList}
F1 -- no --> X[drop]
F1 -- yes --> SEM[GetPluginImplementation<br/>resolve symbol +<br/>check AllInterfaces]
SEM --> F2{implements<br/>plugin interface?}
F2 -- no --> X
F2 -- yes --> COL[Collect<INamedTypeSymbol>]
COL --> OUT[RegisterSourceOutput]
OUT --> EMIT[EmitRegistrationSource<br/>β AddSource("*.g.cs")]
The two-stage split is what makes the generator incremental: when you type inside a method body, the syntactic filter discards the node before the semantic stage ever runs, and Roslyn reuses the previous result. That's how generators avoid slowing down the IDE.
Aspect-based ordering with an attribute
DI registration order matters more often than you'd like to admit. In the sample, hello should come first when iterating and all should always come last (since it delegates to the others). Hard-coding that inside the generator would couple it to specific type names β a bad idea. Instead, each plugin declares its own position:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class PluginOrderAttribute(int order) : Attribute { public int Order { get; } = order; }
Used like this:
[PluginOrder(0)] public sealed class HelloCommandPlugin : ICommandPlugin { β¦ } [PluginOrder(1)] public sealed class GoodByeCommandPlugin : ICommandPlugin { β¦ } // AllCommandPlugin has no attribute β falls back to int.MaxValue (i.e. last)
The generator reads that via the symbol API β no reflection, no runtime lookup β and uses it as the sort key when emitting the registration calls:
private static int GetPluginOrder(INamedTypeSymbol symbol) { foreach (var attribute in symbol.GetAttributes()) { if (attribute.AttributeClass?.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat) == "MyApp.Plugins.PluginOrderAttribute" && attribute.ConstructorArguments.Length == 1 && attribute.ConstructorArguments[0].Value is int order) { return order; } } return int.MaxValue; } // in EmitRegistrationSource: var uniquePlugins = pluginTypes .Distinct(SymbolEqualityComparer.Default) .OfType<INamedTypeSymbol>() .OrderBy(static s => GetPluginOrder(s)) .ThenBy(static s => s.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), StringComparer.Ordinal) .ToImmutableArray();
This is the broader point worth sitting with: anything you can express declaratively on a type β ordering, lifetime, named registrations, conditional inclusion β can be read by the generator and baked in at compile time. Cross-cutting concerns become aspects on the type rather than branches inside a runtime registrar.
What gets emitted
For each interface, EmitRegistrationSource deduplicates the discovered types, applies the ordering, and writes out a file like this:
// <auto-generated /> using Microsoft.Extensions.DependencyInjection; namespace MyApp; public static partial class ServiceCollectionExtensions { static partial void RegisterCommandPlugins(IServiceCollection services) { services.AddSingleton<global::MyApp.Plugins.ICommandPlugin, global::MyApp.Plugins.Implementations.HelloCommandPlugin>(); services.AddSingleton<global::MyApp.Plugins.ICommandPlugin, global::MyApp.Plugins.Implementations.GoodByeCommandPlugin>(); services.AddSingleton<global::MyApp.Plugins.ICommandPlugin, global::MyApp.Plugins.Implementations.AllCommandPlugin>(); } }
That's really it. By the time AddPlugins(services) runs, those registrations are already baked into the binary. No Assembly.GetTypes(). No Activator.CreateInstance. Just method calls, in the order [PluginOrder] asked for.
And you don't have to take my word for it β the generated files show up under Dependencies β Analyzers β MyApp.SourceGeneration β PluginRegistrationGenerator in Solution Explorer. Open them, read them, set breakpoints:

Runtime reflection vs build-time generation
Both approaches solve the same problem β they just do it in completely different places:
flowchart TB
subgraph RT[Runtime reflection approach]
R1[App starts] --> R2[Scan loaded assemblies]
R2 --> R3[Filter types by interface]
R3 --> R4[Register each with DI]
R4 --> R5[(Cost: startup time<br/>+ trim/AOT risk<br/>+ silent misses)]
end
subgraph BT[Build-time generator approach]
B1[Build starts] --> B2[Roslyn discovers types]
B2 --> B3[Generator emits AddSingleton calls]
B3 --> B4[Compiler compiles them into IL]
B4 --> B5[(Runtime: just method calls<br/>AOT-safe, zero scan)]
end
RT ~~~ BT
The runtime approach is convenient to write, but it pushes the costs β startup time, trim compatibility, silent misses β onto whoever consumes your library. The build-time version pays those costs once, at compile time, and ships a flat AOT-safe assembly.
Adding a new plugin in practice
Say you want a time command that prints the current time. Here's the entire workflow:
- Create a class that implements
ICommandPlugin, optionally annotated with[PluginOrder(2)]. - Build.
dotnet run -- timeworks.
The generator picks up your class on the next compile, regenerates CommandPluginRegistration.g.cs, and services.GetServices<ICommandPlugin>() returns it in exactly the position your attribute requested. Delete the class and the generated file loses one line on the next build. Typo the interface name and the build fails immediately β you never make it to runtime.
Takeaways
Source generators take the category of code that used to live in the "magic reflection" bucket and turn it into plain, inspectable C# the compiler can reason about. The PluginRegistrationGenerator in this sample is deliberately small, but the pattern scales:
- A
partialconsumer class +static partial voidstubs gives you safe, optional extension points that compile fine even before the generator runs. - A two-stage incremental pipeline (cheap syntactic filter first, semantic resolution second) keeps the IDE responsive.
- Aspect-based metadata β attributes like
[PluginOrder]β lets each implementation declare cross-cutting concerns without coupling the generator to specific type names. - Deterministic, sorted output keeps builds reproducible and incremental caches valid.
- Build-time wiring means no reflection, no AOT surprises, and errors surface during
dotnet buildinstead ofdotnet run.
If you've been writing a manual DI registration for every new implementation of an interface β or worse, scanning assemblies at startup β a generator like this is usually a one-evening project that pays you back indefinitely.
The full sample, generator, plugins, and tests included, is here:
SourceGeneratedDIRegistrationon GitHubPluginRegistrationGenerator.csServiceCollectionExtensions.cs
What is a C# source generator?
How does a source generator register services automatically?
services.AddSingleton<I, T>() calls into the body of a static partial void method declared by the consumer.Why is source-generated DI registration better than reflection?
How do I reference a source generator from a consumer project?
<ProjectReference> with OutputItemType="Analyzer", ReferenceOutputAssembly="false", and SetTargetFramework="TargetFramework=netstandard2.0" so MSBuild loads it as a build-time analyzer rather than a runtime dependency.Can a source generator handle ordering or other cross-cutting concerns?
[PluginOrder(1)] to the implementation; the generator reads it from ITypeSymbol.GetAttributes() and uses it as a sort key when emitting registrations.