Choosing Between Controllers and Minimal API for .NET APIs

Michael Jolley - Dec 20 '23 - - Dev Community

This post has been included in the 2023 edition of C# Advent. Be sure to follow @CsAdvent on Twitter (X) and watch the #csadvent hashtag for more C# goodness.

ASP.NET was first released in January 2002 and .NET developers everywhere started learning WebForms. It felt like an easy step to the web for WinForm developers, but it abstracted much of how the internet works. As a result, .NET developers didn't think about things like HTTP verbs or payload size.

Many of those developers, myself included, got a little "closer to the metal" when Microsoft officially released ASP.NET MVC in early 2009. My mind really enjoyed the MVC pattern for building web applications & APIs. Though there have been several improvements to the framework since, developers who have left the .NET world will still feel familiar with the conventions of the latest iteration.

That said, the past several years have seen an explosion of development shifting to the web and APIs are popping up everywhere. While .NET developers were previously limited to MVC, the introduction of Minimal API and other non-MS .NET API frameworks is providing them with a plethora of options.

Let's review a few of those options to help you choose the best option for your API needs.

A quick note about the code snippets below: These snippets do not contain all the code needed to run the APIs. I've purposely not shown code related to Entity Framework DbContexts or POCO classes. They do include the code that ASP.NET uses to build the /todoitems routes for retrieving all and one ToDoItem.

Controllers

Controllers have been the "bread and butter" of .NET API building for a long time. Their structure is familiar to all .NET developers; even those that are just now working on web-based projects.

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace TodoApi.Controllers;

[Route("api/[controller]")]
[ApiController]
public class TodoItemsController : ControllerBase
{
    private readonly TodoContext _context;

    public TodoItemsController(TodoContext context)
    {
        _context = context;
    }

    // GET: api/TodoItems
    [HttpGet]
    public async Task<ActionResult<IEnumerable<TodoItemDTO>>> GetTodoItems()
    {
        return await _context.TodoItems
            .Select(x => ItemToDTO(x))
            .ToListAsync();
    }

