ASP.NET Core Minimal APIs with .NET 6: MapGet, MapPost and How They Compare to Controllers
MapGet, MapPost, MapPut and MapDelete with .NET 6 minimal APIs, parameter binding rules, Results helpers, filling in the missing validation by hand, grouping endpoints, and when to pick minimal APIs over controllers.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
A model you know from the previous post, this time behind an endpoint without a controller:
public class Product
{
public int Id { get; set; }
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Range(typeof(decimal), "0.01", "100000", ParseLimitsInInvariantCulture = true)]
public decimal Price { get; set; }
}
You send {"name":"","price":-5} to the MapPost("/api/products", ...) endpoint we are about to write. In a controller, this request would return 400 with two invalid fields. What does a minimal API return?
The First Minimal API
Minimal APIs, introduced in .NET 6, let you define an endpoint directly in Program.cs without a controller class. We have already seen Program.cs and the app object; now we add endpoints to it:
using ProductCatalog.Models;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
List<Product> products = new()
{
new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
new Product { Id = 2, Name = "Wireless Mouse", Price = 520m }
};
app.MapGet("/api/products", () => products);
app.MapGet("/api/products/{id:int}", (int id) =>
{
Product? product = products.FirstOrDefault(p => p.Id == id);
return product is null ? Results.NotFound() : Results.Ok(product);
})
.WithName("GetProductById");
app.Run();
No AddControllers, no MapControllers, no class. MapGet takes two things: a route template and a delegate that handles the request, the handler. The object the handler returns is serialized to JSON and written with 200. The route templates and the {id:int} constraint are exactly what we saw in the routing post; the only thing that changes is where the endpoint is defined.
If a controller is a restaurant kitchen, a minimal API is a food truck: you serve the same dish with less space and fewer staff. But as the menu grows, keeping that tight space organized is up to you.
Completing the CRUD
The write endpoints follow the same pattern:
app.MapPost("/api/products", (Product product) =>
{
product.Id = products.Max(p => p.Id) + 1;
products.Add(product);
return Results.CreatedAtRoute("GetProductById", new { id = product.Id }, product);
});
app.MapPut("/api/products/{id:int}", (int id, Product input) =>
{
Product? existing = products.FirstOrDefault(p => p.Id == id);
if (existing is null)
{
return Results.NotFound();
}
existing.Name = input.Name;
existing.Price = input.Price;
return Results.NoContent();
});
app.MapDelete("/api/products/{id:int}", (int id) =>
{
Product? existing = products.FirstOrDefault(p => p.Id == id);
if (existing is null)
{
return Results.NotFound();
}
products.Remove(existing);
return Results.NoContent();
});
CreatedAtRoute builds the Location header using the name we gave with WithName; it is the minimal API counterpart of CreatedAtAction in a controller. Let's send a POST and a GET for a product that does not exist:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/products/3
{"id":3,"name":"USB-C Hub","price":890}
HTTP/1.1 404 Not Found
Content-Length: 0
Look at the second response. In a controller, NotFound() produced a ProblemDetails body; here the body is completely empty. It was [ApiController] that did that conversion, and minimal APIs have no such layer. If you want an error body, you call Results.Problem() yourself.
Parameter Binding
In a minimal API, when you write no attributes, the rules work like this: a parameter whose name appears in the route template comes from the route, simple types come from the query string, and complex types come from the body. A type registered in the container comes as a service, and special types like HttpContext and CancellationToken come straight from the framework. When needed, you can use the [FromRoute], [FromQuery], [FromHeader], [FromBody] and [FromServices] attributes you know from controllers here as well:
app.MapGet("/api/info", (
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
IWebHostEnvironment environment,
HttpContext context) =>
{
return Results.Ok(new
{
correlationId,
environment = environment.EnvironmentName,
path = context.Request.Path.Value
});
});
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"correlationId":"abc-123","environment":"Production","path":"/api/info"}
There is no [FromServices] in front of IWebHostEnvironment, yet the value arrived. In the model binding post we saw this produce a 415 in .NET 6 controllers; minimal APIs have looked at the container from day one. For [FromHeader] you need to add using Microsoft.AspNetCore.Mvc; at the top of the file.
.NET 6 has one gap here: filling a class from the query string as in a controller, that is [FromQuery] ProductQuery query, is not supported. .NET 7 covered this need with [AsParameters]; we will see it in the version posts later on.
Results and Return Values
There are three behaviors depending on what the handler returns. If it returns an IResult, meaning something like Results.Ok, Results.NotFound, Results.CreatedAtRoute, Results.NoContent, Results.Problem or Results.StatusCode, that object writes the response. If it returns a string, it is written as text/plain. If it returns any other object, it is serialized to JSON and written with 200.
app.MapGet("/api/hello", () => "Hello from minimal API");
app.MapGet("/api/products/{id:int}/problem", (int id) =>
Results.Problem(
title: "Product not found",
detail: $"There is no product with id {id}.",
statusCode: StatusCodes.Status404NotFound));
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Hello from minimal API
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Product not found","status":404,"detail":"There is no product with id 99."}
Results.Problem produces the same schema, but the traceId from the controller side is missing here. In .NET 6 every type returned by Results is just IResult, which hides from the outside which type an endpoint returns. TypedResults, which arrived with .NET 7, solves that; we will cover it in the version posts.
No Validation: The Answer to the Guess
Here is the real response to the {"name":"","price":-5} request:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/products/4
{"id":4,"name":"","price":-5}
A nameless product with a negative price made it into the list. In .NET 6, minimal APIs never run Data Annotations. The attributes sit on the class, but nobody looks at them. You have to do the check yourself:
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Endpoints;
public static class ModelValidator
{
public static Dictionary<string, string[]> Validate(object model)
{
List<ValidationResult> results = new();
Validator.TryValidateObject(model, new ValidationContext(model), results, validateAllProperties: true);
return results
.SelectMany(r => r.MemberNames.Select(member => (Member: member, Message: r.ErrorMessage ?? "Invalid value.")))
.GroupBy(x => x.Member)
.ToDictionary(g => g.Key, g => g.Select(x => x.Message).ToArray());
}
}
app.MapPost("/api/v2/products", (Product product) =>
{
Dictionary<string, string[]> errors = ModelValidator.Validate(product);
if (errors.Count > 0)
{
return Results.ValidationProblem(errors);
}
product.Id = products.Max(p => p.Id) + 1;
products.Add(product);
return Results.CreatedAtRoute("GetProductById", new { id = product.Id }, product);
});
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 100000."]}}
Validator.TryValidateObject is the very Data Annotations engine that MVC uses behind the scenes. Without validateAllProperties: true only [Required] is checked, so don't forget it. You can take the FluentValidation validators from the previous post as handler parameters and call them the same way. Instead of repeating this check in every endpoint, a layer in between arrived with endpoint filters in .NET 7, and the framework's own validation support arrived with AddValidation() in .NET 10. We will see both in the version posts.
Keeping Program.cs Lean: Grouping with Extension Methods
If you write every endpoint in Program.cs, the file grows to hundreds of lines within weeks. In .NET 6 the cleanest way is to gather related endpoints into an extension method:
namespace ProductCatalog.Endpoints;
public static class CategoryEndpoints
{
private static readonly List<string> Categories = new() { "peripherals", "books", "office" };
public static IEndpointRouteBuilder MapCategoryEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/categories", GetAll);
app.MapGet("/api/categories/{name}", GetByName);
return app;
}
private static IResult GetAll()
{
return Results.Ok(Categories);
}
private static IResult GetByName(string name)
{
string? category = Categories.FirstOrDefault(c => c == name);
return category is null ? Results.NotFound() : Results.Ok(category);
}
}
A single line is left in Program.cs: app.MapCategoryEndpoints();. The handlers are no longer lambdas but named static methods. That way you see what is going on in a stack trace, and you can test them one by one. MapGroup, which lets you define a shared route prefix in one place, arrived in .NET 7.
Controllers or Minimal APIs?
Two options, two different weights. Controllers bring structure: the automatic 400 and ProblemDetails of [ApiController], the filter pipeline, query binding into a class, a folder layout everyone recognizes. The price is a bit of ceremony and a controller instance created per request. They feel comfortable in large teams and in projects with many endpoints that want to leave the rules to the framework.
Minimal APIs start with little code, make you write everything explicitly and are a bit lighter. They are ideal for small services, microservices with a handful of endpoints and quick prototypes. The price is that in .NET 6 you build the order yourself for things like validation, a shared error format and grouping. Later versions closed these gaps quickly; starting with .NET 8, the dotnet new webapi template generates a minimal API by default.
In the core of this series we continue with controllers, because most .NET 6 codebases are written that way and most topics are richer on the controller side. We will follow minimal APIs as they mature, step by step, in the version posts.
Common Pitfalls
The most surprising one is a non-nullable query parameter. Send a request with ?name=mouse to this endpoint and you get a 400:
app.MapGet("/api/products/search", (string? name, decimal? minPrice, int page) =>
{
IEnumerable<Product> result = products;
if (!string.IsNullOrWhiteSpace(name))
{
result = result.Where(p => p.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
}
if (minPrice is not null)
{
result = result.Where(p => p.Price >= minPrice);
}
return result.Skip((Math.Max(page, 1) - 1) * 10).Take(10);
});
In a controller, a missing int page would silently become 0. A minimal API, however, treats every non-nullable parameter as required. In the Development environment the response body tells you why: Required parameter "int page" was not provided from query string. In Production the body is empty and nothing shows up at the default log level. The first fix that comes to mind is writing int page = 1, but C# 10 does not allow default values in lambdas: error CS8936: Feature 'lambda optional parameters' is not available in C# 10.0. Please use language version 12.0 or greater. In .NET 6 the fix is to use int? page, or to move the handler into a named method and give the default value there.
Second, a handler that returns null. A controller returned 204, while a minimal API writes 200 OK with null in the body. For a resource that is not found, always return Results.NotFound().
Third, broken JSON. When "price":"abc" is sent, Production returns a 400 with no body. The client cannot learn what went wrong, so agree on an error format with the team writing the client from the start.
Fourth, the products list in Program.cs. Because the lambdas capture it, it lives as long as the application, so in practice it is a singleton. Like the static list in the controller post it is not thread-safe; it will do until we move to a real data layer.
A Small Experiment
★☆☆ Change int page in the /api/products/search endpoint to int? page and confirm that the ?name=mouse request now returns 200.
★★☆ Move the product endpoints into a static class called ProductEndpoints so that only the line app.MapProductEndpoints(); is left in Program.cs. Move the list into that class as well.
★★★ Write the same GET /api/products/{id} endpoint both in a controller and as a minimal API (on different routes). Compare the two responses for an id that does not exist, then make the minimal API side return a ProblemDetails body with type, title and status fields as well.
In this post we asked for IWebHostEnvironment as a parameter, and the framework found it somewhere and brought it in. In the previous post we received IValidator<T> the same way. Where do these objects live, who creates them, and how long do they stay alive? In the next post we talk about dependency injection, service lifetimes and one of its sneakiest traps, the captive dependency.