Minimal API Workshop — all decks
← Back to slides
Welcome — Minimal API Workshop
Minimal API Workshop
ASP.NET 10 · Minimal APIs

Building Secure & Scalable Minimal APIs

A full-day, hands-on workshop

9:00 AM – 5:00 PM

What you'll build

Roastery — an ordering API for a small coffee roaster

The day

TimeModule
9:201 · Fundamentals & the endpoint pipeline
10:452 · Data, validation & the shape of a good API
1:003 · Identity, tokens & authorization
2:304 · Performance, resilience & testing
3:455 · Aspire, containers & shipping

Morning break 10:30 · Lunch 12:00 · Afternoon break 2:15 · Wrap-up 4:40

How each module works

Two kinds of users

Customer

  • Browse the menu
  • Place orders
  • View their own orders

Staff (barista)

  • Everything a customer can
  • Manage products
  • Advance any order

The order lifecycle

Pending  ->  Roasting  ->  Ready  ->  PickedUp
   |
   +------->  Cancelled

The API enforces every transition. Illegal moves return 409.

Ground rules

Lab

Environment check

Ends at let's build

Module 1 — Fundamentals
Minimal API Workshop
Module 1

Fundamentals & the Endpoint Pipeline

Route groups · binding · TypedResults · filters · OpenAPI

9:20 – 10:30 AM

By the end you can

Anatomy of Program.cs

Four parts — every line belongs to one

The four parts

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

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

var app = builder.Build();

app.UseHttpsRedirection();                          // 3. pipeline

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

“Minimal” = fewer abstractions between you and HTTP, not fewer capabilities.

Coming from controllers

Controller worldMinimal API world
[Route("api/[controller]")]app.MapGroup("/api/products")
[HttpGet("{id}")]group.MapGet("/{id}", handler)
ActionResult<T>Results<Ok<T>, NotFound>
Action filtersEndpoint filters
[Authorize].RequireAuthorization()

Route groups + feature folders

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;
}

Anything applied to the group — a tag, filter, policy, cache — applies to every endpoint in it.

Parameter binding

SourceHow it binds
Route /{id}Parameter with a matching name
Query stringSimple types not matched by a route value
BodyA complex type on POST / PUT / PATCH
Header[FromHeader]
ServicesAny registered service
SpecialHttpContext, CancellationToken, ClaimsPrincipal

Bind a group of query values into one record with [AsParameters].

TypedResults, not Results

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<…> union is the contract: compile-time correctness and OpenAPI for free.

Endpoint filters

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;
});

Middleware runs outside filters; filters run outside the handler — and see the bound arguments.

OpenAPI in .NET 10

builder.Services.AddOpenApi();
// ...
app.MapOpenApi();               // /openapi/v1.json
app.MapScalarApiReference();    // /scalar/v1  (browsable UI)

Built in — no Swashbuckle. Metadata flows from TypedResults, .WithName, .WithSummary.

Lab

Lab 1 · The Public Menu

Ends at checkpoint-1

Module 2 — Data & Validation
Minimal API Workshop
Module 2

Data, Validation & the Shape of a Good API

EF Core · DTOs · validation · ProblemDetails · paging

10:45 AM – 12:00 PM

By the end you can

The DbContext

public class RoasteryDbContext(DbContextOptions<RoasteryDbContext> options)
    : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();
}

builder.Services.AddDbContext<RoasteryDbContext>(o =>
    o.UseSqlite(builder.Configuration.GetConnectionString("Roastery")));

Migrations — even in a workshop

Entities are not contracts

public record OrderDto(int Id, string Status, decimal Total,
                       DateTimeOffset PlacedAt, IReadOnlyList<OrderLineDto> Lines);

public record CreateOrderRequest(
    [property: Required, MinLength(1)] List<OrderLineRequest> Lines);

DTOs decouple schema from contract, prevent JSON cycles, and close OWASP API3.

Validation in .NET 10

builder.Services.AddValidation();   // turn it on once

