Cut Boilerplate in Half: Building a Tiny .NET Core API with Minimal APIs
Minimal APIs let you expose a full REST service in under 50 lines, cutting boilerplate while keeping ASP.NET Core’s routing, DI, and Swagger. This guide shows a working TODO example, trade‑offs, and when to use them.
27 Apr 2026, 13:49 UTC

The Problem: Too Much Boilerplate for Simple Endpoints
When you start an ASP.NET Core project with the classic MVC template, you end up with a folder full of controller classes, view models, and a lot of attribute decoration just to expose a handful of CRUD routes. Even a simple “TODO” service can require 200‑plus lines of repetitive code. For small services, prototypes, or micro‑services, that overhead feels like a waste of time.
Thesis: Minimal APIs Slice the Boilerplate in Half
ASP.NET Core Minimal APIs let you define routes, handlers, and model binding inside a single lambda or method. The same routing, middleware, and dependency‑injection pipeline that powers MVC is reused, so authentication, logging, EF Core, and Swagger still work out of the box. The result is a single‑file web app that can expose a full REST API in under 50 lines of code.
How Minimal APIs Work Under the Hood
- Routing – The
IEndpointRouteBuilderregisters a delegate that receives anHttpContextand any bound parameters. - Dependency Injection – Services added to
builder.Servicescan be injected directly into the lambda. - Model Binding – JSON request bodies are automatically deserialized into C# types when the route parameter type is a class.
- OpenAPI – Calling
app.MapGet(...).WithName(...).Produces(...)automatically populates Swagger documentation.
Worked Example: A Tiny TODO API in 45 Lines
The following code demonstrates a complete API that supports GET, POST, PUT, and DELETE on /todos. It uses EF Core InMemory for storage, so no external database is required.
// Program.cs – run with `dotnet run` from the project root
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<TodoContext>(opt => opt.UseInMemoryDatabase("TodoDb"));
builder.Services.AddScoped<ITodoRepository, TodoRepository>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// GET /todos
app.MapGet("/todos", async (ITodoRepository repo) =>
await repo.GetAllAsync())
.WithName("GetAllTodos")
.Produces<List<Todo>>(StatusCodes.Status200OK);
// POST /todos
app.MapPost("/todos", async (Todo item, ITodoRepository repo) =>
{
await repo.AddAsync(item);
return Results.Created($"/todos/{item.Id}", item);
})
.WithName("CreateTodo")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created);
// PUT /todos/{id}
app.MapPut("/todos/{id}", async (int id, Todo item, ITodoRepository repo) =>
{
if (id != item.Id) return Results.BadRequest();
var updated = await repo.UpdateAsync(item);
return updated ? Results.NoContent() : Results.NotFound();
})
.WithName("UpdateTodo")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// DELETE /todos/{id}
app.MapDelete("/todos/{id}", async (int id, ITodoRepository repo) =>
{
var deleted = await repo.DeleteAsync(id);
return deleted ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteTodo")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
app.Run();
// Supporting types
public record Todo(int Id, string Title, bool IsComplete = false);
public interface ITodoRepository
{
Task<List<Todo>> GetAllAsync();
Task AddAsync(Todo item);
Task<bool> UpdateAsync(Todo item);
Task<bool> DeleteAsync(int id);
}
public class TodoRepository : ITodoRepository
{
private readonly TodoContext _ctx;
public TodoRepository(TodoContext ctx) => _ctx = ctx;
public Task<List<Todo>> GetAllAsync() => _ctx.Todos.ToListAsync();
public Task AddAsync(Todo item) => _ctx.Todos.AddAsync(item).AsTask();
public async Task<bool> UpdateAsync(Todo item)
{
var existing = await _ctx.Todos.FindAsync(item.Id);
if (existing == null) return false;
existing.Title = item.Title;
existing.IsComplete = item.IsComplete;
await _ctx.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteAsync(int id)
{
var todo = await _ctx.Todos.FindAsync(id);
if (todo == null) return false;
_ctx.Todos.Remove(todo);
await _ctx.SaveChangesAsync();
return true;
}
}
public class TodoContext : DbContext
{
public TodoContext(DbContextOptions<TodoContext> options) : base(options) {}
public DbSet<Todo> Todos => Set<Todo>();
}
To run this demo:
- Create a new project:
dotnet new web -n MinimalApiDemo - Add the EF Core InMemory package:
dotnet add package Microsoft.EntityFrameworkCore.InMemory - Replace the generated
Program.cswith the code above. - Start the app:
dotnet run(requires at least .NET 6). - Test the endpoints with
curlor Postman. For example,curl http://localhost:5000/todosshould return an empty array.
Trade‑offs and Limitations
- No automatic model‑state validation – Controllers automatically return 400 when
ModelState.IsValidfails. With minimal APIs you must add manual checks or write a custom filter. - Flat organization – All routes live in a single file; for large applications this can become hard to navigate. Route groups (
app.MapGroup) help but don’t replace controller hierarchies. - Advanced features – OData, SignalR, or complex content negotiation are not part of the minimal API surface; you’ll need extra packages or to revert to controllers.
- Testing – Unit‑testing lambdas is less straightforward than controller actions, though you can still inject services and call the handlers directly.
When to Use Minimal APIs
- Micro‑services or small utilities where a full MVC stack feels overkill.
- Prototypes or proof‑of‑concepts that need to spin up quickly.
- Serverless functions (Azure Functions, AWS Lambda) that map to a single endpoint.
- Legacy systems that want to modernize without refactoring all controllers.
Actionable Checklist
- Start with
dotnet new webfor a minimal API project. - Add
AddEndpointsApiExplorerandAddSwaggerGento get automatic API docs. - Use
app.MapGet/MapPost/MapPut/MapDeletefor CRUD routes; chainProducesandAcceptsfor accurate OpenAPI metadata. - Inject services via constructor parameters in the lambda; register them in
builder.Services. - For validation, write a small helper method or use
FluentValidationwith a custom filter. - Run
dotnet testto assert that your handlers behave correctly; you can call the lambda directly if you expose it as a public method. - Publish with
dotnet publish -c Release -r win-x64 --self-containedand compare the output size to a traditional MVC project; you’ll typically see a 30–50% reduction.
Minimal APIs are a powerful tool for reducing boilerplate without sacrificing the robust ASP.NET Core ecosystem. Try them on a small project today and see how quickly you can go from idea to working endpoint.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.