Migrate from MVC to Minimal APIs with ASP.NET Core 6

In 2007, .NET web application development had a much needed evolution with the introduction of ASP.NET MVC, providing native support for the Model-View-Controller pattern that was becoming commonplace in other languages. In 2012, perhaps due to the increasing popularity of ReSTful APIs, we were introduced to ASP.NET Web API, a significant improvement over WCF that enabled developers to build HTTP APIs with less ceremony, borrowing many concepts from ASP.NET MVC. Later, in ASP.NET Core these frameworks were unified into ASP.NET Core MVC; a single framework for building both web sites and APIs.

In ASP.NET Core MVC applications, the controller is responsible for accepting input, taking or orchestrating operations and returning a response. It’s a fully featured framework, offering an extensible pipeline via filters, built-in model-binding and validation, convention and declarative based behaviours and much more. For many, it’s an all-in-one solution for building modern HTTP applications.

There are also scenarios where you may only need specific features of the MVC framework or have performance constraints that make MVC undesirable. With more HTTP features surfacing as ASP.NET Core Middleware (e.g. Authentication, Authorization, Routing etc.) it has become much easier to build lightweight HTTP applications without MVC, but there are features commonly needed that you would otherwise have to build yourself, such as model-binding and HTTP response generation.

ASP.NET Core 6.0 aims to bridge this gap with Minimal APIs, offering many of the features of ASP.NET MVC with less ceremony. This post provides a step-by-step guide on how to translate traditional MVC concepts to this new way of building lightweight HTTP APIs and services.

Bootstrapping

MVC

dotnet new webapi

The new ASP.NET templates do away with the Startup class and take advantage of C# 10’s top-level statements feature so we have a single Program.cs file with all of our bootstrapping code:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

The call to builder.Services.AddControllers() takes care of registering the MVC framework dependencies and discovering our controllers. We then call app.MapControllers() to register our controller routes and the MVC middleware.

Minimal API

dotnet new web

The ASP.NET Empty template uses Minimal APIs for the canonical “Hello world” example:

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

if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

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

app.Run();

The MapGet method is part of the Minimal API extensions. Other than that it doesn’t really differ much from MVC (taking into account that the HTTPS Redirection and Authorization middleware are simply omitted from the Empty template rather than being implicitly enabled).

Defining Routes and Handlers

MVC

In MVC we have two ways to define routes, either by convention or using attributes.

Convention based routing is more commonly used for websites rather than APIs and is included in the mvc template. Instead of app.MapControllers we use:

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

The pattern specifies the different segments of the route and allows default values to be specified. Parameters can make use of ASP.NET’s route constraint syntax to restrict the accepted values.

For APIs it’s recommended to use attribute-based routing.

With attribute routing you decorate your controllers and actions with attributes that specify the HTTP verb and path:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    [HttpGet]
    public IEnumerable<WeatherForecast> Get()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

At startup the routes will be automatically registered. The above example, from the default webapi template, demonstrates route token replacement. The [Route("[controller]")] attribute will use the prefix (or resource) /weatherforecast for all routes (the controller class name minus the “Controller” suffix) and the parameterless [HttpGet] attribute will register the action at the root of the resource so HTTP GET /weatherforecast will hit this action.

If I wanted to extend the API to allow retrieving the forecast by location I could add the following action:

[HttpGet("locations/{location}")]
public IEnumerable<WeatherForecast> GetByLocation(string location)
{

}

When requesting /weatherforecast/locations/london the value london will be bound to the corresponding action parameter.

MVC controllers can look quite bloated in comparison to their Minimal API counterparts. However, it’s worth noting that controllers can also be POCOs (Plain Old CLR Objects). To achieve the same result as the “Hello World” Minimal API example above, this is all we need:

public class RootController
{
    [HttpGet("/")]
    public string Hello() => "Hello World";
}

From this you can see that MVC can also be “minimal” especially when you consider you’ll still want some level of modularisation, even with Minimal APIs.

Minimal API

To define routes and handlers using Minimal APIs, use the Map(Get|Post|Put|Delete) methods. Interestingly there’s no MapPatch method but you can define any set of verbs using MapMethods.

To implement the same weather forecast example using Minimal APIs:

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = summaries[Random.Shared.Next(summaries.Length)]
    })
    .ToArray();
});

app.Run();

Similar to the MVC example, we can extend this to query by location:

app.MapGet("/weatherforecast/locations/{location}", (string location) =>
{

});

Note that in both the MVC and Minimal API examples, we’re benefiting on the implicit conversion of the return type to a serialized HTTP 200 (OK) response. We’ll cover the more explicit HTTP object model for both frameworks later.

Model Binding

Model binding is the process of retrieving values from the HTTP request and converting them to .NET types. This section will mainly look at receiving JSON data in the request body or via query string parameters since we covered binding route values above.

MVC

In MVC you can bind JSON from the request body to a .NET type by passing it as a parameter to your action method and decorating it with the [FromBody] attribute:

[HttpPost("/payments")]
public IActionResult Post([FromBody]PaymentRequest request)
{

}

Alternatively, by decorating your controller with the [ApiController] attribute, a convention will be applied that will bind any complex types from the body.

In some cases you may want to bind a complex type from a query parameter. I like to do this for search endpoints with multiple filtering options. You can achieve this with the [FromQuery] attribute:

[HttpGet("/echo")]
public IActionResult Search([FromQuery]SearchRequest request)
{

}

Simple types will otherwise be bound from route or query string values:

[HttpGet("/portfolios/{id}")]
public IActionResult Search(int id, int? page = 1, int? pageSize = 10)
{

}

A request to /portfolios/10?page=2&pagesize=20 would satisfy the above action parameters.

The above example also demonstrates the use of optional parameters by marking them as nullable and optionally providing a default value.

This works slightly differently with complex types. Even when making the type nullable, if a body is not sent you will either receive a HTTP 415 (Invalid Media Type) or 400 (Bad Request) response depending on whether the Content-Type header is set.

Previously this behaviour could only be configured globally via MvcOptions.AllowEmptyInputInBodyModelBinding globally but since ASP.NET Core 5 it can now be configured per request:

[HttpPost("/payments")]
public IActionResult Post([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)]PaymentRequest? request)
{

}

Minimal API

Model binding in Minimal APIs is very similar; you configure your handler delegate with the types you wish to be bound from the request. Complex types will be automatically bound from the request body and simple types from route or query string parameters. The same examples implemented using Minimal APIs are as follows:

app.MapPost("/payments", (PaymentRequest paymentRequest) => 
{

});

app.MapGet("/portfolios/{id}", (int id, int? page, int? pageSize) => 
{

});

In order to specify default values, you’ll need to pass a method as the delegate as default values for inline lambda functions are not yet supported in C#:

app.MapGet("/search/{id}", Search);

app.Run();

IResult Search(int id, int? page = 1, int? pageSize = 10)
{

}

The [FromQuery] attribute does not support binding complex types. There are extensibility points available to customise model binding which I’ll cover in a later post.

To support optional request parameters you can apply the same [FromBody] attribute as MVC, specifying the EmptyBodyBehavior:

app.MapPost("/payments", ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)]PaymentRequest? paymentRequest]) => 
{

});

HTTP Responses

Both MVC and Minimal APIs will automatically serialize your return types to the response body and return a HTTP 200 (OK) response, for example:

[HttpPost("/echo")]
public EchoRequest Echo(EchoRequest echo) => echo;

// Minimal API
app.MapPost("/echo", (EchoRequest echo) => echo);

You can also return void or Task to return an empty HTTP 200 (OK) response:

[HttpPost("/echo")]
public void Echo(EchoRequest echo) => {};

// Minimal API
app.MapPost("/echo", (EchoRequest echo) => {});

Beyond implicit conversion, both MVC and Minimal APIs have a rich HTTP response object model that covers most common HTTP responses.

MVC

In MVC you can return IActionResult and make use of the many built-in implementations, for example, AcceptedResult. If you are deriving your controllers from ControllerBase there are helper methods available for majority of response types:

[HttpDelete("/projects/{id}")]
public IActionResult Delete(int id)
{
return Accepted();
}

Minimal API

With Minimal APIs we can return an implementation of IResult. The Results static class makes it easy to generate a number of built-in response types:

app.MapDelete("/projects/{id}", (int id) =>
{
return Results.Accepted();
});

Dependency Injection

MVC

