- Published on
Dynamic Endpoint Registration in ASP.NET Core Minimal APIs Using Reflection
- Authors
- Name
- Ivan Gechev
Minimal APIs are awesome, but when they grow, the Program.cs file can quickly become overwhelming with dozens or even hundreds of endpoint definitions cluttering up our code. In this article, we'll explore how to use reflection to automatically discover and register all endpoints in our application, keeping our codebase clean and maintainable.
When I started building the business management system at Sandberg Translation Partners, I knew from day one this was going to be a very extensive system. We're talking projects, clients, translators, agencies, invoices, quotes, tasks, time tracking, document management, notifications, reports - and the list goes on. I quickly realized that if I defined every endpoint manually in Program.cs, I'd end up with a file that would be a nightmare to navigate.
I opted for the reflection-based approach from the start, and it's been a game-changer. Now when I add a new resource, I create a class that implements the IEndpoint interface, define my routes, and that's it. The system automatically discovers and registers it at startup. No manual registration, no forgetting to wire things up, no cluttered Program.cs. Everything just works like magic, but it's not.
In this post, we'll build a cat-related API (because cats make everything better) and implement a reflection-based registration system that scales effortlessly as we add more endpoints. Along the way, we'll dive deep into how reflection, assembly scanning, and service descriptors work together to create this automatic registration system.
The Problem With Manual Endpoint Registration
Let's look at a typical Minimal API setup with just a few endpoints:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapGet("/cats",
async (ICatService service) =>
{
var cats = await service.GetAllAsync();
return Results.Ok(cats);
})
.WithName("GetAllCats")
.Produces<IEnumerable>(StatusCodes.Status200OK);
app.MapGet("/cats/{id:guid}",
async (Guid id, ICatService service) =>
{
var cat = await service.GetByIdAsync(id);
return cat is not null ? Results.Ok(cat) : Results.NotFound();
})
.WithName("GetCatById")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/cats",
async (CreateCatRequest request, ICatService service) =>
{
var cat = await service.CreateAsync(request);
return Results.Created($"/cats/{cat.Id}", cat);
})
.WithName("CreateCat")
.Produces(StatusCodes.Status201Created)
.ProducesValidationProblem();
// Imagine the remaining CRUD endpoints for cats...
// Now add veterinarians endpoints...
// Then add appointments endpoints...
app.Run();
Those are just three endpoints, and already our Program.cs is getting a bit hard to navigate. As we expand our API, it will become harder and harder to navigate and maintain. We obviously need a better approach that scales with our application.
Creating the Endpoint Abstraction
The key to automatic registration is creating an interface that all endpoints must implement:
public interface IEndpoint
{
void MapEndpoints(IEndpointRouteBuilder routeBuilder);
}
We create the IEndpoint interface with a single method called MapEndpoints(). This method takes an IEndpointRouteBuilder as a parameter. The IEndpointRouteBuilder interface is implemented by WebApplication and provides us with the core routing functionality through methods like MapGet(), MapPost(), MapPut(), and MapDelete(). By accepting this interface, our endpoint classes can register routes on any object that implements it.
This approach is important as it maintains the same programming model we use in Program.cs. When we call app.MapGet(), we're using IEndpointRouteBuilder methods. Next, when we call routeBuilder.MapGet() in our endpoint classes, we're going to be using the exact same methods through the exact same interface.
Now, let's move on and implement this interface for our cat endpoints:
public class CatEndpoints : IEndpoint
{
public void MapEndpoints(IEndpointRouteBuilder routeBuilder)
{
routeBuilder.MapGet("/cats",
async (ICatService service) =>
{
var cats = await service.GetAllAsync();
return Results.Ok(cats);
})
.WithName("GetAllCats")
.Produces<IEnumerable>(StatusCodes.Status200OK);
routeBuilder.MapGet("/cats/{id:guid}",
async (Guid id, ICatService service) =>
{
var cat = await service.GetByIdAsync(id);
return cat is not null ? Results.Ok(cat) : Results.NotFound();
})
.WithName("GetCatById")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
routeBuilder.MapPost("/cats",
async (CreateCatRequest request, ICatService service) =>
{
var cat = await service.CreateAsync(request);
return Results.Created($"/cats/{cat.Id}", cat);
})
.WithName("CreateCat")
.Produces(StatusCodes.Status201Created)
.ProducesValidationProblem();
}
}
We create the CatEndpoints class and implement the IEndpoint interface. Inside the MapEndpoints() method, we define all routes related to cats using the same Map methods we're familiar with from the Program.cs file.
The beauty of this approach is that each endpoint class becomes a self-contained module responsible for its own route definitions and their documentation. Want to add a new cat-related route? Just add it to this class.
An important note: the MapEndpoints() method is called during application startup, after the service provider has been built. This means any services registered in our dependency injection container are available for injection into our endpoint handlers, just like they would be in Program.cs.
If you want to master Minimal APIs and learn advanced patterns like this, check out my Minimal APIs in ASP.NET Core course here.
Understanding Assembly Scanning and Type Discovery
Now, it's time for us to work our magic. We need to utilize reflection to discover all classes implementing the IEndpoint interface and register them automatically.
Let's break this down step by step:
public static class EndpointExtensions
{
public static IServiceCollection AddEndpoints(this IServiceCollection services)
{
var assembly = typeof(Program).Assembly;
var serviceDescriptors = assembly
.DefinedTypes
.Where(type => !type.IsAbstract &&
!type.IsInterface &&
type.IsAssignableTo(typeof(IEndpoint)))
.Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type));
services.TryAddEnumerable(serviceDescriptors);
return services;
}
}
We create the EndpointExtensions class and it will contain our extension methods. The AddEndpoints() method extends IServiceCollection and does the majority of the work when it comes to endpoint discovery. Let's examine each part in detail.
First, we retrieve the assembly containing our Program class using typeof(Program).Assembly. Note that if you place your endpoints in another assembly, you will need to reference it and use a different assembly marker.
Next, we access the DefinedTypes property. It returns an IEnumerable<TypeInfo> that contains metadata about every type defined in our assembly. The TypeInfo class provides rich reflection capabilities, including information about base types, implemented interfaces, constructors, methods, properties, and many more.
Our filtering logic uses three important conditions:
.Where(type => !type.IsAbstract &&
!type.IsInterface &&
type.IsAssignableTo(typeof(IEndpoint)))
The IsAbstract property check excludes abstract classes. While abstract classes can implement interfaces, we can't instantiate them, so they shouldn't be registered as services. With this filter, we ensure that if you have an abstract base class like BaseEndpoint : IEndpoint, it won't be registered.
The IsInterface property check is equally important. In C#, interfaces can implement other interfaces, so technically IEndpoint itself would match our IsAssignableTo check. We need to explicitly exclude interfaces from our registration.
The IsAssignableTo() method is where the real magic happens. This method returns true if the type can be assigned to the target type. For interfaces, this means "does this type implement this interface?" For classes, it includes inheritance checking - if we create a base class like BaseEndpoint : IEndpoint and then CatEndpoint : BaseEndpoint, the IsAssignableTo() method will correctly identify CatEndpoint even though it doesn't directly declare the interface implementation.
Working With Service Descriptors
After filtering our types, we transform each one into a ServiceDescriptor:
.Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type))
A ServiceDescriptor is the fundamental building block of .NET's dependency injection system. It contains all the information needed to create service instances: the service type (the interface or abstract class), the implementation type (the concrete class), and its lifetime (Transient, Scoped, or Singleton).
We use the ServiceDescriptor.Transient() method to create a descriptor with transient lifetime. The first parameter we pass is the service type - we use typeof(IEndpoint). The second parameter is the implementation type.
This registration pattern is crucial: by registering multiple implementations of the same interface (IEndpoint), we can later retrieve all of them as an IEnumerable<IEndpoint>. Without this, registering multiple types with the same service type would result in each registration overwriting the previous one.
Understanding TryAddEnumerable
The final piece of our registration is TryAddEnumerable():
services.TryAddEnumerable(serviceDescriptors);
This method is part of the Microsoft.Extensions.DependencyInjection.Extensions namespace and serves a specific purpose: it registers multiple implementations of the same service type while preventing duplicate registrations.
The TryAddEnumerable() only adds descriptors that haven't already been registered. It determines uniqueness by comparing both the service type and the implementation type.
You might wonder why is this important? Consider what happens if we call AddEndpoints() multiple times (perhaps by accident, or in our tests):
builder.Services.AddEndpoints();
builder.Services.AddEndpoints(); // Called again accidentally
With the regular AddEnumerable() method, we'd register CatEndpoint twice. Then, later, when we retrieve IEnumerable<IEndpoint>, we'd get two instances of the same class. With TryAddEnumerable(), the second call will skip any services that we've already registered, while giving us the idempotent behavior we want.
Resolving and Invoking Endpoints
We've handled our endpoint registrations, now we need a method that will actually map their routes:
public static IApplicationBuilder RegisterEndpoints(this WebApplication app)
{
var endpoints = app.Services
.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (var endpoint in endpoints)
{
endpoint.MapEndpoints(app);
}
return app;
}
The RegisterEndpoints() method extends WebApplication and completes our registration system. Let's examine what happens when this method executes.
First, we call GetRequiredService<IEnumerable<IEndpoint>>(). This is where the dependency injection container performs service resolution. When we request IEnumerable<T>, the container automatically returns all services registered with service type T, regardless of their implementation types.
The GetRequiredService() method (as opposed to GetService()) throws an InvalidOperationException if no services are found. This fail-fast behavior is important during startup - if we forgot to call AddEndpoints(), we want the application to fail immediately rather than silently skip endpoint registration.
Next, we loop through each resolved endpoint and call its MapEndpoints() method, passing the WebApplication instance.
It's worth noting that we pass app directly to MapEndpoints(), which means endpoints have access to the entire WebApplication instance. This gives our endpoints the flexibility to access configuration, apply route-level metadata, or even conditionally map routes based on environment or settings.
Putting It All Together
With all the pieces in place, our Program.cs becomes incredibly clean:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
// Register all endpoint implementations
builder.Services.AddEndpoints();
var app = builder.Build();
// Map all routes from registered endpoints
app.RegisterEndpoints();
app.Run();
That's it! No matter how many endpoints we add to our application, our Program.cs class remains clean.
Let's examine what happens when we run our application:
builder.Services.AddEndpoints()is called during the configuration phase- Reflection scans the assembly containing the
Programclass for all types that implement theIEndpointinterface - Service descriptors are created for each discovered type and added to the service collection
var app = builder.Build()constructs the service provider and "freezes" the service collectionapp.RegisterEndpoints()requests allIEndpointservices from the now-built container- The DI container instantiates each endpoint class and returns them as an enumerable
- We loop through each endpoint and call
MapEndpoints(), which registers all its routes with proper documentation app.Run()starts the application with all routes fully configured
Performance Considerations and Trade-offs
While reflection is often cited as a performance concern, let's examine the actual impact in this scenario.
The assembly scanning and type filtering happens exactly once at application startup, before any HTTP requests are served. On a typical modern machine, scanning an assembly with hundreds of types takes only a few milliseconds. Even in large applications, this adds negligible startup time.
The more important consideration is the service instantiation cost. For each endpoint class, the DI container must:
- Locate the constructor
- Resolve any constructor dependencies
- Invoke the constructor
- Call
MapEndpoints()
If our endpoint classes have constructor dependencies (which they might for configuration or specialized services), those dependencies must be resolved from the container. This can add up if we have dozens of endpoint classes with complex dependency trees.
However, this again happens only during startup, and not during request processing. Once MapEndpoints() completes for all endpoints, the endpoint class instances become eligible for garbage collection, and the registered routes remain active in memory. Your request processing performance is identical to manual route registration.
When to Use Reflection-Based Registration
This approach shines in several scenarios:
Large Applications: When we have dozens of endpoints, automatic discovery saves significant boilerplate and keeps our code organized. The reflection overhead becomes negligible compared to the development time saved.
Convention-Based Architecture: If the team you work with follows a consistent pattern for defining endpoints, any class implementing IEndpoint is automatically discovered, ensuring developers can't forget to register new endpoints.
However, this approach isn't always the best choice. The automatic nature can make it less obvious which endpoints are registered, which might confuse developers unfamiliar with the system. For smaller APIs with just a handful of endpoints, the added complexity might not be worth it.
If you prefer more explicit control or have a smaller API, you might want to check out my follow-up article on simpler extension method registration, which provides a middle-ground approach without reflection.
Conclusion
Reflection-based endpoint registration transforms how we structure Minimal APIs in .NET. By establishing a common IEndpoint interface and using assembly scanning to discover implementations, we eliminate the clutter in Program.cs and create a scalable architecture that grows effortlessly with our application. The reflection overhead is negligible since it only runs once at startup, and the benefits in code organization and maintainability make it worthwhile for medium to large applications.