Why Most Developers Get Minimal API Structure Wrong (And How to Fix It)

Why Most Developers Get Minimal API Structure Wrong (And How to Fix It)

Minimal APIs in ASP.NET Core are fast, lightweight, and easy to set up. But there’s a problem…

Most developers end up stuffing all their endpoints into Program.cs, turning it into a tangled mess that’s impossible to scale or maintain.

Sound familiar?

It works… until it doesn’t.

In this post, I’ll show you exactly why this happens, and how to organize your endpoints cleanly, without giving up the flexibility that Minimal APIs provide.

Why Minimal APIs Are Tricky to Get Right

Minimal APIs are designed to simplify web API development by reducing boilerplate code and enabling faster prototyping. At first glance, their concise syntax and streamlined setup seem like a perfect fit for small projects or microservices. However, this simplicity often leads developers into structural pitfalls that can become costly as projects grow.

The core challenge with Minimal APIs lies in balancing minimalism with maintainability and scalability. Without proper boundaries, it’s easy to let business logic, data access, and request handling all mix into a single file, typically Program.cs. This results in tightly coupled code that is difficult to test, maintain, or extend.

C#
				var app = builder.Build();

app.MapGet("/tasks", async (ITaskService service, CancellationToken ct) =>
{
    //...
});

app.MapGet("/tasks/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) =>
{
    //...
});

app.MapPost("/tasks", async (CreateTaskRequest request, ITaskService service, CancellationToken ct) =>
{
    //...
});

app.MapPut("/tasks/{id:guid}/complete", async (Guid id, ITaskService service, CancellationToken ct) =>
{
    //...
});

app.MapDelete("/tasks/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) =>
{
    //...
});

app.Run();
			

Minimal APIs put more responsibility on the developer to organize code effectively. Because they encourage inline endpoint definitions and lambda handlers, developers can unintentionally create “God files” packed with logic. This defeats the purpose of a clean, modular codebase.

In short: Developers get Minimal APIs wrong not because of the APIs themselves, but because they confuse minimal setup with minimal design and responsability.

How to Fix it

So what are the alternatives?

Let’s start small and simple. we can extract our endpoints into dedicated extension methods. This immediately improves readability without sacrificing the minimal API syntax you know and love.

Here’s a simple example using a static class:

C#
				public static class TasksEndpoints
{
    public static void AddTasksEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/tasks", async (ITaskService service, CancellationToken ct) =>
        {
            //...
        });

        app.MapGet("/tasks/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) =>
        {
            //...
        });

        app.MapPost("/tasks", async (CreateTaskRequest request, ITaskService service, CancellationToken ct) =>
        {
            //...
        });

        app.MapPut("/tasks/{id:guid}/complete", async (Guid id, ITaskService service, CancellationToken ct) =>
        {
            //...
        });

        app.MapDelete("/tasks/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) =>
        {
            //...
        });
    }
}
			

Now, instead of cluttering Program.cs, you register everything cleanly:

C#
				app.AddTasksEndpoints();
			

This modular approach lets you organize endpoints by feature or module, making your API routes easier to find and maintain. Want to change something in the Tasks feature? You’ll know exactly where to go.

However, as your app scales, you’ll likely end up with dozens of static extension methods. Manually managing and registering all of them can quickly become a pain.

 

Let’s Abstract the Boilerplate

To avoid repeating yourself, we can define a common interface for all endpoint registrations:

C#
				public interface IEndpoints
{
    void MapEndpoints(IEndpointRouteBuilder app);
}

			

Then implement it in each feature/module/Endpoint class:

C#
				public class TasksEndpoints : IEndpoints
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        app.MapGet("/tasks", async (ITaskService service, CancellationToken ct) =>
        {
            //...
        });

        app.MapGet("/tasks/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) =>
        {
            //...
        });

        app.MapPost("/tasks", async (CreateTaskRequest request, ITaskService servic, CancellationToken ct) =>
        {
            //...
        });

        app.MapPut("/tasks/{id:guid}/complete", async (Guid id, ITaskService service, CancellationToken ct) =>
        {
            //...
        });

        app.MapDelete("/tasks/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) =>
        {
            //...
        });
    }
}
			

Now here comes the magic: using reflection and DI we can scan and register all endpoint implementations automatically:

