4 minute read

I often hear (on social media or direct comments) people saying you can’t use minimal APIs for “real production” apps.

I think the reason for this, is that most samples that we see are very simple, and don’t show how to organize the code in a way that makes sense for a larger application.

Although truth be told, many “real production” apps are not that complicated either, but, that’s another story.

I work with a lot of large organizations, helping them move their workloads to Azure, and over the past two years or so, since minimal APIs were introduced in ASP.NET Core, I’ve been using them for all our projects, and I’ve been very happy with them. Before then, I have also used similar minimal apis in python (FastAPI and Flask).

The issue though is that in ASP.NET, the samples have all ended with some endpoints in program.cs, with the code inline (like below), and that’s not really how you want to organize your code in a larger application, so I wanted to do a quick writeup of a few easy steps to make the code more maintainable.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

...

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

app.Run();

I’m using an example from Microsoft Learn Tutorial: Create a minimal API with ASP.NET Core, and a few of the tips below come from that post, but I have added some variations that I have found useful in the projects I have been working on.

Use extension methods to organize the endpoints

A simple solution to organize the endpoints is through using extension methods.

In a folder called Endpoints or in a TodoItems folder if you do clean architecture, we can create a file called TodoItemsEndpoints.cs and add the following code:

public static class TodoItemsEndpoints
{
    public static void RegisterTodoItemsEndpoints(this WebApplication app)
    {
        app.MapGet("/todoitems", async (TodoDb db) =>
            await db.Todos.ToListAsync());

        app.MapGet("/todoitems/complete", async (TodoDb db) =>
            await db.Todos.Where(t => t.IsComplete).ToListAsync());

        app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
            await db.Todos.FindAsync(id)
                is Todo todo
                    ? Results.Ok(todo)
                    : Results.NotFound());
        ...
    }
}

This instantly makes our Program.cs file much cleaner, and we can separate out all our endpoints into separate files, and organize them in a way that makes sense for our application.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

...

app.RegisterTodoItemsEndpoints();
app.Run();

I should mention, there are nuget packages (like Carter) that will allow you to create endpoint registrations without calling them from program.cs but I have personally never found a need for this.

Use TypedResults instead of Results

Using TypedResults instead of Results, we can make our code even cleaner.

If you use Results, you would typically want to add a Produces attribute, to specify for swagger etc. what the valid response types are.

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound())
    .Produces<Todo>(StatusCodes.Status200OK)
    .Produces(StatusCodes.Status404NotFound);

with TypedResults, we can skip the .Produces() attribute, as it will be inferred from the return type. This means that we will have less clutter, we can check at compile time if we have made mistakes, and our unit tests will be simpler as well.

app.MapGet("/todoitems/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound());

Separate out the functionality from the endpoint registrations

Even though we have separated this out into a separate file, it still looks very messy, and it’s not very testable.

We can fix both of these issues by moving our lambdas to separate methods.

app.MapGet("/todoitems/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound());

becomes

app.MapGet("/todoitems/{id}", GetTodoById);

static async Task<Results<Ok<Todo>, NotFound>> GetTodoById(int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();

So now the registration part of TodoItemsEndpoints looks like this:

app.MapGet("/todoitems", GetAllTodos);
app.MapGet("/todoitems/complete", GetCompleteTodos);
app.MapGet("/todoitems/{id}", GetTodoById);
app.MapPost("/todoitems/", CreateTodo);
app.MapPut("/todoitems/{id}", UpdateTodoById);
app.MapDelete("/todoitems/{id}", DeleteTodo);

followed by the methods that we just moved out of the registration

And now if we want to test any of our methods, we can just call them directly, without having to go through the API.

Grouping endpoints

We can further clean this up, by grouping the endpoints so that we can avoid repetition.

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodoById);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodoById);
todoItems.MapDelete("/{id}", DeleteTodo);

This saves us from writing todoitems 6 times, but it also allows us to apply other attributes to the whole group if we want to.

We can require authorization, or add tags or CORS or rate limiting to all the endpoints in the group in one go

var todoItems = app.MapGroup("/todoitems")
    .RequireAuthorization()
    .WithTags("Todo Items");

You can of course also add the authorization and tags to the individual endpoints if you want to.

Summary

Over the past number of years, when I have split my time between python and .NET, I have come to appreciate simplicity and minimalism more and more in my code. The less boiler plate and less clutter the better, so minimal APIs have been a really great fit for me, and so far, I have not found any limitations that have been a problem for me independent of project size. Whether you agree or disagree or have other tips or experiences, I would love to hear from you on twitter (X) @tessferrandez.

Tags:

Categories:

Updated: