.NET Tools How-To's

Introduction to ASP.NET Core Minimal APIs

In recent .NET versions, there’s a new way to build JSON-based APIs with ASP.NET Core: Minimal APIs. Inspired by previous attempts in the ASP.NET ecosystem and elements from other communities, the Minimal APIs approach is an attempt to simplify the development of JSON-producing HTTP APIs.

This post explores why it makes sense to use Minimal APIs, the programming model compared to ASP.NET Core MVC, and some drawbacks that might make you consider using it.

Why ASP.NET Core Minimal API?

Over several versions of .NET Core and .NET, performance has been a central focus of the .NET team. While ASP.NET Core MVC is a robust and production-hardened approach to building Web APIs, the complexity of the MVC pipeline leads to a lot of wasted time and resources when processing incoming HTTP requests. 

Steps along a standard ASP.NET Core MVC request include nothing short of routing, controller initialization, action execution with model binding and filters, and result filters. Processing a request is typically a 17-step process, with more steps if you use any view engines. When building JSON-based APIs, you may view these steps as “unnecessary” and reduce your opportunities for greater throughput.

Along with the performance implications, some folks might find the “convention over configuration” approach of ASP.NET Core MVC “too magical”. For example, ASP.NET Core registers controller endpoints by scanning for route attributes or matching user-defined routing patterns. In addition, the ASP.NET Core MVC approach can typically detach the structural definitions of your application from the actual code you write. With global filters, model binders, and middleware, this complexity can lead developers to introduce subtle yet frustrating bugs. 

Finally, Minimal APIs fit the programming paradigms developed over the last decade, emphasizing microservices and limiting the functionality that each host exposes. In practice, applications built with Minimal APIs can easily fit into a single file, expressing the functionality in one easy-to-read place. Some developers prefer this explicitness to ASP.NET Core MVC’s sprawl of controllers, models, and views.

Another critical part of ASP.NET Core is the componentization of cross-cutting functionality such as routing, middleware, and dependency injection. The separation of elements from any programming model allows you to mix and match ASP.NET Core MVC, Razor Pages, and Minimal APIs functionality in a single host application. Using Minimal APIs doesn’t mean starting over but reflecting on an existing codebase and an opportunity to optimize.

Your first Minimal API application

You can create a new ASP.NET Core solution using the “Empty” ASP.NET Core project type to get started with Minimal APIs. Once you’ve created your solution, you’ll notice a Program.cs file with the following code.

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

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

app.Run();

First, let’s talk about the WebApplication.CreateBuilder method call at the top of the file. This method registers commonly used elements of a web application host, such as configuration, logging, host services, routing, and more. Once called, you can augment your host by registering services, reading configuration, logging, and more. Then, once your modifications are complete, you can Build your application host.

Once you have cemented your application’s configuration, you can modify the request pipeline by registering middleware or adding HTTP endpoints. Take note of the call to MapGet, which takes both the path and RequestDelegate to return a string value. Minimal APIs have correlations for all the essential HTTP methods, such as GET, POST, PUT, PATCH, DELETE, and others.

The RequestDelegate is a powerful abstraction, allowing ASP.NET Core a common interface to execute all HTTP requests. While a powerful abstraction, it’s a relatively straightforward delegate definition with an HttpContext parameter.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Http;

/// <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);

While a RequestDelegate has a single parameter, as you’ll see later, we can pass any number of arguments to our endpoint definitions using dependency injection.

On the final line, you start the application and listen for incoming requests by calling the Run method. In this example, Run is blocking and will pause further execution past this point. Other methods, such as Start, will allow you to execute more code afterward.

From here, let’s start utilizing more Minimal APIs programming model features.

Model binding and return types

Let’s add a POST method that takes advantage of Minimal APIs model binding and the Results class.

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

app.MapPost("/hugs", (Hug hug) =>
    Results.Ok(new Hugged(hug.Name, "Side Hug"))
);

app.Run();

public record Hug(string Name);
public record Hugged(string Name, string Kind);

Model binding in Minimal API applications differs from ASP.NET Core MVC. ASP.NET Core MVC uses the IModelBinder interface, allowing commonly shared approaches and stacking model binders to hydrate incoming requests. With Minimal APIs, you get a single opportunity to bind data from the request to the model. By default, it’s a straightforward deserialization of the request body, assumed to be JSON, to your request type.

A request to our new endpoint would pass a JSON payload in the HTTP request body.

POST http://localhost:5272/hugs
Content-Type: application/json

{ "Name" : "Khalid Abuhakmeh" }

Once received, you can process the request and return a result using the Result class. The Result class is a helper type that contains methods to reduce the complexity around typical status code results and payloads. Before writing unique response types, you should explore the list of methods in the Result class. You’ll likely find the needed result types, such as content, files, and JSON.

Info: Check out our blog post if you’re interested in testing endpoints with JetBrains Rider’s built-in HTTP Client and Endpoints window.

Dependency Injection and Services

An application of sufficient complexity will always have some dependency on services. Minimal APIs are built on the ASP.NET Core’s dependency injection, which means you can inject any registered service you need to create an appropriate response. Let’s refactor our service with a dependency of HuggingService.

using static System.Security.Cryptography.RandomNumberGenerator;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new HuggingService());
var app = builder.Build();

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

app.MapPost("/hugs", (Hug hug, HuggingService hugger) =>
    Results.Ok(hugger.Hug(hug))
);

app.Run();

public record Hug(string Name);

public record Hugged(string Name, string Kind);

public class HuggingService
{
    private readonly string[] _hugKinds = {
        "Side Hug", "Bear Hug",
        "Polite Hug", "Back Hug",
        "Self Hug"
    };

    private string RandomKind =>
        _hugKinds[GetInt32(0, _hugKinds.Length)];

    public Hugged Hug(Hug hug)
        => new(hug.Name, RandomKind);
}

If you look at the definition for our MapPost method call, you’ll notice an instance of HuggingService as a delegate parameter. You can get a service injected because ASP.NET Core will resolve types from the IServiceProvider after it has exhausted the likelihood that value resolution should occur from route values, query string, HTTP Headers, and the HTTP body. Several attributes can give ASP.NET Core hints as to where parameter binding should be preferred, but in most cases, it’s unnecessary. Regarding services, you must register any dependency in the builder phase of your application, or you will get an error.

Running the same HTTP request from before, we get the same result, but now with random kinds of hugs.

Filters for request and response handling

Filters are a mainstay of ASP.NET Core MVC and can help reduce commonly-repeated code. Recently introduced in .NET 7, the addition of Minimal API filters aims to do the same as their predecessor. Let’s modify our code to add a timestamp filter to hydrate the current date and time to the Hugged response objects.

using Microsoft.AspNetCore.Http.HttpResults;
using static System.Security.Cryptography.RandomNumberGenerator;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new HuggingService());
var app = builder.Build();

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

async ValueTask<object?> Timestamp(
    EndpointFilterInvocationContext ctx,
    EndpointFilterDelegate next
)
{
    var result = await next(ctx);
    if (result is Ok<Hugged> { Value : { } } hugged) 
        hugged.Value.Timestamp = DateTime.UtcNow;
    return result;
}

app.MapPost("/hugs", (Hug hug, HuggingService hugger) =>
    Results.Ok(hugger.Hug(hug))
).AddEndpointFilter(Timestamp);

app.Run();

public record Hug(string Name);

public record Hugged(string Name, string Kind)
{
    public DateTime Timestamp { get; set; } = DateTime.UnixEpoch;
}

public class HuggingService
{
    private readonly string[] _hugKinds = {
        "Side Hug", "Bear Hug",
        "Polite Hug", "Back Hug",
        "Self Hug"
    };

    private string RandomKind =>
        _hugKinds[GetInt32(0, _hugKinds.Length)];

    public Hugged Hug(Hug hug)
        => new(hug.Name, RandomKind);
}

You can implement filters as local functions, delegates, or the IEndpointFilter interface. For simplicity, I’ve elected to use a local function and pass it into the AddEndpointFilter. It’s the responsibility of the filter to choose when and where to execute the endpoint it is filtering. Deciding when to run the next argument gives you the same flexibility you’d have with middleware, as filters behave very similarly.

In the next section, we’ll see how to reduce code noise by taking advantage of groups.

Route groups and common behaviors

Another recently introduced feature is the concept of RouteGroupBuilder, which allows you to logically group routes to a base path prefix. Further modifying our application, let’s create a new “hugs” route group, which will now be a foundation for all hug-based endpoints. This group allows us to add group metadata, filters, and more in one convenient location.

using Microsoft.AspNetCore.Http.HttpResults;
using static System.Security.Cryptography.RandomNumberGenerator;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new HuggingService());
var app = builder.Build();

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

async ValueTask<object?> Timestamp(
    EndpointFilterInvocationContext ctx,
    EndpointFilterDelegate next
)
{
    var result = await next(ctx);
    if (result is Ok<Hugged> { Value : { } } hugged) 
        hugged.Value.Timestamp = DateTime.UtcNow;
    return result;
}

var hugs = app.MapGroup("hugs")
    .AddEndpointFilter(Timestamp);

hugs.MapPost("", (Hug hug, HuggingService hugger) =>
    Results.Ok(hugger.Hug(hug))
);
hugs.MapGet("", (HuggingService hugger) => 
    Results.Ok(hugger.Hug(new Hug("Test")))
);

app.Run();

public record Hug(string Name);

public record Hugged(string Name, string Kind)
{
    public DateTime Timestamp { get; set; } = DateTime.UnixEpoch;
}

public class HuggingService
{
    private readonly string[] _hugKinds = {
        "Side Hug", "Bear Hug",
        "Polite Hug", "Back Hug",
        "Self Hug"
    };

    private string RandomKind =>
        _hugKinds[GetInt32(0, _hugKinds.Length)];

    public Hugged Hug(Hug hug)
        => new(hug.Name, RandomKind);
}

Now you can enhance our “hugs” group in one location and have those changes affect all routes within the group. It’s an excellent optimization as your API grows in complexity.

Minimal API drawbacks

The technology is robust and capable of handling most, if not all, workloads an ASP.NET Core developer would want. Some of the drawbacks of the Minimal API approach are more about the still-growing community. ASP.NET Core MVC has a rich history of open-source solutions to enhance the MVC pipeline and many blog posts explaining common pitfalls. With Minimal APIs, the community of users is still in its infancy. As a result, you might spend more time in the minutiae of infrastructure code than you’d prefer.

If you’re starting with Minimal APIs, you’ll make many decisions that you might not have to with ASP.NET Core MVC. There’s freedom in choice, but it can sometimes feel like a burden. Where do you put your models and services? How do you refactor filters? Where should you define routes?

The dizzying amount of choices likely means that you’ll see many Minimal API apps looking dramatically different from each other, while MVC is a standard and recognizable approach. These are certainly not showstoppers in adopting Minimal APIs, but you should be mindful of them.

Conclusion and final thoughts

The story of Minimal APIs in the ASP.NET Core ecosystem is still being written, and you’ll likely see new features with every iteration of .NET. However, this post introduced common parts of Minimal APIs if you are considering switching from the MVC approach. In addition, you gleaned a small taste of concepts such as application setup, endpoint definitions, model binding, dependency injection, filters, and route groups. I also pointed to potential drawbacks with the burgeoning community support and decision overload with a Lego-style framework. 

All in all, the ultimate choice is yours, and I hope you found this post helpful. Please feel free to share your thoughts and comments below about Minimal APIs.

Image Credit: Akshar Dave🌻

image description