C#
				public static class EndpointExtensions
{
    public static IServiceCollection AddEndPoints(this IServiceCollection services, Assembly assembly)
    {
        ServiceDescriptor[] endpointServiceDescriptors = assembly
            .DefinedTypes
            .Where(t => t is { IsAbstract: false, IsInterface: false } &&
            t.IsAssignableFrom(typeof(IEndpoint), t))
            .ToArray();

        services.TryAddEnumerable(endpointServiceDescriptors);

        return services;
    }

    public static IApplicationBuilder MapEndpoints(
        this WebApplication app,
        RouteGroupBuilder? routeGroupBuilder = null)
    {
        IEnumerable<IEndpoint> endpoints = app.Services
            .GetRequiredService<IEnumerable<IEndpoint>>();

        IEndpointRouteBuilder builder =
            routeGroupBuilder is null ? app : routeGroupBuilder;

        foreach (IEndpoint endpoint in endpoints)
        {
            endpoint.MapEndpoints(builder);
        }

        return app;
    }
}
			

And in your Program.cs, a single line keeps everything clean:

C#
				builder.Services.AddEndpoints(Assembly.GetExecutingAssembly());

// ...

app.MapEndpoints();

			

You’ve now turned Minimal APIs into a better modular and scalable system without abandoning the elegance that makes them appealing in the first place. This structure grows with your application and makes onboarding, testing, and navigating your codebase far easier.

If you don’t need advanced features, the previous solution might be all you need.
But if you’re looking for more flexibility, things like route metadata, automatic tagging, or per-endpoint policies, Carter is a powerful next step, here you can also use FastEndpoints or other approaches, I will introduce them in future blogs.

To get started, you need to install the Carter package:

PowerShell
				dotnet add package Carter
			

Carter provides the ICarterModule interface, or you can inherit directly from CarterModule, which gives you a more fluent way to define routes and attach metadata.

C#
				public class TasksEndpoints : CarterModule
{
    public TasksEndpoints() : base("/tasks")
    {
        this.WithTags("Tasks");
        this.WithSummary("Handles all task-related operations");
        this.RequireRateLimiting("task-policy");
        this.RequireAuthorization("admin");
    }

    public override void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/", async (ITaskService service, CancellationToken ct) => { /* ... */ });

        app.MapGet("/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) => { /* ... */ });

        app.MapPost("/", async (CreateTaskRequest request, ITaskService service, CancellationToken ct) => { /* ... */ });

        app.MapPut("/{id:guid}/complete", async (Guid id, ITaskService service, CancellationToken ct) => { /* ... */ });

        app.MapDelete("/{id:guid}", async (Guid id, ITaskService service, CancellationToken ct) => { /* ... */ });
    }
}

			

Notice the constructor: you define the base route (/tasks) and apply configurations like tags, summaries, rate limiting, and authorization etc.

To enable Carter in your app, register its services and map the routes:

C#
				builder.Services.AddCarter(); //Register all the services the character needs

var app = builder.Build();

app.MapCarter(); // Map endpoints

app.Run();
			

Carter and endpoint methods also works seamlessly with a vertical slice-like approach. Instead of grouping by modules, you can isolate each endpoint into its own class, giving you fine-grained control over each route and its dependencies.

C#
				public class Create : IEndpoint // Or : CarterModule
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        app.MapPost("/tasks", async (CreateTaskRequest request, ISender sender, CancellationToken ct) => { /* ... */ });
    }
}

public class GetById : IEndpoint // Or : CarterModule
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        app.MapGet("/tasks/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => { /* ... */ });
    }
}

			

You can register these classes through DI or Assembly and scan them during startup, exactly like we did before with EndpointExtensions

I use all these approaches in my minimal APIs…

If it’s just a POC, test or something simple, I use the first approach with extension methods.

If there are several endpoints but I don’t need anything advanced I go with the approach of registering everything with a single EndpointExtensions.

And for more advanced cases I go with carter.

Conclusion

Minimal APIs open the door to fast, clean, and highly focused endpoints, but structure still matters. As we’ve explored, moving logic out of Program.cs, grouping endpoints by feature, and using abstractions like IEndpoint or libraries like Carter brings the best of both worlds: simplicity and maintainability.

Whether you’re organizing endpoints into modules or going one step further with Carter’s rich feature set, the key is to scale your structure as your app grows.

So the next time you reach for a Minimal API, think beyond the hello world, and build something that’s ready for real-world complexity.

Are you already using these patterns? Or still relying on the old Program.cs jungle?

Thank you for reading.

See you next time!

Share the Post:
plugins premium WordPress