Skip to main content
SEMastery
ASP.NETintermediate

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.

13 min readUpdated September 19, 2025

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 three moving parts of automatic endpoint registration

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

Build
Scan
Register
Map
Serve

Steps

1

Build

App host starts up

2

Scan

Reflection finds IEndpoint classes

3

Register

Each added to DI container

4

Map

MapEndpoint called on each

5

Serve

Routes answer requests

What the framework does once you call AddEndpoints and MapEndpoints

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.

Startup sequence when the application boots

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.

Two paths to the same registration result

Here is how they compare on the things that matter day to day.

ConcernReflectionSource generator
Setup effortVery lowMore work upfront
Startup speedTiny one-time scanFastest, no scan
Native AOT supportTrickyExcellent
Trimming friendlyNeeds careYes
DebuggabilityNormalYou can read generated code
Best forMost appsAOT, 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.

SituationManual MapGet/MapPostAuto-registered IEndpoint
Three or four endpointsSimplest, do thisOverkill
Dozens of endpointsHard to manageClean and scalable
Many developersMerge conflicts in Program.csEach endpoint in its own file
Feature foldersAwkwardNatural fit
Need fastest cold startFineUse 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

Count
Team
AOT
Pick

Steps

1

Count

Few routes? Stay manual

2

Team

Many devs? Auto-register

3

AOT

Native AOT? Source generator

4

Pick

Choose and keep it consistent

A simple decision path for endpoint registration

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 MapGet and MapPost in Program.cs does not scale. The file grows, and a team trips over it.
  • The fix is a tiny IEndpoint interface with one MapEndpoint(IEndpointRouteBuilder app) method.
  • Each endpoint lives in its own small class and implements that interface — one class, one endpoint.
  • AddEndpoints scans an assembly and registers every endpoint with dependency injection.
  • MapEndpoints asks for them all and calls MapEndpoint on each, so routes register themselves.
  • Program.cs shrinks 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 public and light, and give each route a unique path.
  • Be careful with MediatR and MassTransit — their newer versions are now commercially licensed. The plain IEndpoint pattern needs no extra package.

References and further reading

Related Posts