Automatically Register Minimal APIs in ASP.NET Core
Learn to auto-register Minimal API endpoints in ASP.NET Core using the IEndpoint pattern, assembly scanning, and source generators. With diagrams and code.
A school where every teacher writes their own name on the board
Imagine the first day at a big school. Every morning, the head teacher has to walk into the main hall and read out a long list: "Maths class is in Room 1, Science is in Room 2, History is in Room 3..." The list keeps growing every year. New teachers join. Old rooms change. Soon the head teacher is reading out a hundred lines, and one day they forget a class, and those students have nowhere to go.
Now imagine a smarter school. Each teacher simply writes their own subject and room on the notice board when they arrive. The head teacher does not keep a master list at all. They just say one sentence: "Everyone, please put your card on the board." The board fills itself. Add a new teacher, and the board grows on its own. Remove one, and their card is simply gone. Nobody has to remember anything.
Your Program.cs file in ASP.NET Core is that main hall. Every MapGet, MapPost, and MapPut you write by hand is the head teacher reading another line. As your app grows, that file becomes a giant scroll that everyone is scared to touch.
In this post we will build the smarter school. Each endpoint will write its own card. One sentence in Program.cs will fill the whole board. Let us see how.
What "Minimal APIs" means first
Before the trick, a quick refresher. Minimal APIs are a light way to build web APIs in ASP.NET Core. Instead of controllers and lots of ceremony, you map a route straight to a small function.
Here is the classic starting point that every tutorial shows you.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/hello", () => "Hello!");
app.MapGet("/products/{id}", (int id) => $"Product {id}");
app.MapPost("/products", (Product product) => Results.Created());
app.Run();This is lovely for three endpoints. The problem starts at thirty. All those Map... lines pile up in one file. Two developers editing them at the same time get merge conflicts. Finding "the code for the orders endpoint" means scrolling and scrolling. We want each endpoint to live on its own, in its own file, and join the app by itself.
The big idea in one picture
The plan has three small parts: a shared interface, an endpoint class, and a tiny bit of startup glue. Here is the shape of it.
The interface is the agreement. Every endpoint promises to have one method that maps its route. The startup glue is the head teacher's single sentence: "everyone put your card on the board." Let us build each piece.
Step 1: The shared interface
This is the smallest, most important piece. It is the card format that every teacher must follow.
public interface IEndpoint
{
void MapEndpoint(IEndpointRouteBuilder app);
}That is the whole thing. IEndpointRouteBuilder is the type that gives you MapGet, MapPost, and friends. Both WebApplication and route groups implement it, so this interface works everywhere routes can be added.
The rule we agree on: one class, one endpoint, one MapEndpoint method. Keeping it to a single endpoint per class means each file stays short and has one clear job.
Step 2: Write some endpoints
Now each endpoint becomes its own little class. Here are two of them. Notice how each one is fully self-contained — it knows its own route and its own logic.
public sealed class GetProductEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("/products/{id}", (int id, ProductService service) =>
{
var product = service.Find(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
}
}
public sealed class CreateProductEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("/products", (Product product, ProductService service) =>
{
service.Add(product);
return Results.Created($"/products/{product.Id}", product);
});
}
}You can ask for services like ProductService right in the handler — Minimal APIs inject them for you, just like before. Nothing about the endpoint logic changes. We only changed where it lives. Each endpoint now sits in its own file, ready to put its card on the board.
Step 3: The startup glue (reflection version)
Here is the head teacher's single sentence. We write two small extension methods. One finds every IEndpoint and registers it with dependency injection. The other asks each one to map itself.
public static class EndpointExtensions
{
public static IServiceCollection AddEndpoints(
this IServiceCollection services, Assembly assembly)
{
ServiceDescriptor[] descriptors = assembly
.DefinedTypes
.Where(type => type is { IsAbstract: false, IsInterface: false }
&& type.IsAssignableTo(typeof(IEndpoint)))
.Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type))
.ToArray();
services.TryAddEnumerable(descriptors);
return services;
}
public static IApplicationBuilder MapEndpoints(this WebApplication app)
{
IEnumerable<IEndpoint> endpoints = app.Services
.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (IEndpoint endpoint in endpoints)
{
endpoint.MapEndpoint(app);
}
return app;
}
}Let us read this slowly, because it is the heart of the whole article.
AddEndpoints looks through one assembly. It finds every type that is a real class (not abstract, not an interface) and that implements IEndpoint. For each one it creates a service registration. TryAddEnumerable adds them all so they can be resolved together as a collection. We register them as Transient so each gets a fresh instance.
MapEndpoints then asks the service provider for all the IEndpoint instances at once and loops through them, calling MapEndpoint on each. Because they came from dependency injection, each endpoint could even have its own constructor dependencies if it needs them.
Now your Program.cs shrinks to this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpoints(typeof(Program).Assembly);
var app = builder.Build();
app.MapEndpoints();
app.Run();Two lines. That is the whole notice board. Add a new endpoint class anywhere in the project and it joins the app on the next run. Delete one and it is simply gone. Nobody edits Program.cs again.
How a request flows through the system
Let us trace what happens from boot to the first request, so the magic feels less like magic.
From startup to a served request
Steps
Build
App host starts up
Scan
Reflection finds IEndpoint classes
Register
Each added to DI container
Map
MapEndpoint called on each
Serve
Routes answer requests
The first three steps happen once, at startup. The last step happens on every request. This is the key point about performance: the scanning cost is paid a single time, not per request.
Grouping endpoints together
Real APIs like to group routes. All the product routes might share /api/products, a tag for documentation, and an authorization rule. You can build the group once in Program.cs and pass it to the endpoints. A small change to MapEndpoints lets you hand a group builder to each endpoint instead of the whole app.
public static IApplicationBuilder MapEndpoints(
this WebApplication app, RouteGroupBuilder? group = null)
{
IEnumerable<IEndpoint> endpoints = app.Services
.GetRequiredService<IEnumerable<IEndpoint>>();
IEndpointRouteBuilder builder = group is null ? app : group;
foreach (IEndpoint endpoint in endpoints)
{
endpoint.MapEndpoint(builder);
}
return app;
}Then in Program.cs you can do this:
var versionedGroup = app.MapGroup("/api/v1").WithTags("v1");
app.MapEndpoints(versionedGroup);Because RouteGroupBuilder also implements IEndpointRouteBuilder, every endpoint now hangs off /api/v1 without changing a single endpoint class. This pairs very well with API versioning, where each version is its own group.
Reflection versus source generators
There are two common ways to find your endpoints. Reflection finds them while the app is running. A source generator finds them while the app is being built. Both end with the same result: every MapEndpoint gets called.
Here is how they compare on the things that matter day to day.
| Concern | Reflection | Source generator |
|---|---|---|
| Setup effort | Very low | More work upfront |
| Startup speed | Tiny one-time scan | Fastest, no scan |
| Native AOT support | Tricky | Excellent |
| Trimming friendly | Needs care | Yes |
| Debuggability | Normal | You can read generated code |
| Best for | Most apps | AOT, serverless cold starts |
For the great majority of projects, the reflection version you saw above is the right call. It is short, clear, and the startup cost is invisible. Only reach for a source generator when you publish with Native AOT, when you trim aggressively, or when cold-start time is genuinely critical, such as in some serverless functions.
A quick word on libraries: some people used to reach for big mediator or messaging libraries to organise this kind of code. Note that popular ones like MediatR and MassTransit moved to commercial licensing in their newer versions, so check the licence and cost before adding them. The plain IEndpoint pattern shown here needs no extra package at all — it is just a small interface and an extension method.
When to use this pattern and when not to
This pattern shines as a project grows, but it is not free of trade-offs. Here is an honest comparison with writing Map... calls by hand.
| Situation | Manual MapGet/MapPost | Auto-registered IEndpoint |
|---|---|---|
| Three or four endpoints | Simplest, do this | Overkill |
| Dozens of endpoints | Hard to manage | Clean and scalable |
| Many developers | Merge conflicts in Program.cs | Each endpoint in its own file |
| Feature folders | Awkward | Natural fit |
| Need fastest cold start | Fine | Use source generator variant |
The honest takeaway: if your whole API is four routes, just write the four MapGet lines. The auto-registration pattern earns its keep once the count climbs and a team is working together. Do not add machinery you do not need yet.
Choosing your approach
Steps
Count
Few routes? Stay manual
Team
Many devs? Auto-register
AOT
Native AOT? Source generator
Pick
Choose and keep it consistent
Common mistakes to avoid
A few small traps catch people the first time they try this.
The first trap is forgetting to register the assembly. AddEndpoints scans the assembly you pass in. If your endpoints live in a different project or library, pass that assembly, for example typeof(SomeEndpointInThatProject).Assembly. Pass the wrong one and your routes silently never appear.
The second trap is the lifetime of the endpoint classes. We registered them as Transient. That is fine because they are only used once at startup to map routes. Do not put heavy work or long-lived state in the endpoint class constructor — keep the class light, and let the handler ask for the real services it needs at request time.
The third trap is hidden ordering needs. Reflection does not promise any particular order of discovery. If two endpoints map the same route and order matters, that is a design smell. Give each endpoint a unique route and ordering stops mattering.
The fourth trap is sealing and access. Mark endpoint classes public so reflection can see them across assemblies, and sealed for a tiny performance and clarity win. A private or internal class in another assembly will be skipped.
A fuller example tying it together
Let us put the pieces into one small, believable feature so you can see the shape of a real project. Imagine a folder called Features/Products with one file per endpoint, plus a shared service. Each file is short and focused.
// Features/Products/ListProductsEndpoint.cs
public sealed class ListProductsEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("/products", (ProductService service) =>
Results.Ok(service.All()))
.WithName("ListProducts")
.WithTags("Products");
}
}
// Features/Products/DeleteProductEndpoint.cs
public sealed class DeleteProductEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapDelete("/products/{id}", (int id, ProductService service) =>
{
bool removed = service.Remove(id);
return removed ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteProduct")
.WithTags("Products");
}
}Notice that each endpoint still uses every normal Minimal API feature: WithName, WithTags, filters, validation, authorization — all of it works exactly as it does on a hand-written route. The auto-registration changes nothing about what an endpoint can do. It only changes how the endpoint joins the app.
When you add a new file like UpdateProductEndpoint.cs, you do not touch Program.cs, you do not touch any list, and you do not tell anyone. The class implements IEndpoint, the scanner finds it, and the route is live. That is the whole promise: the notice board fills itself.
A note on testing
One quiet benefit of this pattern is testability. Because each endpoint is a small class with one method, and because the handlers ask for their dependencies through normal dependency injection, you can test the handler logic in isolation. You can also write one integration test that boots the app with WebApplicationFactory and checks that every expected route responds, which quietly proves your auto-registration is working end to end. If someone forgets to make a class public, that single test will catch the missing route before it reaches production.
Quick recap
- Hand-writing every
MapGetandMapPostinProgram.csdoes not scale. The file grows, and a team trips over it. - The fix is a tiny
IEndpointinterface with oneMapEndpoint(IEndpointRouteBuilder app)method. - Each endpoint lives in its own small class and implements that interface — one class, one endpoint.
AddEndpointsscans an assembly and registers every endpoint with dependency injection.MapEndpointsasks for them all and callsMapEndpointon each, so routes register themselves.Program.csshrinks to two lines, and adding a new endpoint never touches it.- Reflection is fine for most apps; the scan is a one-time, startup-only cost.
- Use a source generator variant for Native AOT, trimming, or the fastest cold starts.
- Watch the common traps: scan the right assembly, keep classes
publicand light, and give each route a unique path. - Be careful with MediatR and MassTransit — their newer versions are now commercially licensed. The plain
IEndpointpattern needs no extra package.
References and further reading
- Minimal APIs overview — Microsoft Learn
- Route handlers in Minimal API apps — Microsoft Learn
- Automatically Register Minimal APIs in ASP.NET Core — Milan Jovanović
- Automatic Registration of Minimal API Endpoints in .NET — Code Maze
- How to automatically register Minimal API endpoints in ASP.NET Core — Renato Golia
- Mapping Minimal API endpoints with C# source generators — Coding Militia
Related Posts
3 Ways To Create Middleware In ASP.NET Core (Beginner Guide)
Learn the 3 ways to create middleware in ASP.NET Core: inline request delegates, convention-based classes, and factory-based IMiddleware, with simple diagrams and code.
API Versioning in ASP.NET Core: A Friendly, Complete Guide
Learn API versioning in ASP.NET Core with simple examples. URL, query string, header, and media type versioning explained with diagrams, code, and OpenAPI tips.
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
Building Fast Serverless APIs With Minimal APIs on AWS Lambda
Learn to run ASP.NET Core Minimal APIs on AWS Lambda for fast, cheap serverless APIs. Covers setup, cold starts, Native AOT, and .NET 10 with diagrams and code.
Getting Started with FastEndpoints for Building Web APIs in .NET
A friendly beginner guide to FastEndpoints in .NET. Learn the REPR pattern, build your first endpoint, add validation, and see how it compares to controllers.
How to Structure Minimal APIs in ASP.NET Core (.NET 10)
Learn how to structure Minimal APIs in ASP.NET Core with route groups, endpoint files, DTOs, TypedResults, and filters. Beginner-friendly with diagrams.