To inject dependencies into MVC controllers we typically use constructor injection whereby the required types (or more typically their underlying interfaces) are provided as constructor parameters:

public class CacheController : ControllerBase
{
    private readonly ICache _cache;

    public CacheController(ICache cache)
    {
        _cache = cache;
    } 

    [HttpDelete("/cache/{id}")]
    public async Task<IActionResult> Delete(string id)
    {
        await _cache.Delete(id);
        return Accepted();
    }
}

Dependencies are registered at startup (now in Program.cs by default):

builder.Services.AddScoped<ICache, MemoryCache>();

Services registered with a scoped lifecycle will be created per HTTP request within an MVC application.

Minimal API

With Minimal APIs we can still benefit from dependency injection but instead of using constructor injection, the dependencies are passed as parameters in the handler delegate:

app.MapDelete("/cache/{id}", async (string id, ICache cache) =>
{
    await cache.Delete(id);
    return Results.Accepted();
});

This approach is slightly more pure and can make testing easier. The downside is that once you get to more than a few dependencies your handler definitions can become quite noisy.

Finally, though it might be tempting to rely on dependencies declared locally within Program.cs, this will not only make testing difficult but can also result in scoping issues. I recommend leveraging the DI container whenever possible, even for singleton dependencies.

Context

It’s likely that your APIs will need access to additional information about the HTTP request, for example, headers or details of the current user. Both MVC and Minimal APIs are built on top of the same ASP.NET Core HTTP abstractions you are familiar with.

MVC

In MVC, when deriving your controller from ControllerBase you can access the HttpContextHttpRequestHttpResponse and current user (ClaimsPrincipal) from properties on the base class:

[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
    if (Request.Headers.TryGetValue("some header", out var headerValue))
    {

    }

    bool isSpecialUser = User.Identity.IsAuthenticated 
        && User.HasClaim("special");

If your controller is a simple POCO and does not derive from ControllerBase you would need to either use constructor injection to inject IHttpContextAccessor into your controller or for direct access to the request, response and user, perform a bit of DI wire-up for these types. It would be nice if POCO controllers could leverage method injection similar to Minimal APIs, described below.

Minimal API

With Minimal APIs you can access the same contextual information by passing one of the following types as a parameter to your handler delegate:

  • HttpContext
  • HttpRequest
  • HttpResponse
  • ClaimsPrincipal
  • CancellationToken (RequestAborted)
app.MapGet("/hello", (ClaimsPrincipal user) => {
    return "Hello " + user.FindFirstValue("sub");
});

There are cases where you need to generate links to other parts of your API. In ASP.NET Core we can rely on the existing HTTP and routing infrastructure to avoid hardcoding URI components. To generate links to known routes, we first need a way to identify them.

MVC

In MVC we can pass a Name property to the route attributes we decorate our controller actions with, for example:

[HttpGet("products/{id}", Name = "get_product")]
public IActionResult GetProduct(int id)
{

}

We can then use IUrlHelper to generate links to that route:

[HttpPost("products", Name = "create_product")]
public IActionResult CreateProduct(CreateProduct command)
{
    var product = Create(command);
    return Created(Url.Link("get_product", new { id = product.Id }));
}

Note how the route parameters of the get_product route, in this case the ID, are passed as an anonymous object.

IUrlHelper is available via the Url property on ControllerBase. Alternatively you can inject it into your classes providing you are within the HTTP scope.

Minimal API

With Minimal APIs you name your endpoints by attaching metadata:

app.MapGet("/products/{id}", (int id) =>
{
return Results.Ok();
})
.WithMetadata(new EndpointNameMetadata("get_product"));

A shorthand version of the above, WithName, will be available in a future release.

There is also an outstanding proposal to have the endpoint name generated implicitly when you pass a method group rather than an inline lambda. From the above issue:

// These endpoints have their name set automatically
app.MapGet("/todos/{id}", GetTodoById);

async Task<IResult> GetTodoById(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound();
};

Once you have named your endpoints you can inject LinkGenerator into your handlers to generate links:

app.MapPost("payments", async (HttpContext httpContext, IMediator mediator, LinkGenerator links, PaymentRequest payment) =>
{
    var result = await mediator.Send(payment);

    return result.Match(
        invalidRequest => invalidRequest.ToValidationProblem(),
        success => Results.Created(links.GetUriByName(httpContext, "get_payment", new { id = success.Id})!, payment)
    );
})

Some of the built-in Result helpers handle this boilerplate on your behalf. The same example, simplified with Results.CreatedAtRoute:

app.MapPost("payments", async (HttpContext httpContext, IMediator mediator, PaymentRequest payment) =>
{
    var result = await mediator.Send(payment);

    return result.Match(
        invalidRequest => invalidRequest.ToValidationProblem(),
        success => Results.CreatedAtRoute("get_payment", new { id = success.Id }, success);
    );
})

Validation

MVC

Input validation is a vital part of any API. One of the features that MVC adds on top of the ASP.NET is Model state. From the docs:

Model state represents errors that come from two subsystems: model binding and model validation. Errors that originate from model binding are generally data conversion errors.

MVC also includes built-in support for validation via attributes For example:

public class PaymentRequest
{
    [Required]
    public int? Amount { get; set; }

    [Required]
    [StringLength(3)]
    public string Currency { get; set; }
}

Hint: A popular option is to swap out the default attribute based validation for Fluent Validation.

When binding to this model type, any validation errors will be added automatically to Model state. In a controller, we can then inspect it and take the appropriate action:

public IActionResult Post(PaymentRequest paymentRequest)
{
    if (!ModelState.IsValid)
    {
        // return validation error
    }

    // otherwise process
}

In fact, we don’t even need to do the above if we decorate our controller with the [ApiController] convention. This applies a filter to the MVC pipeline that will validate the input of any request and return a Problem Details response if necessary.

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-293242b60c05924743847956126b31fe-a1b01281b398430d-00",
    "errors": {
        "Amount": [
            "The Amount field is required."
        ],
        "Currency": [
            "The Currency field is required."
        ]
    }
}

