DEV Community

Cover image for Choosing Between Controllers and Minimal API for .NET APIs
Michael Jolley
Michael Jolley

Posted on • Originally published at baldbeardedbuilder.com

Choosing Between Controllers and Minimal API for .NET APIs

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.

Top comments (9)

Collapse
 
yogini16 profile image
yogini16 • Edited

Thank you for sharing !!

Collapse
 
blueskyson profile image
Jack Lin

Your explanation is good. I couldn't understand the design logic of minimal API by reading official docs. I finally got it when you said the style is friendly for Node and Python developers.

Collapse
 
michaeljolley profile image
Michael Jolley

Yeah, it's more of a functional programming pattern. It can feel weird for C# developers, but once you've used it, I think most would enjoy it.

Collapse
 
console_x profile image
F. Güngör

how to handle dependency injections ?
The best is the easy way

Collapse
 
michaeljolley profile image
Michael Jolley

Be sure to look at the code examples. I show examples of dependency injection for Controllers (in the constructor), Minimal API (to the endpoint itself), and FastEndpoints (there's a couple ways, but by default, public properties are injected.)

Of course, I'm not showing how to register the services/contexts/etc., but that isn't the point of this post.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

One other thing to have in mind is unit testing. Controller-based projects are probably more unit-test friendly. For example, I can write a unit test that makes sure all controllers can be instantiated easily, making sure all dependencies are registered. I probably cannot do the same for minimal API.

Collapse
 
michaeljolley profile image
Michael Jolley

Don't completely agree. It's two different thought paradigms. Testing that your controller properly sets up all its dependency injected stuff is something that just doesn't exist in Minimal API.

Since you're injecting those dependencies directly into the route method, no setup is needed, and therefore, there's nothing to test. Just send your fakeService into the method rather than into the Controller's constructor.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Ah ok, it might be my ignorance then. I assumed they would still use IoC in a way that I could not mock, but if it is method injection, I guess it is OK.

Thread Thread
 
michaeljolley profile image
Michael Jolley

Yeah. Totally.

Instead of a controller-based injection:

[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(GetTestSessions());
    var controller = new HomeController(mockRepo.Object);

    // Act
    var result = await controller.Index();

    // Assert
    var viewResult = Assert.IsType<ViewResult>(result);
    var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
        viewResult.ViewData.Model);
    Assert.Equal(2, model.Count());
}
Enter fullscreen mode Exit fullscreen mode

You just pass your service or context directly to the endpoint itself:

[Fact]
public async Task GetTodoReturnsTodoFromDatabase()
{
    // Arrange
    await using var context = new MockDb().CreateDbContext();

    context.Todos.Add(new Todo
    {
        Id = 1,
        Title = "Test title",
        Description = "Test description",
        IsDone = false
    });

    await context.SaveChangesAsync();

    // Act
    var result = await TodoEndpointsV1.GetTodo(1, context);

    //Assert
    Assert.IsType<Results<Ok<Todo>, NotFound>>(result);

    var okResult = (Ok<Todo>)result.Result;

    Assert.NotNull(okResult.Value);
    Assert.Equal(1, okResult.Value.Id);
}
Enter fullscreen mode Exit fullscreen mode