// annotations on the request record are now enforced;
// a failure returns 400 ValidationProblemDetails.

public record CreateOrderRequest(List<OrderLineRequest> Lines)
    : IValidatableObject   // for cross-field rules
{ /* ... */ }

Forget AddValidation() and the annotations are silently ignored.

One error shape: ProblemDetails

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
// ...
app.UseExceptionHandler();
app.UseStatusCodePages();

Binding errors, validation, domain exceptions, unhandled crashes — all application/problem+json.

Paging & projection

var query = db.Orders.AsNoTracking()
    .Where(o => status == null || o.Status == status)
    .OrderByDescending(o => o.PlacedAt);

var total = await query.CountAsync(ct);
var items = await query.Skip((page-1)*size).Take(size)
    .Select(o => new OrderSummaryDto(o.Id, o.Status.ToString(), o.Total, o.PlacedAt))
    .ToListAsync(ct);

Project before you materialize · AsNoTracking for reads · never an unbounded list.

Concurrency in one slide

Lab

Lab 2 · Real Orders in a Real Database

Ends at checkpoint-2

Module 3 — Security
Minimal API Workshop
Module 3

Identity, Tokens & Authorization

Identity · JWT · policies · resource authz · hardening

1:00 – 2:15 PM

By the end you can

Authentication ≠ Authorization

Who are you? vs. May you do this?

Order is not negotiable

app.UseAuthentication();   // validate token -> ClaimsPrincipal
app.UseAuthorization();    // evaluate policies against it
app.MapProductEndpoints(); // endpoints come after both

// no principal        -> 401 Unauthorized
// principal, denied   -> 403 Forbidden

Two ways to get a token

MapIdentityApi<T>()

  • Register / login / refresh for free
  • Great default
  • Returns a bearer token

Issue your own JWT

  • Control the claims
  • Add role + customer_id
  • Authz without a DB hit

Validating the token

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o => o.TokenValidationParameters = new()
    {
        ValidIssuer = jwt.Issuer, ValidAudience = jwt.Audience,
        IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
        ClockSkew = TimeSpan.FromSeconds(30),
    });

Signing key lives in user secrets / env — never appsettings.json. HS256 needs ≥ 32 bytes.

Authorization as policy

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Staff", p => p.RequireRole("Staff"))
    .AddPolicy("OwnsOrder", p => p.AddRequirements(new OwnsOrderRequirement()));

var staff = products.MapGroup("/").RequireAuthorization("Staff");
staff.MapPost("/", CreateProduct);   // and PUT, DELETE

Policy on the group → new endpoints are protected by default. Fail safe.

Resource-based authorization

protected override Task HandleRequirementAsync(
    AuthorizationHandlerContext ctx, OwnsOrderRequirement req, Order order)
{
    if (ctx.User.IsInRole("Staff") ||
        ctx.User.FindFirstValue("customer_id") == order.CustomerId.ToString())
        ctx.Succeed(req);
    return Task.CompletedTask;
}

Return 404 (not 403) for another customer's order — a 403 confirms it exists.

Never trust the body for identity

Hardening quick hits

