How to automatically register Minimal API endpoints in ASP.NET Core
ASP.NET Core 6 introduced Minimal APIs as a lightweight alternative to controllers for building HTTP APIs. Instead of relying on [Controller]
classes and attribute routing, minimal APIs define endpoints directly in code, typically within the Program.cs file. This approach reduces ceremony and speeds up development for small or focused APIs.
However, with this flexibility comes a structural challenge. Unlike controllers, which naturally group endpoints by class and namespace, minimal APIs impose no organizational structure. As a result, applications can quickly become difficult to navigate and maintain as endpoint definitions accumulate in Program.cs
or get scattered across multiple files without a clear pattern.
Over time, several patterns have emerged to help group and structure minimal API endpoints: for example, using static classes with MapXyzEndpoints(WebApplication app)
methods or organizing routes by feature folders. However, since the framework does not provide any built-in mechanism for automatic endpoint discovery or registration, developers are left to manually wire up each endpoint group in Program.cs. This manual step introduces friction and increases the risk of missing or inconsistent registrations, especially as the application grows.
A simple test application
A common approach to organizing minimal API endpoints is to define static classes with explicit mapping methods. Here’s a simple example for handling a /todos
route group:
public static class TodoEndpoints
{
public static IEndpointRouteBuilder MapTodoEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("todos");
group.MapGet("/", ListAsync);
// additional todos endpoints
return builder;
}
private static Task<IResult> ListAsync()
{
var todos = new[]
{
new { Id = 1, Title = "Buy milk", Completed = false },
new { Id = 2, Title = "Write blog post", Completed = true }
};
return Task.FromResult(Results.Ok(todos));
}
}
Then in Program.cs
, you register the endpoints explicitly:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Manual registration
app.MapTodoEndpoints();
app.Run();
This pattern improves separation of concerns and keeps Program.cs
manageable. However, as more endpoint groups are added, this manual registration process becomes repetitive and easy to forget, especially in larger applications or modular architectures.
A first solution
One way to automate minimal API registration is by scanning the application’s assemblies at runtime and using reflection to discover and invoke endpoint mapping methods. This pattern typically involves defining a shared interface and having each endpoint group implement it.
For example, you might define a simple interface like this:
public interface IEndpoint
{
void MapEndpoints(IEndpointRouteBuilder builder);
}
Then update the TodoEndpoints
class to implement it:
public class TodoEndpoints : IEndpoint
{
public void MapEndpoints(IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("todos");
group.MapGet("/", ListAsync);
}
private static Task<IResult> ListAsync()
{
var todos = new[]
{
new { Id = 1, Title = "Buy milk", Completed = false },
new { Id = 2, Title = "Write blog post", Completed = true }
};
return Task.FromResult(Results.Ok(todos));
}
}
During startup, the application can use reflection to locate all types implementing IEndpoint
, instantiate them, and invoke the MapEndpoints
method.
A great example of this approach is described in Milan Jovanović’s blog post, where endpoint groups implement a custom interface, and the application uses reflection to locate and execute them at startup. This method provides a centralized and extensible way to manage endpoint registration while reducing manual code in Program.cs
.
However, reflection-based solutions come with trade-offs such as runtime overhead, less visibility in tooling, and reduced compile-time safety. More importantly, they prevent the application from being fully compatible with Ahead-Of-Time (AOT) compilation, which is increasingly important for optimizing startup time and deployment size in modern .NET applications.
A better solution
A more robust and future-proof approach is to use Roslyn source generators, a feature of the .NET compiler that allows code to be generated at compile time based on your application’s code. This eliminates the need for runtime reflection, improves performance, retains full IntelliSense and compile-time safety, and ensures compatibility with Ahead-Of-Time (AOT) compilation.
The ServiceScan.SourceGenerator library builds on this concept by allowing you to automatically register services or endpoints based on compile-time analysis. It scans your project for types matching specified interfaces and emits code to invoke them during application startup.
In this approach, we define a shared marker interface for minimal API endpoints:
public interface IEndpoint
{
static abstract void MapEndpoint(IEndpointRouteBuilder builder);
}
Each endpoint group implements this interface using a static MapEndpoint
method. For example:
public class TodoEndpoints : IEndpoint
{
public static void MapEndpoint(IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("todos");
group.MapGet("/", ListAsync);
}
private static Task<IResult> ListAsync()
{
var todos = new[]
{
new { Id = 1, Title = "Buy milk", Completed = false },
new { Id = 2, Title = "Write blog post", Completed = true }
};
return Task.FromResult(Results.Ok(todos));
}
}
Then we define a partial extension method that ServiceScan will complete using source generation:
public static partial class HttpEndpointServiceCollectionExtensions
{
[GenerateServiceRegistrations(
AssignableTo = typeof(IEndpoint),
CustomHandler = nameof(MapEndpoint)
)]
public static partial IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder builder);
private static void MapEndpoint<T>(IEndpointRouteBuilder builder) where T : IEndpoint
{
T.MapEndpoint(builder);
}
}
At compile time, the source generator will find all implementations of IEndpoint
and generate code that calls MapEndpoint<T>
for each one.
Finally, in Program.cs, registration becomes a single line:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapEndpoints();
app.Run();
This approach offers the best of both worlds: clean modular code, no manual registration, full static typing, and zero reflection at runtime.
Recap
Minimal APIs offer a clean and efficient way to build HTTP endpoints in ASP.NET Core, but their lack of built-in structure can lead to fragmentation and manual overhead as applications grow.
While reflection-based registration techniques offer some relief, they introduce runtime costs and are incompatible with AOT compilation.
By contrast, using source generators like those provided by the ServiceScan.SourceGenerator library enables a fully static, compile-time solution that keeps your codebase modular, maintainable, and future-proof, without sacrificing performance or tooling support.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!