Exploring ASP.NET Core Minimal APIs: Should I use them in production?

If you’ve been keeping up to date with what’s new in the .NET ecosystem over the last year or two, you’ll probably already be aware of the Minimal APIs feature that debuted in ASP.NET 6.0 back in 2021.

However, since Minimal APIs are still a relatively new concept, perhaps you haven’t considered using them in production yet and you’re curious to know more about their potential suitability for enterprise applications.

Since the original launch of Minimal APIs, a lot of their initial limitations have been resolved through the introduction of new features in ASP.NET Core 7.0, with more features on the way as part of the imminent ASP.NET Core 8.0 release this November (2023). Therefore, it’s an ideal time to take a look at Minimal APIs to see if they could be a good fit for one of your projects.

In this article, I will introduce you to Minimal APIs, considering why they were developed and the potential benefits of using them. Along the way, I’ll provide code examples that demonstrate how to get started and look at how you can organise your endpoints to improve the maintainability and testability of your code. Let’s explore!

The why

Let’s start with why. Why were Minimal APIs created?

Minimal APIs are Microsoft’s attempt to provide a simplified, more streamlined approach for developing JSON-based APIs using ASP.NET Core. They aim to cut down on boilerplate code while still offering an impressive set of features.

For newcomers to .NET, the code that is generated for a new ASP.NET Core web application using the Web API project template can seem a little daunting. There is a lot of boilerplate; using statements, namespaces, attributes, classes, base classes, constructors, and lots of curly braces!

For example, consider the contents of the ‘Program.cs’ file for a new ASP.NET Core Web API project shown below.

namespace WebApiDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);
 
            // Add services to the container.
 
            builder.Services.AddControllers();
 
            var app = builder.Build();
 
            // Configure the HTTP request pipeline.
 
            app.UseHttpsRedirection();
 
            app.UseAuthorization();
 
            app.MapControllers();
 
            app.Run();
        }
    }
}

And here’s the sample API Controller class to go with it.

using Microsoft.AspNetCore.Mvc;
 
namespace WebApiDemo.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };
 
        private readonly ILogger<WeatherForecastController> _logger;
 
        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }
 
        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}

While this isn’t a huge amount of code, there’s still a fair bit of setup work to get a basic API endpoint up and running.

Additionally, while the code is easy to understand for developers who are experienced with C# and .NET, for developers coming from another language there are potentially several new concepts to understand before they can get started. This problem is exacerbated further when we consider beginners.

Microsoft is keen to make .NET approachable for developers who are new to the framework and this was one of the key motivators behind the development of the Minimal APIs feature in ASP.NET Core.

What is minimal?

In the context of Minimal APIs, minimal means slim or lightweight. For example, consider Express, the Node.js framework that allows developers to build RESTful APIs using JavaScript running on the server.

The code for an Express application can be as simple as the following.

const express = require('express');
const app = express();
const port = 3000;
 
app.get('/api/hello', (req, res) => {
  res.json({ message: 'Hello, world!' });
});
 
