DEV Community

Cover image for Minimal APIs in ASP.NET Core: Lightweight APIs Without the Boilerplate
Rhuturaj Takle
Rhuturaj Takle

Posted on

Minimal APIs in ASP.NET Core: Lightweight APIs Without the Boilerplate

Minimal APIs in ASP.NET Core: Lightweight APIs Without the Boilerplate

A practical, in-depth guide to Minimal APIs — the lightweight way to build HTTP APIs in ASP.NET Core, ideal for microservices, small backend services, and high-throughput endpoints.


Table of Contents

  1. Introduction
  2. The Basics
  3. Routing and Route Parameters
  4. Parameter Binding
  5. Request Validation
  6. Typed Results
  7. Route Groups and Organization
  8. Dependency Injection in Minimal APIs
  9. Filters
  10. OpenAPI / Swagger Integration
  11. Minimal APIs vs. Controllers
  12. Performance Characteristics
  13. Quick Reference Table
  14. Conclusion

Introduction

Minimal APIs, introduced in .NET 6, are a way to define HTTP endpoints directly in Program.cs (or nearby files) without controllers, attributes, or the MVC conventions that come with them. They were designed for a specific gap: not every service needs full MVC — a small microservice, a health-check endpoint, or a focused internal API often just needs a handful of routes wired directly to logic.

The pitch is simple: fewer files, less ceremony, faster startup — while still supporting the things real APIs need: validation, DI, authorization, OpenAPI docs, and strongly-typed responses.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello, World!");

app.Run();
Enter fullscreen mode Exit fullscreen mode

That's a complete, runnable web API. No controller class, no attributes, no separate Startup.cs.


1. The Basics

The four core HTTP verbs

var app = builder.Build();

app.MapGet("/products", () => products);
app.MapPost("/products", (Product product) => { products.Add(product); return Results.Created($"/products/{product.Id}", product); });
app.MapPut("/products/{id}", (int id, Product updated) => { /* update logic */ });
app.MapDelete("/products/{id}", (int id) => { /* delete logic */ });

app.Run();
Enter fullscreen mode Exit fullscreen mode

Each Map* method takes a route pattern and a delegate (lambda or method group). The delegate's parameters are automatically bound from the route, query string, request body, headers, or DI container — ASP.NET Core infers the source based on the parameter's type and position.

Returning results

You can return plain strings, POCOs (serialized to JSON automatically), or explicit IResult values via the Results static class:

app.MapGet("/status", () => "OK");                          // 200, text/plain
app.MapGet("/config", () => new { Version = "1.0" });        // 200, JSON
app.MapGet("/secret", () => Results.Unauthorized());         // 401
app.MapGet("/report/{id}", (int id) =>
    id > 0 ? Results.Ok(GetReport(id)) : Results.BadRequest("Invalid id"));
Enter fullscreen mode Exit fullscreen mode

2. Routing and Route Parameters

Route constraints

app.MapGet("/products/{id:int}", (int id) => GetProduct(id));
app.MapGet("/orders/{orderId:guid}", (Guid orderId) => GetOrder(orderId));
app.MapGet("/archive/{year:int:min(2000)}/{month:int:range(1,12)}", (int year, int month) => GetArchive(year, month));
Enter fullscreen mode Exit fullscreen mode

Constraints (:int, :guid, :min(), :range(), :alpha, :regex(), etc.) let invalid requests fail at the routing layer with a 404, before your handler even runs.

Optional parameters and defaults

app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
{
    return Search(query, page, pageSize);
});
Enter fullscreen mode Exit fullscreen mode

A request to /search?query=laptop works, filling in page and pageSize from their C# default values if omitted from the query string.

Wildcard / catch-all routes

app.MapGet("/files/{*path}", (string path) => ServeFile(path));
Enter fullscreen mode Exit fullscreen mode

3. Parameter Binding

Minimal APIs infer where a parameter comes from based on type and convention, but you can always be explicit.

Source How it's inferred Explicit attribute
Route values Parameter name matches a { } segment [FromRoute]
Query string Simple type (string, int, bool, etc.) not matching a route segment [FromQuery]
Request body Complex type (class/record), only one per endpoint [FromBody]
Headers [FromHeader]
DI container Type is a registered service [FromServices]
Form data IFormCollection, IFormFile [FromForm]
app.MapPost("/upload", async (IFormFile file, [FromServices] IStorageService storage) =>
{
    await storage.SaveAsync(file);
    return Results.Ok();
});
Enter fullscreen mode Exit fullscreen mode

Binding from JSON body

public record CreateProductRequest(string Name, decimal Price, string Category);

app.MapPost("/products", (CreateProductRequest request, IProductRepository repo) =>
{
    var product = repo.Add(request.Name, request.Price, request.Category);
    return Results.Created($"/products/{product.Id}", product);
});
Enter fullscreen mode Exit fullscreen mode

Custom binding with BindAsync (.NET 7+)

For types that need custom parsing logic (e.g., from a cookie or a composite query format), implement a static BindAsync method:

public record struct SortOptions(string Field, bool Descending)
{
    public static ValueTask<SortOptions?> BindAsync(HttpContext context, ParameterInfo parameter)
    {
        var value = context.Request.Query["sort"].ToString();
        if (string.IsNullOrEmpty(value)) return ValueTask.FromResult<SortOptions?>(null);

        var descending = value.StartsWith('-');
        var field = descending ? value[1..] : value;
        return ValueTask.FromResult<SortOptions?>(new SortOptions(field, descending));
    }
}

app.MapGet("/products", (SortOptions? sort) => GetSortedProducts(sort));
Enter fullscreen mode Exit fullscreen mode

4. Request Validation

Minimal APIs don't include the automatic model-state validation that [ApiController] provides for MVC, so validation is typically explicit — which keeps behavior predictable but means you opt in.

Manual validation

app.MapPost("/products", (CreateProductRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.Name))
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["Name"] = ["Name is required."]
        });

    if (request.Price <= 0)
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["Price"] = ["Price must be greater than zero."]
        });

    // ... create product
    return Results.Created();
});
Enter fullscreen mode Exit fullscreen mode

DataAnnotations + a validation filter (.NET 8+ approach)

public record CreateProductRequest(
    [property: Required, MinLength(3)] string Name,
    [property: Range(0.01, double.MaxValue)] decimal Price);

app.MapPost("/products", (CreateProductRequest request) => Results.Created())
   .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Enter fullscreen mode Exit fullscreen mode

Where ValidationFilter<T> is a small reusable endpoint filter that runs Validator.TryValidateObject before the handler executes — this pattern lets you centralize validation logic instead of repeating if checks in every handler. (.NET 10 also introduces built-in support for validating annotated types automatically in minimal APIs, reducing the need to hand-write this filter.)

FluentValidation (popular third-party option)

public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MinimumLength(3);
        RuleFor(x => x.Price).GreaterThan(0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Many teams pair minimal APIs with FluentValidation via an endpoint filter, since it scales better than DataAnnotations for complex, cross-field rules.


5. Typed Results

Untyped IResult return values don't tell the OpenAPI generator (or callers) what status codes and shapes to expect. TypedResults (added in .NET 7) fixes this.

app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, IProductRepository repo) =>
{
    var product = repo.GetById(id);
    return product is not null
        ? TypedResults.Ok(product)
        : TypedResults.NotFound();
});
Enter fullscreen mode Exit fullscreen mode

Benefits of TypedResults over Results:

  • Accurate OpenAPI/Swagger docs — the possible response types and status codes are visible to the type system, not just at runtime.
  • Compile-time safety — you can't accidentally return a type that isn't declared in the method signature.
  • Easier unit testing — you can assert on the concrete result type without spinning up an HTTP pipeline.
[Fact]
public void GetById_ReturnsNotFound_WhenMissing()
{
    var result = Endpoint.GetProduct(999, new FakeRepository());
    Assert.IsType<NotFound>(result.Result);
}
Enter fullscreen mode Exit fullscreen mode

6. Route Groups and Organization

As an API grows past a handful of endpoints, Program.cs can get crowded. Route groups (.NET 7+) and extension methods keep things tidy without reaching for full MVC.

Grouping related endpoints

var products = app.MapGroup("/products").WithTags("Products");

products.MapGet("/", GetAllProducts);
products.MapGet("/{id:int}", GetProductById);
products.MapPost("/", CreateProduct).RequireAuthorization();
products.MapPut("/{id:int}", UpdateProduct).RequireAuthorization();
products.MapDelete("/{id:int}", DeleteProduct).RequireAuthorization("AdminOnly");
Enter fullscreen mode Exit fullscreen mode

Anything applied to the group (auth, filters, tags, rate limits) cascades to every endpoint inside it.

Extracting endpoints into their own files

// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group)
    {
        group.MapGet("/", GetAllProducts);
        group.MapGet("/{id:int}", GetProductById);
        group.MapPost("/", CreateProduct);
        return group;
    }

    private static async Task<Ok<List<Product>>> GetAllProducts(IProductRepository repo) =>
        TypedResults.Ok(await repo.GetAllAsync());

    private static async Task<Results<Ok<Product>, NotFound>> GetProductById(int id, IProductRepository repo)
    {
        var product = await repo.GetByIdAsync(id);
        return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound();
    }

    private static async Task<Created<Product>> CreateProduct(CreateProductRequest request, IProductRepository repo)
    {
        var product = await repo.AddAsync(request);
        return TypedResults.Created($"/products/{product.Id}", product);
    }
}
Enter fullscreen mode Exit fullscreen mode
// Program.cs
app.MapGroup("/products").MapProductEndpoints();
Enter fullscreen mode Exit fullscreen mode

This pattern — extension methods per feature area — is the community-standard way to keep minimal APIs organized without MVC's folder/attribute conventions, and it scales to dozens of endpoint groups cleanly.


7. Dependency Injection in Minimal APIs

Minimal API handlers get services the same way controllers do — via the DI container — just injected as method parameters instead of constructor parameters.

builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();

app.MapGet("/products", (IProductRepository repo) => repo.GetAll());
Enter fullscreen mode Exit fullscreen mode

Keyed services (.NET 8+)

app.MapPost("/notify", ([FromKeyedServices("sms")] INotifier notifier, string message) =>
{
    notifier.Send(message);
    return Results.Ok();
});
Enter fullscreen mode Exit fullscreen mode

8. Filters

Endpoint filters (.NET 7+) are minimal APIs' answer to MVC's action filters — a way to run logic before/after a handler without duplicating it across endpoints.

public class LoggingFilter : IEndpointFilter
{
    private readonly ILogger<LoggingFilter> _logger;
    public LoggingFilter(ILogger<LoggingFilter> logger) => _logger = logger;

    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        _logger.LogInformation("Executing {Endpoint}", context.HttpContext.Request.Path);
        var result = await next(context);
        _logger.LogInformation("Executed {Endpoint} -> {Result}", context.HttpContext.Request.Path, result);
        return result;
    }
}

app.MapPost("/products", CreateProduct).AddEndpointFilter<LoggingFilter>();
Enter fullscreen mode Exit fullscreen mode

Inline filters

app.MapPost("/products", CreateProduct)
   .AddEndpointFilter(async (context, next) =>
   {
       var request = context.GetArgument<CreateProductRequest>(0);
       if (request.Price <= 0)
           return Results.BadRequest("Price must be positive.");

       return await next(context);
   });
Enter fullscreen mode Exit fullscreen mode

Filters compose in the order they're added, and can short-circuit the pipeline (skip the handler entirely) — useful for validation, logging, and cross-cutting checks like feature flags.


9. OpenAPI / Swagger Integration

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
Enter fullscreen mode Exit fullscreen mode

Enriching endpoint metadata

app.MapGet("/products/{id}", GetProductById)
   .WithName("GetProductById")
   .WithSummary("Retrieves a single product by its ID")
   .WithTags("Products")
   .Produces<Product>(StatusCodes.Status200OK)
   .Produces(StatusCodes.Status404NotFound);
Enter fullscreen mode Exit fullscreen mode

Using TypedResults (Section 5) instead of Results means much of this metadata — the possible status codes and response shapes — is inferred automatically, reducing how much you need to declare by hand. .NET 9 also ships a built-in OpenAPI document generator (Microsoft.AspNetCore.OpenApi) as a lighter-weight alternative to Swashbuckle for teams that only need the raw OpenAPI JSON/YAML output.


10. Minimal APIs vs. Controllers

Aspect Minimal APIs MVC Controllers
File structure Delegates in Program.cs or extension methods Controller classes with attributes
Boilerplate Very low Moderate — base class, attributes, routing conventions
Validation Explicit (manual, filters, or FluentValidation) Automatic via [ApiController] model-state validation
Startup performance Faster — less reflection-based discovery Slightly slower, more metadata to build
Best fit Microservices, small APIs, high-throughput endpoints, serverless functions Larger apps, teams wanting strong conventions, apps mixing API + server-rendered views
View rendering Not supported directly (API-only) Full support via Razor views
Native AOT compatibility Excellent (designed with AOT in mind) Limited — MVC relies heavily on reflection
Learning curve Lower for simple cases Steeper, but scales better with strict team conventions

A practical rule of thumb

  • Small service, a handful of endpoints, containerized, or targeting Native AOT? → Minimal APIs.
  • Large application with dozens of resources, complex validation rules, or mixed API+UI needs? → Controllers (optionally alongside minimal APIs for a few lightweight endpoints — the two can coexist in the same app).

11. Performance Characteristics

Minimal APIs were explicitly designed to reduce the overhead between "request arrives" and "your code runs":

  • Less reflection at startup — routing metadata for minimal APIs is built more directly than the attribute-scanning MVC relies on, which shows up as measurably faster app startup — a meaningful win for serverless functions and container cold starts.
  • No MVC filter pipeline overhead — minimal APIs use the lighter endpoint-filter pipeline described above rather than MVC's larger action/result filter pipeline.
  • First-class Native AOT support — because minimal APIs avoid runtime reflection-heavy model binding by default, they compile and run well under Native AOT, where startup time and memory footprint drop dramatically:
dotnet publish -r linux-x64 -c Release /p:PublishAot=true
Enter fullscreen mode Exit fullscreen mode
  • Source-generated JSON serialization — pairing minimal APIs with System.Text.Json source generators ([JsonSerializable] context classes) avoids reflection-based serialization entirely, which matters most in Native AOT scenarios where reflection-based serialization isn't available at all.
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
internal partial class AppJsonContext : JsonSerializerContext { }

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
Enter fullscreen mode Exit fullscreen mode

Quick Reference Table

Feature Introduced Purpose
MapGet/MapPost/etc. .NET 6 Define routes without controllers
Automatic parameter binding .NET 6 Infer route/query/body sources from method signature
Route groups (MapGroup) .NET 7 Share config across related endpoints
Typed results (TypedResults) .NET 7 Strongly-typed, OpenAPI-friendly responses
Endpoint filters .NET 7 Cross-cutting logic (validation, logging) without duplication
BindAsync custom binding .NET 7 Custom parameter parsing logic
Keyed DI services .NET 8 Multiple implementations of one interface
Built-in Microsoft.AspNetCore.OpenApi .NET 9 Lightweight OpenAPI document generation
JSON source generation .NET 6+, essential for AOT Reflection-free serialization
Native AOT support .NET 8 Fast cold starts, low memory footprint

Conclusion

Minimal APIs aren't a "lesser" version of MVC — they're a different default, optimized for the reality that a large share of modern backend services are small, focused, and deployed as independently scaled units (microservices, serverless functions, sidecars). By stripping away controllers, attributes, and MVC's filter pipeline, they cut the distance between a route definition and the code that handles it — while still supporting the things production APIs need: validation via filters, strongly-typed responses, DI, and OpenAPI docs.

The right choice isn't binary, either — minimal APIs and controllers coexist in the same ASP.NET Core app, so many teams use minimal APIs for lightweight, high-traffic endpoints and controllers for larger, more conventions-heavy parts of the same system.


Found this useful? Feel free to star the repo, open an issue with corrections, or share how you've organized minimal APIs in a larger codebase.

Top comments (0)