Simplify ASP.NET Core Endpoints with Minimal APIs in .NET 6+
Learn how to replace controller boilerplate with concise endpoint definitions using ASP.NET Core minimal APIs, see a Todo example, and understand the trade-offs.
04 Sept 2026, 16:58 UTC

Problem: Controller boilerplate
When building a simple CRUD API with ASP.NET Core, the traditional approach requires a controller class that inherits from ControllerBase, action methods decorated with HTTP verb attributes, and often repetitive code for model binding and validation. Even for a handful of endpoints, this adds noticeable ceremony.
Thesis: Minimal APIs cut the ceremony
Starting with .NET 6, ASP.NET Core minimal APIs let you define HTTP endpoints directly in Program.cs using lambda expressions or method groups. This removes the need for controller classes and attribute routing while still providing full access to routing, model binding, and results helpers.
Getting started
Create a new web API project targeting .NET 6 or later:
dotnet new webapi -net6.0 -n TodoMinimalApi cd TodoMinimalApiReplace the generated
Program.cswith the minimal API setup shown below. The example uses an in‑memory list for simplicity.Worked example: Todo API
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); // Add services needed for OpenAPI/Swagger builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // In‑memory store var todos = new List() { new TodoItem(1, "Learn minimal APIs") }; // GET /todos app.MapGet("/todos", () => Results.Ok(todos)) .WithName("GetTodos") .Produces>(StatusCodes.Status200OK); // GET /todos/{id} app.MapGet("/todos/{id:int}", (int id) => todos.FirstOrDefault(t => t.Id == id) is { } todo ? Results.Ok(todo) : Results.NotFound()) .WithName("GetTodoById") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // POST /todos app.MapPost("/todos", (TodoItem newTodo) => { if (newTodo == null || string.IsNullOrWhiteSpace(newTodo.Title)) return Results.BadRequest("Title is required"); var maxId = todos.Any() ? todos.Max(t => t.Id) : 0; var todo = new TodoItem(maxId + 1, newTodo.Title!); todos.Add(todo); return Results.CreatedAtRoute("GetTodoById", new { id = todo.Id }, todo); }) .WithName("CreateTodo") .Accepts("application/json") .Produces(StatusCodes.Status201Created) .ProducesValidationProblem(); // PUT /todos/{id} app.MapPut("/todos/{id:int}", (int id, TodoItem updated) => { var index = todos.FindIndex(t => t.Id == id); if (index < 0) return Results.NotFound(); if (updated == null || string.IsNullOrWhiteSpace(updated.Title)) return Results.BadRequest("Title is required"); var todo = todos[index] with { Title = updated.Title! }; todos[index] = todo; return Results.NoContent(); }) .WithName("UpdateTodo") .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesValidationProblem(StatusCodes.Status400BadRequest); // DELETE /todos/{id} app.MapDelete("/todos/{id:int}", (int id) => { var index = todos.FindIndex(t => t.Id == id); if (index < 0) return Results.NotFound(); todos.RemoveAt(index); return Results.NoContent(); }) .WithName("DeleteTodo") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); app.Run(); public record TodoItem(int Id, string Title);The example demonstrates:
- Model binding: parameters like
int idandTodoItemare bound automatically from route, query, or JSON body. - Validation: simple null/empty checks return
400 Bad Requestor404 Not Foundas appropriate. - Results helpers:
Results.Ok,Results.CreatedAtRoute,Results.NoContent, etc., produce the correct status codes and payloads.
Trade‑off: When to stay with controllers
Minimal APIs excel for straightforward endpoints, but they lack some controller‑only features:
- Filters: Global or scoped action filters (e.g., custom authorization, logging) are not directly applicable to lambda endpoints; you must apply them manually or refactor logic into a service.
- Complex model binding**: Scenarios requiring
[FromBody],[FromForm], or custom binders are more verbose. - Access to
HttpContextfeatures**: While you can injectHttpContext, controller base classes provide convenient properties likeUser,Request, andResponse. - Run the project:
dotnet run. - Navigate to the Swagger UI at
https://localhost:{port}/swagger(the port appears in the console output). - Use the generated UI or a tool like
curlto call each endpoint and confirm the expected status codes and payloads. - Check that invalid requests (e.g., POST with missing title) return a
400response with a validation problem.
If your API needs extensive filter pipelines, versioning, or sophisticated validation frameworks, a traditional controller may still be the clearer choice.
Actionable closing
To verify the example works:
If the endpoints behave as described, you have successfully replaced controller boilerplate with minimal APIs. For more complex needs, consider extracting business logic into separate services and keeping the endpoint definitions thin.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.