Moving from Controllers to Minimal API

Christian Brevik
Variant
Published in
9 min readMay 3, 2024

--

A developer sitting in the back of a room, intensly focused on a screen.
A developer intensely focused, trying to understand Minimal API — photo: Mikael Brevik

If you’re used to MVC Controllers, and want to know how to use the new Minimal API in ASP.NET Core in a way that doesn’t feel painful, this is the post for you!

When you look at examples of Minimal API, you’ll see a lot of code that looks like this:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

This is of course very different from how you would build APIs with traditional MVC Controllers. And at the same time, examples like these seem very naive and simplistic. Should you put all your routes (app.MapGet, app.MapPost, etc) in Program.cs, and handle the endpoint logic inline with lambda functions (like with () => "Hello World!") there as well?

You’re probably thinking, that’s going to end up being a mess. And you’d be right! This does not translate well to APIs with many endpoints and more logic.

Coming from Controllers, you’re used to having a controller-class for each route prefix, and having an Action-method for each endpoint under that prefix. You're used to having a clear separation of concerns, and you're used to having a way to organize your code files in a way that makes sense.

So how do you do that with Minimal API?

For the impatient, here’s the code

If you’re the type of person who likes to read code rather than posts, I’ve created an example project that demonstrates the pattern I’m suggesting. You can find it on GitHub at https://github.com/varianter/dotnet-template. The solution itself has a lot more moving parts than what I’ll show here, but the pattern I’ll describe is used in the Api-project.

The most interesting files pertaining to this post are under src/Api/Routes. Where RouteGroupBuilderExtensions has encapsulation of common behaviours for endpoints, and the Weather-folder shows the file structure for endpoint-groups, the endpoints themselves, and the request/response models used.

I would still recommend reading the rest of this post to understand my choices. 😅

Translating Controllers to Minimal API

The main difference between Minimal API and Controllers is that you don’t have a controller class as a container for your endpoints with Minimal API. For Controllers you would typically have a class like this:

[ApiController]
[Route("[controller]")]
public class HelloController : ControllerBase
{
private readonly IHelloService _helloService;

public HelloController(IHelloService helloService)
{
_helloService = helloService;
}

public IActionResult Get()
{
return Ok(_helloService.FetchHello());
}
}

A thing to think about with Controllers is there are many different conventions for defining routes, some of them more magical than others.

In the above example, the reason why the Get-method responds when you call GET /hello, is because it is located in a class named HelloController, where by convention the Controller-part of the name is removed and then resolves as the URL-prefix /hello. And then, also by naming convention, the Get()-method will respond to the hello route if you use the HTTP-verb GET because the method is named Get.

There are of course ways of doing this more explicit, by for example adding attributes like [Route] for routing and [HttpGet] or similar for the verbs. But the point is that much of the routing that happens with Controllers are by default implicit. And this can get very difficult to keep track of when you have many endpoints.

The terseness is what many people talk about when they say they prefer Minimal API, but the main reason for me is how explicit it forces you to be about your routes and verbs. The same code in Minimal API would look like this:

// Program.cs
app.MapGet("/hello", (IHelloService helloService) => helloService.FetchHello());

But what if you don’t want to inline all your logic like this? One strength of Controllers is that you have a clear separation of concerns and it forces you to structure your code across multiple files.

Well one way to solve this with Minimal API is to move the routing to extension methods. You can create extension methods for WebApplication that encapsulate the logic for each endpoint, and in this way keep your Program.cs clean and organized:

public static class HelloEndpoints
{
public static void MapHelloEndpoints(this WebApplication app)
{
app.MapGet("/hello", (IHelloService helloService) => helloService.FetchHello());
}
}
// Program.cs
app.MapHelloEndpoints();

But that just moves the problem to another file. This doesn’t scale well with multiple endpoints and longer handler methods.

If you do a small tweak to the code above though, it starts to look more like what you’re used to with Controllers and an Action-method per endpoint:

public static class HelloEndpoints
{
public static void MapHelloEndpoints(this WebApplication app)
{
app.MapGet("/hello", GetHello);
}

private static GetHelloResponse GetHello(IHelloService helloService)
{
return helloService.FetchHello();
}
}

Here you can squint a bit and GetHello looks like an Action, and HelloEndpoints looks like a Controller-class. The main difference being that the service is injected into the method, rather than the constructor. Constructor dependency injection is not supported in Minimal API.

Phew! That was easy, we’re done now right?

Not quite. The code above is deceptively simple. With Controllers you add attributes to your classes and methods to define things like Authorization, OpenAPI documentation, etc.

In Minimal API, you must instead chain method calls while building the routes to add these features. For example, to add authorization and OpenAPI documentation to the GetHello endpoint, you would do something like this:

public static class HelloEndpoints
{
public static void MapHelloEndpoints(this WebApplication app)
{
app.MapGet("/hello", GetHello)
.RequireAuthorization()
.WithOpenApi()
.WithName("GetHello")
.WithSummary("Get personalized hello message");

app.MapPost("/hello", PostHello)
.RequireAuthorization()
.WithOpenApi()
.WithName("PostHello")
.WithSummary("Post a personalized hello message");
}

private static GetHelloResponse GetHello(IHelloService helloService)
{
return helloService.FetchHello();
}

private static string PostHello(
IHelloService helloService,
[FromBody] PostHelloRequest request)
{
return helloService.SendHello(request.Name);
}
}

You see though, that if you then add more endpoints in MapHelloEndpoints, the building of routes will start to get long and unwieldy. And the handler methods for the endpoints will be pushed further down making the file longer and harder to navigate.

Another issue here as well, is that as each handler method grows in complexity and length, co-locating them all in one file can also make it harder to see the big picture of what the API does. I find this is also an issue with Controller-classes that contains many methods.

Instead what I suggest is to create a separate class for each endpoint, but co-locate the route building in the same file, HelloEndpoints in this example. That way you quickly see which routes exists and their configuration, while still having a clear picture of what each endpoint does.

The above then becomes three distinct files:

public static class HelloEndpoints
{
public static void MapHelloEndpoints(this WebApplication app)
{
app.MapGet("/hello", GetHello.Handle)
.RequireAuthorization()
.WithName("GetHello")
.WithSummary("Get personalized hello message");

app.MapPost("/hello", PostHello.Handle)
.RequireAuthorization()
.WithName("PostHello")
.WithSummary("Post a personalized hello message");
}
}
public class GetHello
{
public static GetHelloResponse Handle(IHelloService helloService)
{
return helloService.FetchHello();
}
}
public class PostHello
{
public static string Handle(
IHelloService helloService,
[FromBody] PostHelloRequest request)
{
return helloService.SendHello(request.Name);
}
}

As you see the method has been renamed to Handle to make it clear that it's the handler for the endpoint, and the containing class has been named after the endpoint. Each route that is added refers to the Handle-method in the corresponding endpoint-class.

Now we’re getting somewhere! You have a much clearer separation of concerns. In HelloEndpoints, you can focus on defining the structure and routing of your API. This is where you can define aspects like OpenAPI, authorization, and other higher level concerns.

In turn, by encapsulating each handler into its own class like GetHello and PostHello, will allow you to remain focused on implementing the specific business logic they are designed for, without the overarching concerns of routing and structure.

It might be tempting to co-locate the route and its handler in the same class, but I would argue against it.

Still too much boilerplate!

Another benefit of Controller-classes is that you can add attributes to the class which applies to all actions in the class. The same can be achieved with Minimal API by using a RouteGroupBuilder, and the pattern we've followed so far makes that easier.

Say we want to add authorization to all routes in the HelloEndpoints. And like with [Route] in Controllers, we want to add a common prefix to all the routes. By using app.MapGroup to create a route group, and adding an authorization requirement to the group, all routes added to that group will inherit that behaviour:

public static class HelloEndpointsGroup
{
public static void MapHelloEndpoints(this WebApplication app)
{
var group = app.MapGroup("hello")
.WithOpenApi()
.RequireAuthorization();

group.MapGet("/", GetHello.Handle)
.WithName("GetHello")
.WithSummary("Get personalized hello message");

group.MapPost("/", PostHello.Handle)
.WithName("PostHello")
.WithSummary("Post a personalized hello message");
}
}

As you might have noticed, I’ve renamed the class to HelloEndpointsGroup. And all routes belonging to the constructed group will require authorization, and all routes will have the prefix /hello.