ConcernWhat you add
Brute force on loginRate limiter, fixed window on /api/auth/*
Abuse elsewhereGlobal sliding-window limiter
Cross-origin callsCORS with an explicit origin list
TransportHTTPS redirection + HSTS
Response headersnosniff, Referrer-Policy, remove Server
Lab

Lab 3 · Customers and Baristas

Ends at checkpoint-3

Module 4 — Production Readiness
Minimal API Workshop
Module 4

Performance, Resilience & Testing

caching · background work · resilience · health · tests

2:30 – 3:45 PM

By the end you can

Where the time goes

VersionElapsed
Tracking query, entities, mapped in memory~9 ms
AsNoTracking + projection (Module 2)~3 ms
Output cached< 1 ms

Measure first. Caching is what you reach for after the query is already good.

Output caching — cache the response

builder.Services.AddOutputCache(o => o.AddPolicy("Products", p => p
    .Expire(TimeSpan.FromMinutes(5)).SetVaryByQuery("roast","inStock").Tag("products")));
// ...
products.CacheOutput("Products");
await cache.EvictByTagAsync("products", ct);   // when a product changes

Authenticated responses are never cached — that's the right default.

HybridCache — cache the data

builder.Services.AddHybridCache();

var product = await cache.GetOrCreateAsync($"product:{id}",
    async ct => await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, ct),
    tags: ["products"], cancellationToken: ct);

In-process L1 (+ optional L2), with stampede protection. Used for pricing inside a handler.

Move slow work off the request

builder.Services.AddSingleton(Channel.CreateBounded<OrderReadyMessage>(100));
builder.Services.AddHostedService<OrderReadyNotifier>();

// in the status handler, when the order becomes Ready:
await channel.Writer.WriteAsync(new OrderReadyMessage(order.Id, email), ct);

A Channel + BackgroundService. Wrap each message in try/catch — an escape stops the host.

Outbound resilience — one line

builder.Services.AddHttpClient<PaymentClient>(c => c.BaseAddress = new(paymentsUrl))
    .AddStandardResilienceHandler();

// retry (backoff+jitter) · circuit breaker · timeout · rate limiter

The fake payment provider fails ~30% on purpose. Every retry is a span you'll see in Aspire.

Testing the real thing

Test authentication handler

// X-Test-User: "staff"  or  "customer:1"
var claims = new List<Claim> { new(ClaimTypes.Name, spec) };
if (spec == "staff") claims.Add(new(ClaimTypes.Role, "Staff"));
else claims.Add(new("customer_id", spec.Split(':')[1]));
return AuthenticateResult.Success(new(new ClaimsPrincipal(id), Scheme));

Real JWTs aren't needed in tests — the header describes the identity; the policies do the rest.

Lab

Lab 4 · Fast, Resilient & Proven

Ends at checkpoint-4

Module 5 — Shipping
Minimal API Workshop
Module 5

Aspire, Containers & Shipping

AppHost · dashboard · containers · versioning

3:45 – 4:40 PM

By the end you can

What Aspire is — and is not

The AppHost

var builder = DistributedApplication.CreateBuilder(args);

var payments = builder.AddProject<Projects.Roastery_Payments>("payments");

builder.AddProject<Projects.Roastery_Api>("api")
       .WithReference(payments)
       .WithExternalHttpEndpoints();

builder.Build().Run();

WithReference injects the payment address; service discovery resolves https://payments.

ServiceDefaults

builder.AddServiceDefaults();   // OTel, health, discovery, resilience
// ...
app.MapDefaultEndpoints();      // /health and /alive

One extension every service calls. The OTel + health code from Lab 4 moves here.

The dashboard payoff

A container image — no Dockerfile

dotnet publish src/Roastery.Api -c Release \
    --os linux --arch x64 /t:PublishContainer

# base image, non-root user, port 8080 — all chosen for you
docker run -p 8080:8080 -e Jwt__SigningKey=$KEY roastery-api

Double-underscore env vars map to config sections. Same code, laptop → container → cloud.

Version before the first client

builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1);
    o.ReportApiVersions = true;
});

var v1 = app.MapGroup("/api/v{version:apiVersion}")
            .WithApiVersionSet(versions).MapToApiVersion(1);
v1.MapProductEndpoints();

URLs become /api/v1/…; a v2 endpoint lives beside v1 with no change to v1's code.

The deployment landscape

TargetHow
Azure Container Appsazd up from the Aspire manifest
KubernetesAspire manifest → manifests / Helm
Any container hostThe image + environment variables

The instructor plays a recorded azd up — conference Wi-Fi has ended more demos than bad code.

Lab

Lab 5 · Run It Like Production

Ends at checkpoint-5

Minimal API Workshop
Wrap-up

You built Roastery.

Architecture review · retrospective · where to go next

4:40 – 5:00 PM