Module 1: Fundamentals and the Endpoint Pipeline

9:20 – 10:30 AM · Concepts 25 min · Lab 45 min

Present this module

Learning objectives

By the end of this module you can explain what happens between WebApplication.CreateBuilder and app.Run(), organize endpoints with route groups, bind parameters from every source, return strongly typed results, and read your API's OpenAPI document.

Anatomy of Program.cs

A Minimal API has four parts, and every line in Program.cs belongs to one of them.

var builder = WebApplication.CreateBuilder(args);   // 1. the builder

builder.Services.AddOpenApi();                        // 2. services (DI)

var app = builder.Build();

app.UseHttpsRedirection();                            // 3. middleware pipeline

app.MapGet("/health", () => "Healthy");               // 4. endpoints
app.MapOpenApi();

app.Run();

"Minimal" describes the number of abstractions between you and HTTP, not the number of things the framework can do. Everything ASP.NET Core offers, from authentication to output caching, plugs into the same builder and pipeline.

Coming from controllers

If you have written [ApiController] classes, the mapping is direct. What was implicit in a controller becomes an explicit choice in a Minimal API, which is the point.

Controller world Minimal API world
[Route("api/[controller]")] on a class app.MapGroup("/api/products")
[HttpGet("{id}")] on a method group.MapGet("/{id}", handler)
[FromBody], [FromQuery] inferred by [ApiController] Inferred the same way, with [AsParameters] for grouping
ActionResult<T> Results<Ok<T>, NotFound> via TypedResults
Action filters Endpoint filters (AddEndpointFilter)
ModelState automatic 400 AddValidation() automatic 400 (Module 2)
[Authorize] .RequireAuthorization() on an endpoint or group (Module 3)

Route groups and feature folders

A Program.cs with sixty MapGet calls is not minimal, it is a mess. Group endpoints by feature and give each feature an extension method:

// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/products")
                       .WithTags("Products");

        group.MapGet("/", GetProducts).WithName("GetProducts");
        group.MapGet("/{id:int}", GetProduct).WithName("GetProduct");
        group.MapPost("/", CreateProduct).WithName("CreateProduct");

        return group;
    }

    // handlers below…
}
// Program.cs
app.MapProductEndpoints();

Anything you apply to the group, such as a tag, a filter, an authorization policy, or a cache policy, applies to every endpoint inside it. That is how Module 3 locks down all staff endpoints in one line.

Parameter binding

Handlers are plain methods, and the framework binds each parameter from the most sensible source.

static async Task<Ok<List<ProductDto>>> GetProducts(
    [AsParameters] ProductQuery query,      // ?roast=&inStock= bundled into a record
    IProductCatalog catalog,                // resolved from DI
    CancellationToken ct)                   // the request's cancellation token
public record ProductQuery(string? Roast, bool? InStock);
Source How it binds
Route /{id} Parameter with a matching name
Query string Simple types not matched by a route value
Body A complex type on POST, PUT, PATCH
Header [FromHeader(Name = "X-Request-Id")]
Services Any registered service, or [FromServices] to be explicit
Special HttpContext, HttpRequest, CancellationToken, ClaimsPrincipal

When binding fails, for example /{id:int} receiving abc, the client gets a 400 before your handler runs. You will make that response consistent with ProblemDetails in Module 2.

TypedResults, not Results

Results.Ok(product) compiles, but it tells neither the compiler nor OpenAPI what your endpoint returns. TypedResults does both:

static async Task<Results<Ok<ProductDto>, NotFound>> GetProduct(int id, IProductCatalog catalog)
{
    var product = await catalog.FindAsync(id);
    return product is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(product.ToDto());
}

The Results<T1, T2, T3> union in the return type is the contract. If you try to return something not in the union, it is a compile error, and the OpenAPI document lists both a 200 with a schema and a 404.

Endpoint filters

A filter wraps a handler. It runs after routing and binding, so it sees the bound arguments, which is something middleware cannot do.

group.AddEndpointFilter(async (context, next) =>
{
    var sw = Stopwatch.StartNew();
    var result = await next(context);
    context.HttpContext.Response.Headers["X-Elapsed-Ms"] =
        sw.ElapsedMilliseconds.ToString();
    return result;
});

Order matters: middleware runs outside filters, filters run outside the handler.

flowchart LR
    R[Request] --> M1[Middleware] --> Rt[Routing] --> B[Binding] --> F[Endpoint filters] --> H[Handler]
    H --> F --> M1 --> Rs[Response]

OpenAPI in .NET 10

Document generation is built in; no Swashbuckle package is required.

builder.Services.AddOpenApi();
// …
app.MapOpenApi();                      // serves /openapi/v1.json

For a browsable UI, add the Scalar package and one line:

app.MapScalarApiReference();           // serves /scalar/v1

Metadata flows into the document from the code you already wrote: the return-type union, .WithName, .WithSummary, .WithDescription, and .Produces<T>() where the type cannot be inferred. The lab has you fix a deliberately wrong Produces so you can see how a mismatch looks.

Head to the lab

You now have everything you need to build the public menu: Lab 1: The Public Menu.