app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}/`);
});

Or even simpler when method chaining is used, as shown below.

const app = require('express')();
app.get('/api/hello', (req, res) => res.json({ message: 'Hello, world!' }));
app.listen(3000, () => console.log(`Server running`));

While there are still several concepts for beginners to learn, 3 lines of code are a lot less intimidating than 60 or more!

Wouldn’t it be great if we could do something similar to the above in .NET?

Embarking on the Minimal APIs journey

There are, of course, other reasons that Microsoft decided to introduce Minimal APIs. One of these additional reasons is performance, which has been a major focus point for the ASP.NET Core team from the start. However, we can discuss this, along with some of the pros and cons later. Let’s start our exploration of Minimal APIs!

When you create a new ASP.NET Core via Visual Studio using the ‘ASP.NET Core Empty project’ template, the contents of the ‘Program.cs’ file that is automatically generated will look similar to the following code.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
app.MapGet("/", () => "Hello World!");
 
app.Run();

Now that’s a short program!

The program can be even more concise if you don’t need the WebApplicationBuilder (builder) instance, as shown below. 

var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/", () => "Hello World!");
app.Run();

However, usually, you will need to access the WebApplicationBuilder for configuring services in the dependency injection container. WebApplicationBuilder.CreateBuilder does a lot under the covers, registering defaults for things like configuration, logging, and routing.

Aside from a small C# project file, the 3 lines of code shown above are all that is needed to set up an API with an endpoint that returns the text ‘Hello World’. Compared to the amount of code that is generated by the ASP.NET Core Web API project template (or most other frameworks), this is minuscule!

The application start-up logic and API endpoints are all defined in one place. Unlike some older ASP.NET Core project templates, there isn’t a separate ‘Startup.cs’ file for configuring services and the request pipeline. Additionally, the top-level statements feature that was introduced in C# 9 allows for the removal of the application namespace, Program class and Main method that would usually be present.

By the way, here is the code that would be required to replace the ‘Weather Forecast’ example shown in the previous section.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
 
app.MapGet("/weatherforecast", () =>
{
    var forecast = Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast
        (
            DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next(summaries.Length)]
        ))
        .ToArray();
    return forecast;
});
 
app.Run();

As you can see, in this instance, there is still a lot less code required to set things up compared to the traditional approach.

Setting up base camp

In this section, we’ll look at some more examples of how to use Minimal APIs that demonstrate some of what is possible.

First of all, consider the following code.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
IList<Todo> _todos = new List<Todo>
{
    new Todo("Buy milk", 1),
    new Todo("Leave out the trash", 2),
    new Todo("Clean room", 2)
};
 
app.MapGet("/todos", () => Results.Ok(_todos));
app.Run();
 
record Todo
{
    public int Id { get; init; }
    public int UserId { get; init; }
    public string Title { get; init; }
    public bool Completed { get; init; }
 
    private static int _nextId = 1;
 
    public Todo(string title, int userId, bool completed = false)
    {
        Id = _nextId++;
        UserId = userId;
        Title = title;
        Completed = completed;
    }
}

Note that normally you should separate out classes and records etc. into individual files. The List and record in the above code sample are purely there to make it simpler to show the full code that is required for the program to compile.

The above code sample defines a Todo record that represents a basic to-do item, as well as a collection of Todo objects that simulate a very simple data store for demonstration purposes. For the remainder of this section, assume that the Todo record and the collection of Todo objects held in the _todos variables are both accessible to the code that is shown in the following subsections.

MapGet

The MapGet method allows us to set up an API endpoint that responds to HTTP GET requests.

app.MapGet("/todos", () => Results.Ok(_todos));

The above code will respond to a GET request that includes the ‘/todos’ URL segment e.g. https://localhost:7037/todos or https://myapi.com/todos

The Results class allows us to return RESTful HTTP status codes, along with a JSON-formatted response body, by specifying the appropriate status code method name and passing in the object that contains the data we want to return.

The response body will look like the following JSON when the API endpoint is called.

[
{
"id": 1,
"userId": 1,
"title": "Buy milk",
"completed": false
},
{
"id": 2,
"userId": 2,
"title": "Leave out the trash",
"completed": false
},
{
"id": 3,
"userId": 2,
"title": "Clean room",
"completed": false
}
]

In this case, the full collection of to-do items is returned.

MapGet (with parameters)

If we want to pass a parameter to our API endpoint, we can add a parameter to the anonymous delegate function that is passed into the MapGet method.

app.MapGet("/todos/{id}", (int id) =>
{
    var todo = _todos.FirstOrDefault(t => t.Id == id);
    return todo is not null ? Results.Ok(todo) : Results.NotFound();
});

In the above code, the same MapGet method overload as the previous example is being used. However, this time an integer id parameter has been added and curly braces are being used, since there is more than one line of code for the delegate function. As per normal C# compiler rules, more than one line of code in the delegate function means that we need to add the return keyword. If a Todo object is found in the in-memory collection, it will be returned to the client, otherwise, a Not Found HTTP response will be returned instead.

The other key difference is the addition of {id} in the route pattern which maps to the id parameter. This allows us to retrieve a to-do item with a specific ID as follows: https://localhost:7037/todos/3

MapPost

If we want to make a POST request to create a new resource, we can use the MapPost method.

app.MapPost("/todos", (Todo todo) =>
{
    _todos.Add(todo);
    return Results.Created($"/todos/{todo.Id}", todo);
});

In the above example, the framework will automatically recognise that the Todo parameter is a complex type and will therefore default to binding the (JSON) request body to the object.

In cases where things may be ambiguous, it’s possible to specify the binding source for a parameter using attributes such as FromRoute and FromBody, as per the following updated example.

app.MapPost("/todos", ([FromBody] Todo todo) =>
{
    _todos.Add(todo);
    return Results.Created($"/todos/{todo.Id}", todo);
});

Other attribute examples include the following.

The FromServices attribute is used to specify that a parameter should be populated by the services registered in the dependency injection container; let’s cover that next.

MapPost (with Dependency Injection)

In most cases, when you want to include an instance of an object that has been registered as a service in the dependency injection container, you can simply include a parameter of the required type on your delegate function. However, if things are ambiguous, you can use the FromServices attribute to force dependency injection for a specific parameter.

app.MapPost("/todos", (
    Todo todo, 
    IConfiguration configuration, 
    ILogger<Program> logger,
    HttpContext httpContext) =>
{
    _todos.Add(todo);
 
    if (Convert.ToBoolean(configuration["LoggingEnabled"]))
    {
        logger.LogInformation("To-do item created: {0}", todo.Title);
    }
 
    var newTodoLink = new UriBuilder
    {
        Scheme = httpContext.Request.Scheme,
        Host = httpContext.Request.Host.Host,
        Port = httpContext.Request.Host.Port.GetValueOrDefault(-1),
        Path = $"/todos/{todo.Id}"
    }.ToString();
 
    return Results.Created(newTodoLink, todo);
});

In the above example, IConfiguration and ILogger objects are being injected from the dependency injection container. Since these are built-in services, there’s no need for them to be registered manually in the application start-up logic.

You’ll also notice that a HttpContext parameter has been specified, with the HttpContext being used to construct the full URL that represents the location of a newly created resource.

It’s important to be aware that the HttpContext is, in fact, made available to us as part of the RequestDelegate that we are passing into the MapPost method. Therefore the HttpContext isn’t coming from the dependency injection container.

RequestDelegate is defined by the framework as follows for reference.

/// <summary>
/// A function that can process an HTTP request.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> for the request.</param>
/// <returns>A task that represents the completion of request processing.</returns>
public delegate Task RequestDelegate(HttpContext context);

It is very convenient to access the HttpContext in this way by including it as one of our handler parameters.

Additional mapping and configuration

In addition to the mapping methods that have been demonstrated so far, several others are available, including the following.

  • MapPut
  • MapDelete
  • MapPatch

The principle remains the same, it’s the HTTP verb which is being targeted that changes.

If you start playing around with things further you’ll notice there are methods that you can chain onto the end of your endpoint mapping methods, like AddEndpointFilter which allows custom filter logic to be applied.

Additionally, there are a bunch of other methods available for customising the endpoint, such as the following.

These methods, and others, allow a high degree of customisation for your endpoints.

Getting organised

When developing Minimal APIs, before long you will likely notice that your Program.cs file is starting to grow. In an application that has tens or hundreds of API endpoints, this can start to become unmanageable very quickly.

To avoid your code becoming hard to read and difficult to maintain, you will need to consider some options for structuring your Minimal APIs. Let’s consider a few of these options in the following subsections.

Separate methods

Instead of inline handlers, you can define separate methods for your endpoints.

Consider the following code which defines an asynchronous inline handler that accepts an integer id parameter and an injected ITodoRepository instance.

app.MapGet("/todos/{id}", async (int id, ITodoRepository repository) =>
{
    var todo = await repository.GetByIdAsync(id);
    return todo is not null ? Results.Ok(todo) : Results.NotFound();
});

The above code can be replaced with the following.

app.MapGet("/todos/{id}", GetTodoAsync);
 
async Task<IResult> GetTodoAsync(int id, ITodoRepository repository)
{
    var todo = await repository.GetByIdAsync(id);
    return todo is not null ? Results.Ok(todo) : Results.NotFound();
}

This doesn’t do anything to reduce the amount of code, in fact, there’s now slightly more code. However, the methods are now a bit more maintainable, testable, and potentially reusable.

Separate classes

A variation of the above approach is to separate your endpoint methods into another class.

For example, you could define a TodoHandlers class as follows.

public static class TodoHandlers
{
    public static async Task<IResult> GetTodoAsync(int id, ITodoRepository repository)
    {
        var todo = await repository.GetByIdAsync(id);
        return todo is not null ? Results.Ok(todo) : Results.NotFound();
    }
 
    // Other endpoint methods...
}

In Program.cs, the endpoint can then simply delegate to the appropriate static method.

app.MapGet("/todos/{id}", TodoHandlers.GetTodoAsync);

This cleans up things pretty well, but we are still going to have a long list of mapping methods in the Program.cs file.

Extension methods

Another approach is to extend the IEndpointRouteBuilder interface which will allow us to condense multiple mapping methods from the Program.cs file into a single method call.

Below is an example of how this is achieved.

public static class TodoEndpointRouteBuilderExtensions
{
    public static void MapTodoEndpoints(this IEndpointRouteBuilder app)
    {
        var todoEndpoints = app.MapGroup("/todos");
 
        todoEndpoints.MapGet("/{id}", GetTodoAsync);
        todoEndpoints.MapPost("", PostTodoAsync);
 
        // Other endpoints registrations...
    }
 
    private static async Task<IResult> GetTodoAsync(int id, ITodoRepository repository)
    {
        var todo = await repository.GetByIdAsync(id);
        return todo is not null ? Results.Ok(todo) : Results.NotFound();
    }

// Other endpoints methods e.g. PostTodoAsync etc. }

Note the usage of the MapGroup method, which removes the need to repeatedly specify ‘/todos’ when mapping individual todo endpoints.

You can of course mix and match approaches. For example, instead of the private methods defined above you could choose to define inline handlers if that is your preference, or move the private methods to separate classes.

With the above in place, in Program.cs we can now call the app.MapTodoEndpoints method and all of our todo-related endpoints will be registered with a single line of code.

app.MapTodoEndpoints();

This means that if your API only needs to expose ‘todo’ endpoints, the overall Program.cs could be as short as the following, assuming that ITodoRepository and TodoRepository etc. are defined in separate files.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ITodoRepository, TodoRepository>();

var app = builder.Build();
app.MapTodoEndpoints();
app.Run();

That’s pretty neat!

Pros and Cons

Before you decide to use Minimal APIs for a new project or start ripping out API Controllers from your existing project, it’s important to take a step back and consider some of the pros and cons of Minimal APIs.

Let’s consider a few of these before wrapping up.

Pros

Simplicity

The code required to define a Minimal API endpoint is concise, intuitive, and beginner-friendly.

When it comes to API Controllers, you need to understand all of the concepts that are in play before you can get started, such as controller and action attributes and the wiring up of the request pipeline.

Quick prototyping

Minimal APIs can be an excellent choice for small projects and prototyping purposes, where you need to get something up and running as fast as possible. After the initial prototyping phase, you can choose to stick with Minimal APIs or switch to API Controllers.

Microservices are also a good potential use case for Minimal APIs. If you are developing very granular microservices, with a small number of endpoints, Minimal APIs could be a good solution. This applies in particular if your endpoints are simply routing requests to your business/services layer and contain minimal logic, which ideally should be the case anyway!

Performance

Minimal APIs can offer better performance compared to API Controllers by avoiding a lot of the steps that are normally processed as part of the MVC request pipeline. This includes complex routing, controller initialisation, and action execution, amongst other things. As a result, an API built with the Minimal API framework will likely use fewer resources and consume less processor time.

Cons

Features

For some of the features you are used to having when working with API Controllers, you may need to use alternative approaches when developing Minimal APIs. It could take some time to discover the equivalent approach in these situations and if you already have custom middleware and global filters etc. in an existing project, you will need to spend time figuring out how you can ensure that the logic from these gets applied correctly to your Minimal API endpoints.

Mental shift

Although the framework is abstracting away a lot of the complexity that sits underneath Minimal APIs, it can take some time to understand all the possibilities. So if you’re already an experienced ASP.NET Core developer who is used to the classic MVC architecture, Minimal APIs may require a mental shift to adapt to a new way of doing things that could slow you down initially.

Since there are many ways to organize your Minimal API endpoints, as discussed in the previous section, you’ll need to spend some time thinking about your approach in this regard. In the future, you’ll likely find many variations in the approaches used by different development teams. With API Controllers, the MVC framework is very prescriptive about how your endpoints/actions are structured, so you’re less likely to suffer from decision fatigue.

Smaller community

API Controllers are battle-tested and are well-understood by most of the .NET community. On the other hand, Minimal APIs have not yet gained the same level of adoption. As a result, you may not find as much content online to refer to when you are developing Minimal APIs and if you run into issues there may not be as many people on hand who have encountered the same problem you are facing.

Debriefing

Minimal APIs are a welcome addition for developers who prefer a more lightweight approach to API development. However, they are not a one-size-fits-all solution and may not be suitable for every project type. Whether you’re building a quick prototype or a complex enterprise application, the key lies in choosing the right tool for the job.

In my opinion, Minimal APIs are great for small, not too complex, APIs. If you’re developing an API with lots of endpoints, you will need to consider how you are organising these in your project. If a lot of effort needs to be expended to separate large numbers of endpoints into modules or controller-like classes, it probably makes sense to opt for the MVC approach instead. There’s no need for us to recreate the MVC framework when it’s already there!

This article has only scratched the surface of what is possible with Minimal APIs, particularly regarding aspects such as request filtering and endpoint configuration. If you choose to start adopting Minimal APIs, I highly recommend that you check out the Minimal APIs quick reference page on the Microsoft Docs website. It is an excellent resource that will help to answer any questions you have regarding the nuances of Minimal APIs as you continue your journey.


I hope you enjoyed this post! Comments are always welcome and I respond to all questions.

If you like my content and it helped you out, please check out the button below 🙂

Comments