    // GET: api/TodoItems/5
    [HttpGet("{id}")]
    public async Task<ActionResult<TodoItemDTO>> GetTodoItem(long id)
    {
        var todoItem = await _context.TodoItems.FindAsync(id);

        if (todoItem == null)
        {
            return NotFound();
        }

        return ItemToDTO(todoItem);
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding attributes to properties, methods, and classes makes it relatively painless to add support for routing, authentication/authorization, or building Swagger and OpenAPI documentation.

If you're building an API that requires Swagger or OpenAI documentation, one huge benefit is support for reading code comments into that documentation. This makes it super easy for documentation to become part of the product itself.

Of course, your scenario is rarely as basic as the example above and unfortunately, most Controller-based API examples use the pattern of injecting an Entity Framework context and manipulating it within the endpoint method. For more complex applications, you'll probably be injecting your own services with business logic customized to your needs.

One "con" of the approach of Controller-based APIs, is that dependency injection occurs at the controller level. While this does mean your service, DbContext, etc. are available to all methods within the controller, it also means that the application may be spinning up resources that your particular web request may not need. However, that overhead is usually one of the last places you need to start optimizing.

An additional benefit of Controller-based APIs is the built-in support for generating Swagger and OpenAPI documentation based on your code comments and class attributes.

Minimal API

Minimal API is a newer approach to building APIs with ASP.NET Core and its fluent syntax is very appealing to developers coming from the JavaScript and Python worlds. In fact, after spending the past few years building applications with TypeScript, I found Minimal API a much simpler path to onboard to .NET APIs.

Here is the same two API endpoints shown above, but written using Minimal API:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

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

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

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

app.Run();
Enter fullscreen mode Exit fullscreen mode

I'm sure you immediately notice the conciseness of Minimal API. One benefit to using Minimal API is the granularity of control over the construction of your endpoints. While both of the endpoints have a ToDoDb DBContext injected, you can probably imagine a world where different services are provided to different endpoints.

As for documentation, Minimal API does support Swagger and OpenAPI documentation generation, but the process for documenting endpoints is more invasive than the Controller-based method. For instance, to modify the / route above to include a summary and description of the endpoint, you'd need to use the WithOpenApi fluent method as shown below.

todoItems.MapGet("/", async (TodoDb db) =>
        await db.Todos.ToListAsync())
    .WithOpenApi(operation => new(operation)
    {
        Summary = "This is a summary",
        Description = "This is a description"
    });
Enter fullscreen mode Exit fullscreen mode

Also, if you're not returning TypedResults, you'll need to document the response types of your endpoint. Here's an example:

todoItems.MapGet("/", async (TodoDb db) =>
        await db.Todos.ToListAsync())
    .Produces<IList<Todo>>();
Enter fullscreen mode Exit fullscreen mode

FastEndpoints

Bonus Time! In addition to the Microsoft-supported methods above, many community frameworks exist for building APIs with .NET.
FastEndpoints is an option I found recently that seems very promising. With performance benchmarks that put them on par with Minimal API, they are firmly ahead of Controller-based APIs.

Also like Minimal API, FastEndpoints uses a fluent-based approach to configuration. However, one major difference is found in how endpoints are created. While the Minimal API framework expects many endpoints to exist within a class and be organized within a MapGroup, the FastEndpoints convention expects each endpoint to live within its own class.

Those Endpoint classes also define the request and response signatures via DTO classes. Using FastEndpoints, our two endpoints would look like the below example:

public class AllToDoEndpoint : EndpointWithoutRequest<IEnumerable<TodoItemDTO>>
{
    public TodoContext _context;

    public override void Configure()
    {
        Get("/api/todoitems");
        AllowAnonymous();
    }

    public override async Task HandleAsync(MyRequest req, CancellationToken ct)
    {
        var todoItems = await _context.Todos.ToListAsync();
        await SendAsync(todoItems);
    }
}
Enter fullscreen mode Exit fullscreen mode
public class GetToDoEndpoint : Endpoint<IdRequest, 
                                        Results<Ok<TodoItemDTO>, 
                                                NotFound>>
{
    public TodoContext _context;

    public override void Configure()
    {
        Get("/api/todoitems/{Id}");
        AllowAnonymous();
    }

    public override async Task<Results<Ok<TodoItemDTO>, NotFound>> ExecuteAsync(
        IdRequest req, CancellationToken ct)
    {
        var todoItem = await _context.Todos.FindAsync(req.Id)

        if (todoItem is null)
        {
            return TypedResults.NotFound();
        }

        return TypedResults.Ok(todoItem);
    }
}

public class IdRequest
{
    public int Id { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

For Swagger & OpenAPI documentation, you'll use fluent methods within the endpoints Configure method.

public override void Configure()
{
    Get("/api/todoitems/{Id}");
    AllowAnonymous();
    Description(b => b
        .Produces<TodoItemDTO>(200, "application/json+custom")
        .ProducesProblemDetails(404, "application/json+problem"); //if using RFC errors 
}
Enter fullscreen mode Exit fullscreen mode

One big bonus that FastEndpoints provides is a large collection of supported features, including model binding, rate limiting, caching, pre/post processors, and more.

Which is Right for You?

If you have a background in JavaScript, Python, or Functional programming, Minimal API will feel more natural to you. But if you've spent a lot of time using .NET for the web or WinForms, you'll likely find Controllers more accessible.

Another point of consideration is documentation. While it's possible to document your API via Swagger or OpenAPI with all three, unless you enjoy documenting your endpoints using fluent methods, you'll likely find writing Controller-based APIs less cumbersome to manage.

In the end, all are valid and welcome additions to the .NET ecosystem. You truly can't go wrong with any of them and I'd recommend building with all three to find what works best with your existing application patterns and processes.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .