Back to Blog
6 min read

Minimal APIs in ASP.NET Core 6: Building Lightweight HTTP Services

ASP.NET Core 6 introduces Minimal APIs, a new way to build HTTP APIs with minimal ceremony. Inspired by frameworks like Flask and Express, Minimal APIs let you create endpoints with just a few lines of code.

Hello, Minimal API

Here’s a complete, working API in a single file:

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

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

app.Run();

That’s it. No Startup.cs, no controllers, no attributes. Just code.

Building a Complete CRUD API

Let’s build a more realistic example - a todo API:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddDbContext<TodoDb>(opt =>
    opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure middleware
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

// Map endpoints
app.MapGet("/todos", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

app.MapPost("/todos", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();
    return Results.Created($"/todos/{todo.Id}", todo);
});

app.MapPut("/todos/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;
    await db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todos/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.Ok(todo);
    }
    return Results.NotFound();
});

app.Run();

// Models
public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

public class TodoDb : DbContext
{
    public TodoDb(DbContextOptions<TodoDb> options) : base(options) { }
    public DbSet<Todo> Todos => Set<Todo>();
}

Parameter Binding

Minimal APIs support various binding sources:

// Route parameters
app.MapGet("/users/{id}", (int id) => $"User {id}");

// Query strings
app.MapGet("/search", (string? q, int page = 1, int pageSize = 10) =>
    $"Searching for '{q}' on page {page}");

// Headers
app.MapGet("/headers", (
    [FromHeader(Name = "X-Custom-Header")] string customHeader) =>
    $"Header value: {customHeader}");

// Request body
app.MapPost("/users", (User user) => $"Created {user.Name}");

// Services from DI
app.MapGet("/time", (TimeProvider time) => time.GetUtcNow());

// HttpContext for advanced scenarios
app.MapGet("/context", (HttpContext context) =>
{
    var userAgent = context.Request.Headers.UserAgent;
    return $"User-Agent: {userAgent}";
});

// Combining multiple sources
app.MapPost("/items/{category}", (
    string category,
    [FromQuery] string? filter,
    [FromBody] Item item,
    [FromServices] ILogger<Program> logger) =>
{
    logger.LogInformation("Creating item in {Category}", category);
    return Results.Created($"/items/{category}/{item.Id}", item);
});

public record User(int Id, string Name, string Email);
public record Item(int Id, string Name);

Results and Responses

The Results class provides typed responses:

app.MapGet("/result-examples/{id}", (int id) => id switch
{
    < 0 => Results.BadRequest("ID cannot be negative"),
    0 => Results.NotFound(),
    > 1000 => Results.StatusCode(503), // Service Unavailable
    _ => Results.Ok(new { Id = id, Message = "Found" })
});

// Typed results for OpenAPI documentation
app.MapGet("/typed/{id}", Results<Ok<User>, NotFound> (int id, UserService service) =>
{
    var user = service.GetUser(id);
    return user is not null
        ? TypedResults.Ok(user)
        : TypedResults.NotFound();
});

// File results
app.MapGet("/download", () => Results.File(
    fileContents: System.Text.Encoding.UTF8.GetBytes("Hello"),
    contentType: "text/plain",
    fileDownloadName: "hello.txt"));

// Redirect
app.MapGet("/old-endpoint", () => Results.Redirect("/new-endpoint"));

// JSON with custom options
app.MapGet("/custom-json", () => Results.Json(
    new { Message = "Hello" },
    new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));

Route Groups

Organize related endpoints with route groups:

var app = builder.Build();

// Group related endpoints
var todoGroup = app.MapGroup("/todos")
    .WithTags("Todo Operations");

todoGroup.MapGet("/", GetAllTodos);
todoGroup.MapGet("/{id}", GetTodo);
todoGroup.MapPost("/", CreateTodo);
todoGroup.MapPut("/{id}", UpdateTodo);
todoGroup.MapDelete("/{id}", DeleteTodo);

// Nested groups with shared configuration
var apiGroup = app.MapGroup("/api/v1")
    .RequireAuthorization();

var usersGroup = apiGroup.MapGroup("/users")
    .WithTags("Users");

usersGroup.MapGet("/", () => "List users");
usersGroup.MapGet("/{id}", (int id) => $"Get user {id}");

var ordersGroup = apiGroup.MapGroup("/orders")
    .WithTags("Orders");

ordersGroup.MapGet("/", () => "List orders");
ordersGroup.MapPost("/", (Order order) => Results.Created($"/orders/{order.Id}", order));

public record Order(int Id, string Product);

Filters and Middleware

Apply cross-cutting concerns with filters:

// Endpoint filter
app.MapGet("/filtered", () => "Hello")
    .AddEndpointFilter(async (context, next) =>
    {
        var logger = context.HttpContext.RequestServices
            .GetRequiredService<ILogger<Program>>();

        logger.LogInformation("Before endpoint");
        var result = await next(context);
        logger.LogInformation("After endpoint");

        return result;
    });

// Validation filter
app.MapPost("/users", (User user) => Results.Ok(user))
    .AddEndpointFilter(async (context, next) =>
    {
        var user = context.GetArgument<User>(0);

        if (string.IsNullOrEmpty(user.Name))
            return Results.ValidationProblem(new Dictionary<string, string[]>
            {
                { "Name", new[] { "Name is required" } }
            });

        return await next(context);
    });

// Reusable filter class
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("Handling request: {Path}",
            context.HttpContext.Request.Path);

        var result = await next(context);

        _logger.LogInformation("Request completed");
        return result;
    }
}

// Apply reusable filter
app.MapGet("/with-logging", () => "Logged!")
    .AddEndpointFilter<LoggingFilter>();

public record User(int Id, string Name, string Email);

Authentication and Authorization

Secure your endpoints:

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// Require authentication
app.MapGet("/secure", () => "Secret data")
    .RequireAuthorization();

// Specific policy
app.MapGet("/admin", () => "Admin only")
    .RequireAuthorization("AdminPolicy");

// Allow anonymous
app.MapGet("/public", () => "Public data")
    .AllowAnonymous();

// Access user claims
app.MapGet("/me", (ClaimsPrincipal user) =>
    $"Hello, {user.Identity?.Name}");

// Role-based authorization
app.MapDelete("/users/{id}", (int id) => Results.Ok())
    .RequireAuthorization(policy => policy.RequireRole("Admin"));

OpenAPI/Swagger Integration

Minimal APIs integrate with OpenAPI:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "My API", Version = "v1" });
});

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

// Add metadata for documentation
app.MapGet("/products", () => new[] { new Product(1, "Widget") })
    .WithName("GetProducts")
    .WithDescription("Retrieves all products")
    .Produces<Product[]>(StatusCodes.Status200OK)
    .WithOpenApi();

app.MapPost("/products", (Product product) => Results.Created($"/products/{product.Id}", product))
    .WithName("CreateProduct")
    .Accepts<Product>("application/json")
    .Produces<Product>(StatusCodes.Status201Created)
    .ProducesValidationProblem()
    .WithOpenApi();

public record Product(int Id, string Name);

When to Use Minimal APIs

Minimal APIs are great for:

  • Microservices with few endpoints
  • Quick prototypes and MVPs
  • Lightweight APIs and webhooks
  • Learning and teaching

Stick with controllers when you need:

  • Large APIs with many endpoints
  • Extensive use of filters and attributes
  • Complex routing scenarios
  • Existing codebases that use controllers

Migrating from Controllers

You can mix controllers and minimal APIs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers(); // Keep controller support
builder.Services.AddEndpointsApiExplorer();

var app = builder.Build();

app.MapControllers(); // Map controller routes

// Add minimal API endpoints alongside
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy" }));
app.MapGet("/version", () => Results.Ok(new { Version = "1.0.0" }));

app.Run();

Minimal APIs bring the simplicity of modern web frameworks to .NET while maintaining the performance and type safety C# developers expect.

Resources

Michael John Pena

Michael John Pena

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.