Using ASP.NET Core MVC Value Providers With Minimal APIs

Minimal APIs in ASP.NET Core deliberately strip away a lot of what MVC controllers give you for free, and one of the bigger things left on the cutting room floor is MVC’s model binding pipeline. Minimal API endpoints expect JSON by default, and if a request comes in as form data instead, you are largely on your own for pulling values out of HttpContext by hand.

That manual binding gets tedious fast, especially once a type has more than two or three properties. It turns out you do not actually have to give up MVC’s model binding to use Minimal APIs, you can register just the pieces you need and reuse them.

The Problem: Form Data Into a Minimal API Endpoint

Take a simple form-encoded POST request.

POST https://localhost:7113/hi
Content-Type: application/x-www-form-urlencoded
 
Name=Khalid&Age=38

On the backend, the endpoint expects a Person parameter.

public record Person(string Name, int Age);

Minimal APIs let a type opt into custom binding by defining a static BindAsync method, which the framework will call automatically when that type shows up as a parameter.

public record Person(string Name, int Age)
{
    public static async ValueTask<Person?> BindAsync(HttpContext httpContext, ParameterInfo parameter)
    {
        // return type
    }
}

BindAsync gives you the hook, but you still have to fill in the body, and mapping form fields to properties by hand inside that method is exactly the tedious part we are trying to avoid. This is where pulling in a slice of MVC helps.

Registering MVC’s Model Binding Without the MVC Pipeline

You do not need the full MVC request pipeline to get its model binding services, calling AddMvcCore is enough to register what BindFromForm needs, without pulling in routing, filters, or the rest of the MVC middleware stack.

using System.Reflection;
using FormBinding;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMvcCore();
 
var app = builder.Build();
 
app.MapGet("/", () => "Hello World!");
 
app.MapPost("/hi", (Person person) =>
{
    return Results.Ok(person);
});
 
app.Run();
 
public record Person(string Name, int Age)
{
    public static async ValueTask<Person?> BindAsync(HttpContext httpContext, ParameterInfo parameter)
    {
        return await httpContext.BindFromForm<Person>();
    }
}

AddMvcCore is the important line here, it registers the model binder factory and metadata provider services that BindFromForm leans on in a moment. Without it, the extension method below would fail to resolve its dependencies from the service container at runtime.

The BindFromForm Extension Method

The actual binding logic wires up a ComplexObjectModelBinder against the incoming form data and runs it manually, outside the MVC pipeline.

using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
 
namespace FormBinding;
 
public static class BindingExtensions
{
    public static async Task<T?> BindFromForm<T>(this HttpContext httpContext)
    {
        var serviceProvider = httpContext.RequestServices;
        var factory = serviceProvider.GetRequiredService<IModelBinderFactory>();
        var metadataProvider = serviceProvider.GetRequiredService<IModelMetadataProvider>();
 
        var metadata = metadataProvider.GetMetadataForType(typeof(T));
        var modelBinder = factory.CreateBinder(new() {
            Metadata = metadata
        });
 
        var context = new DefaultModelBindingContext
        {
            ModelMetadata = metadata,
            ModelName = string.Empty,
            ValueProvider = new FormValueProvider(
                BindingSource.Form,
                httpContext.Request.Form,
                CultureInfo.InvariantCulture
            ),
            ActionContext = new ActionContext(
                httpContext,
                new RouteData(),
                new ActionDescriptor()),
            ModelState = new ModelStateDictionary()
        };
        await modelBinder.BindModelAsync(context);
        return (T?) context.Result.Model;
    }
}

Walking through what each piece is doing: IModelBinderFactory and IModelMetadataProvider are pulled straight from the request’s service provider, since AddMvcCore registered them earlier. GetMetadataForType builds the metadata MVC needs to know how to construct a Person, and CreateBinder uses that metadata to build the right binder for the type, which in most cases resolves to ComplexObjectModelBinder.

The FormValueProvider is the actual bridge to the incoming request, it wraps httpContext.Request.Form so the model binder can pull Name and Age out of the posted form fields by matching property names. DefaultModelBindingContext ties the metadata, value provider, and an ActionContext together into the shape BindModelAsync expects, even though there is no real MVC action being executed here, the ActionContext is just a container the binder needs to run.

Run this and the endpoint correctly returns a JSON representation of the Person, built entirely from form fields rather than a JSON request body.

https://localhost:7113/hi
 
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Kestrel
 
{
  "name": "Khalid",
  "age": 38
}

Where This Actually Helps, and Where It Does Not

This pattern is worth reaching for specifically when you are migrating existing MVC controllers to Minimal APIs and some of those controllers relied on binding sources other than the JSON body, form posts from HTML forms, query string parameters, or route values feeding into a complex type. Rebuilding that binding logic by hand for every endpoint is a lot of repeated, easy to get wrong code, whereas this approach reuses model binding MVC already got right.

The same FormValueProvider swap works for other MVC value providers too, QueryStringValueProvider, RouteValueProvider, FormFileValueProvider for file uploads, and JQueryFormValueProvider all plug into the same BindFromForm shape with a different value provider passed in.

Where this is overkill: if your Minimal API endpoints are consistently JSON in, JSON out, which is the common case and the scenario Minimal APIs were actually optimized for, you do not need any of this. Reach for it only when a specific endpoint genuinely needs a non-JSON binding source and rewriting that logic manually is more work than pulling in AddMvcCore for the binder services.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading