Cutting Boilerplate with .NET Core Minimal APIs: A Practical Guide
Minimal APIs in .NET 6 let you write lightweight microservices without controllers, cutting boilerplate by ~70%. This guide walks through a practical example, explains key trade‑offs, and shows how to verify startup and memory gains.
31 Jan 2026, 08:04 UTC

What problem are we solving?
In a typical ASP.NET Core MVC project you spend a lot of time wiring up Controllers, action methods, and routing attributes. A single “Hello World” endpoint can require dozens of lines of code, plus the overhead of a compiled assembly that contains the entire MVC framework. For lightweight microservices that only expose a handful of endpoints, that verbosity is wasteful and can slow startup, increase memory usage, and make the code harder to read.
Minimal APIs to the rescue
Starting with .NET 6, ASP.NET Core introduced Minimal APIs, a feature that lets you write HTTP endpoints directly in Program.cs without controllers or action results. The framework still provides the full middleware pipeline, dependency injection, authentication, and authorization, but the surface area is dramatically smaller.
Key benefits at a glance
- ~70 % less boilerplate compared to MVC.
- Single‑file startup that compiles to one assembly.
- Built‑in DI resolves scoped services per request.
- Full compatibility with existing middleware and services.
- Available in .NET 6 and newer; upgrade from .NET 5 requires no code changes beyond replacing MVC scaffolding.
A concrete example: a “Book” microservice
Below is a minimal, self‑contained microservice that manages a small in‑memory collection of books. The example demonstrates routing, DI, validation, and a basic authentication guard.
// Program.cs – .NET 6 Minimal API
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
// Register a scoped repository – one instance per HTTP request
builder.Services.AddScoped();
// Add authentication/authorization middleware (placeholder for real scheme)
builder.Services.AddAuthentication("Basic")
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("Basic", null);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// -- Endpoints ------------------------------------------------------------
app.MapGet("/books", async (IBookRepository repo) =>
await repo.GetAllAsync());
app.MapPost("/books", async (HttpRequest request, IBookRepository repo) =>
{
var book = await request.ReadFromJsonAsync<Book>();
if (book == null || string.IsNullOrWhiteSpace(book.Title))
{
return Results.BadRequest("Title is required");
}
await repo.AddAsync(book);
return Results.Created($"/books/{book.Id}", book);
}).RequireAuthorization();
app.MapGet("/books/{id:int}", async (int id, IBookRepository repo) =>
await repo.GetByIdAsync(id) is Book book ? Results.Ok(book) : Results.NotFound());
app.MapPut("/books/{id:int}", async (int id, HttpRequest request, IBookRepository repo) =>
{
var book = await request.ReadFromJsonAsync<Book>();
if (book == null) return Results.BadRequest();
var exists = await repo.UpdateAsync(id, book);
return exists ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization();
app.MapDelete("/books/{id:int}", async (int id, IBookRepository repo) =>
await repo.DeleteAsync(id) ? Results.NoContent() : Results.NotFound());
app.Run();
// -------------------------------------------------------------------------
// Domain and repository implementation – simple in‑memory store
public record Book(int Id, string Title, string Author);
public interface IBookRepository
{
Task GetAllAsync();
Task<Book> GetByIdAsync(int id);
Task AddAsync(Book book);
Task<bool> UpdateAsync(int id, Book book);
Task<bool> DeleteAsync(int id);
}
public class InMemoryBookRepository : IBookRepository
{
private readonly ConcurrentDictionary<int, Book> _store = new();
private int _nextId = 1;
public Task GetAllAsync() => Task.FromResult(_store.Values.AsEnumerable());
public Task<Book> GetByIdAsync(int id) => Task.FromResult(_store.TryGetValue(id, out var book) ? book : null);
public Task AddAsync(Book book)
{
book = book with { Id = _nextId++ };
_store[book.Id] = book;
return Task.CompletedTask;
}
public Task<bool> UpdateAsync(int id, Book book)
{
if (!_store.ContainsKey(id)) return Task.FromResult(false);
_store[id] = book with { Id = id };
return Task.FromResult(true);
}
public Task<bool> DeleteAsync(int id)
{
return Task.FromResult(_store.TryRemove(id, out _));
}
}
// -------------------------------------------------------------------------
// Basic authentication handler – stub for illustration
public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public BasicAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// In a real app validate the Authorization header.
var ticket = new AuthenticationTicket(new System.Security.Claims.ClaimsPrincipal(), "Basic");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
What changed under the hood?
When you run dotnet run on this project, the compiler produces a single assembly named Program.dll. Compared to a classic MVC template, the output size drops by roughly 30–50 % and the process startup time shortens by 20–30 ms on a typical laptop. The difference comes from:
- Eliminating the MVC framework assembly.
- Inlining endpoint routing logic into the generated assembly.
- Reducing the number of types that the CLR must load at startup.
Trade‑offs and limitations
- Model validation – MVC automatically runs data‑annotation validators and returns
400 Bad Requestwhen a model fails validation. Minimal APIs do not perform this step; you must manually checkModelState.IsValidor use a library such asFluentValidation. - Scalability of large APIs – If your service grows to dozens of endpoints, a single
Program.cscan become unwieldy. Split into partial classes or separate files using thepartialkeyword orbuilder.MapGroupto keep the codebase maintainable. - Tooling support – Some IDE features (e.g., attribute‑based navigation, scaffolding) are less mature for Minimal APIs than for MVC controllers.
- Version requirement – Minimal APIs are available only in .NET 6+. Projects on .NET 5 or earlier cannot use this feature without a major upgrade.
How to verify the performance gains
- Create a new project:
dotnet new web -n MinimalApiDemo. - Replace the generated
Program.cswith the example above. - Run:
dotnet runand note the startup time. - Build the project:
dotnet publish -c Release -o out. - Check the size of
out/MinimalApiDemo.dlland compare it to the size of a similar MVC project’s DLL. - Optional: use
dotnet-trace collectto capture a trace and analyze theRuntimeInitializeevent for startup latency.
Actionable take‑away
If you’re building a new microservice that only needs a handful of endpoints, start with a Minimal API template:
dotnet new web -n MyMicroservice
cd MyMicroservice
# Add your endpoints in Program.cs as shown above
For services that will grow or require complex validation, consider a hybrid approach: keep Minimal APIs for the public surface and delegate heavy logic to traditional services or background workers. This keeps the API layer lightweight while still enabling the full power of ASP.NET Core.
In summary, Minimal APIs cut boilerplate, reduce memory footprint, and simplify the startup code for small services. Evaluate the trade‑offs—particularly around validation and maintainability—before adopting them in larger, more complex projects.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.