You can take this further by creating a RouteGroupBuilder extension method that encapsulates the common behavior you want to apply to all route-groups of a certain type.

One way this can be demonstrated is that RequireAuthorization can take in an authorization policy name. Let’s say we’ve defined an authorization policy named admin:

// Program.cs
builder.Services.AddAuthorization(options =>
options.AddPolicy("admin", policy =>
policy.RequireClaim(JwtClaimTypes.Role, "admin")
)
);

You are able to say that a group (or endpoint) requires the admin policy like this:

var group = app.MapGroup("hello")
.RequireAuthorization("admin");

If you have many groups which you want to apply the same authorization policy to, it’s a good idea to create an extension method for that. You can at the same time add other common behavior you want to apply to all groups. Here’s an example of such an extension method for creating admin-groups:

public static class RouteGroupBuilderExtensions
{
public static RouteGroupBuilder MapAdminGroup(
this IEndpointRouteBuilder builder,
[StringSyntax("Route")] string prefix,
string groupName)
{
return builder.MapGroup($"admin/{prefix}")
.WithOpenApi()
.WithTags(groupName) // Add tag which groups these endpoints in Swagger UI
.RequireAuthorization("admin");
}
}

All groups which are built with MapAdminGroup will require the admin policy, and have the prefix /admin.

Another minor change we must do now is that MapHelloEndpoints in HelloEndpointsGroup must be changed to be an extension method for RouteGroupBuilder instead of WebApplication. I also recommend returning the group from the method, so that you can chain multiple groups together in Program.cs:

public static class HelloEndpointsGroup
{
public static RouteGroupBuilder MapHelloEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", GetHello.Handle)
.WithName("GetHello")
.WithSummary("Get personalized hello message");

group.MapPost("/", PostHello.Handle)
.WithName("PostHello")
.WithSummary("Post a personalized hello message");

return group;
}
}
// Program.cs
app.MapAdminGroup("hello", "Hello")
.MapHelloEndpoints()
.MapHelloGoodbyeEndpoints();

Now /hello is instead available under /admin/hello since we’ve decided in the MapAdminGroup-method that all admin-endpoints should belong under /admin. It also enforces that any calls to that endpoint will require the admin-role because of the policy.

I’ve also added another example called HelloGoodbye in the code above which will inherit the same behaviour as the Hello-group because they are chained together. If you don’t want or need to chain groups of endpoints like this together, you could call MapAdminGroup explicitly inside MapHelloEndpoints to further encapsulate the group behaviour.

A note on file structure

How you organize your files is of course up to you, but I prefer a structure like this:

src/
Api/
Routes/
Hello/
Models/
GetHelloResponse.cs
PostHelloRequest.cs
Endpoints/
GetHello.cs
PostHello.cs
HelloEndpointsGroup.cs
HelloGoodbye/
Endpoints/
GetHelloGoodbye.cs
HelloGoodbyeEndpointsGroup.cs
RouteGroupBuilderExtensions.cs
Program.cs

If you have nested routes, for example GET /hello/{id}/goodbye, I would recommend creating a new group and folder for each level of nesting. Like with HelloGoodbye here, where /goodbye is nested under /hello in terms of route, but has its own group and folder at the same level as Hello in terms of file structure. This is because I find it makes it quicker to see and navigate to each group of routes in my editor if they live at the same level in the file tree.

Some might prefer to keep all endpoints and nested groups in the same folder. Or, almost like a file-based router, put nested groups in their own folder under the parent groups folder. That’s fine too!

Conclusion

There are libraries which can be used as alternatives to MVC Controllers which are similar and will help encapsulate common behaviours and patterns like what I’m trying to solve here. Some examples are https://fast-endpoints.com/, https://github.com/ardalis/ApiEndpoints and https://github.com/CarterCommunity/Carter.

These are all great and I’d recommend checking them out. The reason why I choose not to use them myself is because I think Minimal API with some simple extension methods, explicit routing and sensible structure is powerful enough in itself.

By following the pattern described in this blog post, I’ve been able to scale Minimal APIs from prototyping to large production APIs. I'm not saying this is the "right way", but this has worked well for me.

I hope this post has been of help, and that you can now start building your Minimal APIs in a way that makes sense to you! 😊

--

--