This is a great example of how the MVC filter pipeline can remove duplication from your application. Filters have access to additional context that you just don’t have within ASP.NET middleware. This is what allows the built-in validation middleware to execute automatically since it is able to run after model binding takes place.

Minimal API

As it stands, Minimal APIs do not come with any built-in support for validation. However, you are of course free to roll your own.

Damian Edwards created MinimalValidation, a small library that takes advantage of validation attributes similar to the default MVC validation:

app.MapPost("/widgets", (Widget widget) =>
    !MinimalValidation.TryValidate(widget, out var errors)
        ? Results.BadRequest(errors)
        : Results.Created($"/widgets/{widget.Name}", widget));

app.Run();

class Widget
{
    [Required, MinLength(3)]
    public string? Name { get; set; }

    public override string? ToString() => Name;
}

I personally prefer to use Fluent Validation typically replacing the attribute-based validation in MVC with this library.

Here’s an example of using Fluent Validation with Minimal APIs:

builder.Services.AddValidatorsFromAssemblyContaining<PaymentRequest>(lifetime: ServiceLifetime.Scoped);

var app = builder.Build();

app.MapPost("payments", async (IValidator<PaymentRequest> validator, PaymentRequest paymentRequest) =>
{
    ValidationResult validationResult = validator.Validate(paymentRequest);

    if (!validationResult.IsValid)
    {
        return Results.ValidationProblem(validationResult.ToDictionary());
    }

    // otherwise process
});

// URL generation?

app.Run();

public record PaymentRequest(int? Amount, string Currency)
{
    public class Validator : AbstractValidator<PaymentRequest>
    {
        public Validator()
        {
            RuleFor(x => x.Amount).NotNull().WithMessage("amount_required");
            RuleFor(x => x.Currency).Length(3).WithMessage("currency_invalid");
        }
    }
}

public static class ValidationExtensions
{
    public static IDictionary<string, string[]> ToDictionary(this ValidationResult validationResult)
        => validationResult.Errors
                .GroupBy(x => x.PropertyName)
                .ToDictionary(
                    g => g.Key,
                    g => g.Select(x => x.ErrorMessage).ToArray()
                );
}

Note: FV validators do not need to be nested within their target type. This is just personal preference.

Here I’m making use of Fluent Validation’s assembly scanning capabilities to locate my validators. Alternatively I could register the IValidator<T> implementations explicitly. Either way it means my validator can be provided to my handler and I can validate the incoming type.

