How to automatically register service modules in ASP.NET Core
In a previous post, I used source generation to discover and register Minimal API endpoints at compile time. That removed the need to update Program.cs every time a new endpoint group was added.
The same problem can appear earlier in application startup, while configuring dependency injection.
As an application grows, related service registrations often end up grouped behind extension methods:
var builder = WebApplication.CreateBuilder(args);
builder.AddOrderServices();
builder.AddNotificationServices();
builder.AddPersistenceServices();
This keeps the individual registrations out of Program.cs, but the composition root still needs to know about every module. Adding a new module means creating its registration code and remembering to add another call during startup.
The endpoint-registration post solved the same kind of maintenance problem at the HTTP boundary. In this post, we will apply the same source-generated approach to service modules.
Defining service modules
The first step is to give every module the same registration contract.
Since service registration does not require a module instance, the interface can expose a static abstract method:
public interface IServiceModule
{
static abstract void Register(WebApplicationBuilder builder);
}
Each module can then own the registrations related to one application capability.
For example, an order module could register its application service and repository:
public sealed class OrderServiceModule : IServiceModule
{
public static void Register(WebApplicationBuilder builder)
{
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<OrderService>();
}
}
A notification module can configure its options and register a client:
public sealed class NotificationServiceModule : IServiceModule
{
public static void Register(WebApplicationBuilder builder)
{
builder.Services
.AddOptions<NotificationOptions>()
.BindConfiguration("Notifications")
.ValidateOnStart();
builder.Services.AddHttpClient<NotificationClient>();
}
}
The modules do not need to be instantiated or registered themselves. Their only responsibility is to describe how their part of the application is added to the builder.
Generating the registration method
The ServiceScan.SourceGenerator package can find every type implementing IServiceModule during compilation and generate the calls needed to register it.
The source-generator hook is another partial extension method:
using System.Diagnostics.CodeAnalysis;
using ServiceScan.SourceGenerator;
[ExcludeFromCodeCoverage(Justification = "Registers services")]
public static partial class ServiceModuleRegistrationExtensions
{
[ScanForTypes(
AssignableTo = typeof(IServiceModule),
Handler = nameof(IServiceModule.Register))]
public static partial WebApplicationBuilder AddServiceModules(
this WebApplicationBuilder builder);
private static void Register<T>(WebApplicationBuilder builder)
where T : IServiceModule
{
T.Register(builder);
}
}
ScanForTypes selects the types assignable to IServiceModule. For every matching type, the generated implementation invokes the local generic handler.
Conceptually, the generated method is equivalent to this:
public static WebApplicationBuilder AddServiceModules(
this WebApplicationBuilder builder)
{
Register<NotificationServiceModule>(builder);
Register<OrderServiceModule>(builder);
Register<PersistenceServiceModule>(builder);
return builder;
}
The real generated implementation may differ in shape, but the important part is that the list of modules is produced at compile time. No assembly scanning takes place while the application starts.
Using the generated hook
Once the partial method is in place, Program.cs only needs one registration call:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceModules();
var app = builder.Build();
app.Run();
Adding another module now requires implementing IServiceModule. The next build discovers it and includes its registration call in the generated method.
When combined with the generated endpoint registration from the previous post, both application-composition boundaries follow the same pattern:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceModules();
var app = builder.Build();
app.MapEndpoints();
app.Run();
AddServiceModules builds the service graph. MapEndpoints builds the HTTP surface.
Keeping modules useful
Automatic discovery does not define where module boundaries should be.
A module should represent a coherent capability or infrastructure concern. For example, an order module might own its application service, repository abstraction, validators, and options. A persistence module might own database clients and shared repository infrastructure.
Creating one module per class or folder would only replace a long list of service registrations with a large number of tiny registration files.
The same applies to dependencies between modules. If one module must be registered before another, automatic discovery hides an important ordering constraint. In that case, it may be better to keep the relationship explicit or move the shared registration into a separate module that both can depend on.
The pattern works best when modules are independent and registration order does not affect the resulting service collection.
Trade-offs
The explicit version makes the composition root easy to inspect:
builder.AddOrderServices();
builder.AddNotificationServices();
builder.AddPersistenceServices();
The generated version is easier to maintain:
builder.AddServiceModules();
The cost is that Program.cs no longer lists every module participating in the application. A developer needs to know the convention or inspect the generated source to see the complete registration set.
Source generation also catches fewer architectural mistakes than the compact startup code might suggest. It can discover modules and emit deterministic calls, but it cannot decide whether the modules are cohesive, whether registrations conflict, or whether hidden ordering dependencies exist.
This is therefore a convention for applications where automatic discovery removes repetitive work without concealing meaningful startup decisions.
Recap
Grouping dependency-injection registrations into modules keeps individual services out of the composition root, but manually registering every module still creates a maintenance step.
By giving modules a shared static contract and using ServiceScan.SourceGenerator to discover them at compile time, ASP.NET Core applications can reduce service registration to one generated call without runtime reflection.
Together with generated Minimal API endpoint registration, the result is a small and consistent composition root: one hook for the service graph and one for the HTTP surface.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!