One downside here is that you will likely end up writing the same boilerplate validation check in each handler. It could be reduced with a bit of refactoring but with no pre-handler hooks that give access to the bound model, we can’t easily short-circuit the request like we can with MVC filters. There are some alternative approaches that I’ll cover in a later blog post.

JSON Serialization

You may need to customize the default JSON serialization settings to meet your needs or API style guide. For example, the default settings serialize field names as camel-case (i.e. firstName) but our API standards require all APIs to use snake case (i.e. first_name).

ASP.NET 6.0 uses System.Text.Json for handling JSON and the customisation options are well documented here.

MVC

In MVC you can customize the JSON via the AddJsonOptions extension:

services.AddControllers()
    .AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = new SnakeCaseNamingPolicy());

Minimal APIs

Minimal APIs rely on a number of extension methods for serializing to/from JSON. They allow for JsonSerializerOptions to be provided but otherwise falls back to retrieving JsonOptions from HttContext.Request.Services. You can configure these options at startup:

builder.Services.Configure<JsonOptions>(opt =>
{
    opt.SerializerOptions.PropertyNamingPolicy = new SnakeCaseNamingPolicy());
});

Note that you need to configure Microsoft.AspNetCore.Http.Json.JsonOptions not the class under the Mvc namespace.

One thing I spotted when digging into the source is that ObjectResult, the base class for the IResult implementations that serialize objects, only supports serializing JSON. I’m told this was by design since most developers rarely need to support other media types. If you need to support content negotiation you’ll likely need to build your own implementation of IResult.

Authorization

The final feature I wanted to cover is Authorization. Both Authentication and Authorization exist as middleware that can be used with any flavour of ASP.NET Core application. You need to ensure you register both the Authorization services and middleware in your application, before you add the MVC or Minimal API middleware:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();

builder.Services.AddAuthorization();
builder.Services.AddControllers(); // If MVC

var app = builder.Build();

// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
   app.UseDeveloperExceptionPage();
}

app.UseAuthentication();
app.UseAuthorization(); // <-- this needs to come first

app.MapControllers(); // MVC
app.MapGet("/", () => "Hello World!"); // Minimal APIs

app.Run();

The above example is using JWT Bearer authentication.

The main difference between MVC and Minimal APIs is the way in which you declare your authorization requirements.

Secure by Default

If you have the same authorization requirements for all of your endpoints I recommend you set the fallback policy to require authenticated users:

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
      .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
      .RequireAuthenticatedUser();
})

If you have additional requirements or need to allow anonymous access for specific endpoints, you can annotate your endpoints using the instructions below.

MVC

In MVC applications, decorate your controllers and/or actions with the [Authorize] attribute to specify your authorization requirements. This attribute allows you to specify both roles and policies. This example, taken from the Microsoft Docs, applies the AtLeast21 policy to all actions defined in the controller:

[Authorize(Policy = "AtLeast21")]
public class AlcoholPurchaseController : Controller
{
    public IActionResult Index() => Ok();
}

In cases where some of your API endpoints need to allow anonymous access, you can decorate those actions with the [AllowAnonymous] attribute:

[AllowAnonymous]
[HttpGet("/free-for-all")]
public IActionResult FreeForAll()
{
    return Ok();
}

Minimal API

To achieve the same behaviour with Minimal API we can attach additional metadata to the endpoints like so:

app.MapGet("/alcohol", () => Results.Ok())
    .RequireAuthorization("AtLeast21");

Similarly, to allow anonymous access:

app.MapGet("/free-for-all", () => Results.Ok())
    .AllowAnonymous();

I later found that it’s possible to use the same [Authorize] attribute as MVC when using Method Groups to define your handlers:

[Authorize("AtLeast21")]
string Alcohol()
{

}

Wrapping Up

Minimal APIs offer an alternative approach for build APIs with ASP.NET Core. Although it’s easy to see them as “APIs with less code”, the main benefit is that you have a lightweight base on which you can pick and choose the components you need, rather than something heavier like MVC which may include lots of great features that you don’t use (filters for example). In many cases this can result in services with a far smaller footprint and subsequent performance gains.

It’s worth mentioning that there have been community efforts in the past to achieve the same thing. Nancy gave us something similar back in the days of Web API / OWIN and more recently Carter emerged for ASP.NET Core, offering similar features to Minimal APIs.

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *