Intermediate

Building Minimal APIs in ASP.NET Core 10

Minimal APIs end to end — routing, binding, responses, validation, security and documentation.

Table of Contents

  1. Module 1 — Getting Started with ASP.NET Core 10 Minimal APIs
  2. Module 2 — Dependency Injection, Routing, and Parameter Binding
  3. Module 3 — Crafting Correct Responses
  4. Module 4 — Creating, Updating, and Deleting Resources
  5. Module 5 — Validation, Structure, Logging, and Exception Handling
  6. Module 6 — Securing Your Minimal API
  7. Module 7 — Documenting Your Minimal API
  8. Architecture Diagrams
  9. Reference Tables

Module 1 — Getting Started with ASP.NET Core 10 Minimal APIs

Introduction

Minimal APIs are a relatively new approach for building Web APIs with ASP.NET Core. More direct than the classic MVC approach with less “ceremony”. This course starts from scratch to build a complete, secure, and documented API.

Recommended prerequisites:

  • Basic ASP.NET Core concepts (DI container, request pipeline, middleware)
  • Experience with ASP.NET Core MVC and Entity Framework Core (recommended but not required)

Tools used: Visual Studio 2026 (or any IDE supporting ASP.NET Core)


ASP.NET Core API Building Approaches

Two approaches coexist for building Web APIs with ASP.NET Core:

ApproachDescriptionCharacteristics
ASP.NET Core MVCModel-View-Controller patternControllers, Action methods, Attributes, Full-featured
ASP.NET Core Minimal APIsLightweight and direct approachLambdas or static methods, fewer classes, faster to get started

Comparison: Minimal APIs vs Controller-based

graph LR
    subgraph MVC["ASP.NET Core MVC"]
        direction TB
        M["Model\n(data, business rules)"]
        V["View\n(returned JSON)"]
        C["Controller\n(logic, routing)"]
        C --> M
        C --> V
        M --> V
    end

    subgraph MinAPI["ASP.NET Core Minimal APIs"]
        direction TB
        PCS["Program.cs"]
        HH["Handler (Lambda or static method)"]
        RT["Route Template"]
        PCS --> RT
        RT --> HH
    end

    Client["HTTP Client"] -->|"Request"| MVC
    Client -->|"Request"| MinAPI

Detailed comparison table:

CriterionMinimal APIsMVC Controllers
BoilerplateMinimalMore verbose
PerformanceSlightly superiorVery good
FeaturesEssentialComplete (Views, Areas, complex Filters)
TestabilityGood (with Handler classes)Excellent
Content NegotiationNot natively supportedSupported
Use casesMicroservices, lightweight APIsComplex applications, enterprise APIs

Creating a Minimal API Project

In Visual Studio:

  1. FILE > New > Project → Template: ASP.NET Core Web API
  2. Uncheck “Use controllers” (to use Minimal APIs)
  3. Uncheck “Enable OpenAPI support” (we’ll configure it manually)
  4. Authentication → None (configured manually afterward)

Initial Program.cs structure:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.UseHttpsRedirection();

app.MapGet("/recipes", () =>
{
    return "Hello from Minimal API!";
});

app.Run();

Request pipeline — overview:

flowchart LR
    Client["HTTP Client"]
    Client -->|"HTTP Request"| MW1["UseHttpsRedirection"]
    MW1 --> MW2["UseStatusCodePages"]
    MW2 --> MW3["UseAuthentication"]
    MW3 --> MW4["UseAuthorization"]
    MW4 --> Router["Route Matching\n(MapGet, MapPost...)"]
    Router --> Handler["Handler\n(Lambda / Static Method)"]
    Handler -->|"HTTP Response"| Client

The .http File — Sending Requests

The .http file lets you send requests directly from Visual Studio:

@baseUrl = https://localhost:7290

### Get all dishes
GET {{baseUrl}}/dishes
Authorization: Bearer {{token}}

###

### Get a specific dish
GET {{baseUrl}}/dishes/fd630a57-2352-4731-b25c-db9cc7601b16
Authorization: Bearer {{token}}

###

### Create a dish
POST {{baseUrl}}/dishes
Content-Type: application/json
Authorization: Bearer {{token}}

{
  "name": "Grilled Salmon with Vegetables"
}

Data Layer with Entity Framework Core

Required NuGet packages:

<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0" />

Entities:

// Entities/Dish.cs
public class Dish
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public ICollection<Ingredient> Ingredients { get; set; }
        = new List<Ingredient>();
}

// Entities/Ingredient.cs
public class Ingredient
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public ICollection<Dish> Dishes { get; set; }
        = new List<Dish>();
}

DbContext:

// DbContexts/DishesDbContext.cs
public class DishesDbContext : DbContext
{
    public DbSet<Dish> Dishes { get; set; }
    public DbSet<Ingredient> Ingredients { get; set; }

    public DishesDbContext(DbContextOptions<DishesDbContext> options)
        : base(options) { }

    // Seed data + model configuration in OnModelCreating
}

Registration in Program.cs:

builder.Services.AddDbContext<DishesDbContext>(o => o.UseSqlite(
    builder.Configuration["ConnectionStrings:DishesDBConnectionString"]));

appsettings.json:

{
  "ConnectionStrings": {
    "DishesDBConnectionString": "Data Source=Dishes.db"
  }
}

Module 2 — Dependency Injection, Routing, and Parameter Binding

Dependency Injection in Minimal APIs

The Inversion of Control pattern delegates the selection of dependencies to an external component. ASP.NET Core includes a built-in DI container.

Available lifetimes:

LifetimeDescriptionTypical usage
AddSingletonSingle instance for the whole applicationConfiguration, caches
AddScopedOne instance per HTTP requestDbContext, business services
AddTransientNew instance on each injectionLightweight, stateless services

Injection in a Minimal API handler:

// The DI container automatically injects DishesDbContext
app.MapGet("/dishes", async (DishesDbContext dishesDbContext) =>
{
    return await dishesDbContext.Dishes.ToListAsync();
});

// With multiple dependencies
app.MapGet("/dishes", async (
    DishesDbContext dishesDbContext,
    ILogger<Dish> logger,
    ClaimsPrincipal claimsPrincipal,
    string? name) =>
{
    logger.LogInformation("Fetching dishes...");
    return await dishesDbContext.Dishes
        .Where(d => name == null || d.Name.Contains(name))
        .ToListAsync();
});

Note: Special types (HttpContext, ClaimsPrincipal, CancellationToken) are automatically injected without annotation.


Routing in Minimal APIs

Routing consists of associating an HTTP method + URI to a specific handler.

flowchart TD
    Request["HTTP Request\nGET /dishes/abc-123"] --> RM["Route Matching"]
    RM --> EP1["MapGet /dishes → GetDishes"]
    RM --> EP2["MapGet /dishes/{dishId:guid} → GetDishById"]
    RM --> EP3["MapPost /dishes → CreateDish"]
    RM --> EP4["MapPut /dishes/{dishId:guid} → UpdateDish"]
    RM --> EP5["MapDelete /dishes/{dishId:guid} → DeleteDish"]
    EP2 -->|"dishId = abc-123"| Handler["Handler executed"]

Available mapping methods:

HTTP MethodMap MethodUsage
GETMapGetRead data
POSTMapPostCreate a resource
PUTMapPutUpdate a resource (full replacement)
PATCHMapPatchPartial update
DELETEMapDeleteDelete a resource

Route template examples:

// Simple route
app.MapGet("/dishes", handler);

// Route with parameter
app.MapGet("/dishes/{dishId}", handler);

// Route with type constraint
app.MapGet("/dishes/{dishId:guid}", handler);

// Route with int constraint
app.MapGet("/items/{id:int}", handler);

// Route with optional parameter
app.MapGet("/dishes/{dishName?}", handler);

Route Templates and Constraints

// Automatic binding of a route parameter
app.MapGet("/dishes/{dishId:guid}", async (
    DishesDbContext dishesDbContext,
    Guid dishId) =>
{
    return await dishesDbContext.Dishes
        .FirstOrDefaultAsync(d => d.Id == dishId);
});

// Route with multiple parameters
app.MapGet("/dishes/{dishId:guid}/ingredients", async (
    DishesDbContext dishesDbContext,
    Guid dishId) =>
{
    var dish = await dishesDbContext.Dishes
        .Include(d => d.Ingredients)
        .FirstOrDefaultAsync(d => d.Id == dishId);
    return dish?.Ingredients;
});

Common route constraints:

ConstraintExampleDescription
:guid{id:guid}Valid GUID
:int{id:int}Integer
:long{id:long}Long integer
:double{price:double}Double
:bool{active:bool}Boolean
:datetime{date:datetime}DateTime
:minlength(n){name:minlength(2)}Minimum length
:maxlength(n){name:maxlength(100)}Maximum length
:range(min,max){age:range(0,120)}Value range

Why Not Expose the Entity Model

Directly exposing entities via endpoints is a bad practice:

  1. Separation of concerns: Entities represent database tables; DTOs represent what is sent over the network.
  2. Computed data: A DishDto can contain IngredientCount (computed) that doesn’t exist in the entity.
  3. Security: Avoids exposing sensitive or internal fields.
  4. Evolvability: Entities can change without impacting the API contract.

DTOs and Mapping

DTO classes:

// Models/DishDto.cs
namespace DishesAPI.Models;

public class DishDto
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
}

// Models/DishForCreationDto.cs
using System.ComponentModel.DataAnnotations;

namespace DishesAPI.Models;

public class DishForCreationDto
{
    [Required]
    [StringLength(200, MinimumLength = 2)]
    public required string Name { get; set; }
}

// Models/DishForUpdateDto.cs
using System.ComponentModel.DataAnnotations;

namespace DishesAPI.Models;

public class DishForUpdateDto
{
    [Required]
    [StringLength(200, MinimumLength = 2)]
    public required string Name { get; set; }
}

// Models/IngredientDto.cs
namespace DishesAPI.Models;

public class IngredientDto
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public Guid DishId { get; set; }
}

Manual mapping extensions:

// Extensions/MappingExtensions.cs
using DishesAPI.Entities;
using DishesAPI.Models;

namespace DishesAPI.Extensions;

public static class MappingExtensions
{
    public static DishDto ToDishDto(this Dish dish)
    {
        return new DishDto
        {
            Id = dish.Id,
            Name = dish.Name
        };
    }

    public static IEnumerable<DishDto> ToDishDtoList(this IEnumerable<Dish> dishes)
    {
        return dishes.Select(d => d.ToDishDto());
    }

    public static IngredientDto ToIngredientDto(this Ingredient ingredient, Guid dishId)
    {
        return new IngredientDto
        {
            Id = ingredient.Id,
            Name = ingredient.Name,
            DishId = dishId
        };
    }

    public static IEnumerable<IngredientDto> ToIngredientDtoList(
        this IEnumerable<Ingredient> ingredients, Guid dishId)
    {
        return ingredients.Select(i => i.ToIngredientDto(dishId));
    }

    public static Dish ToDish(this DishForCreationDto dto)
    {
        return new Dish { Name = dto.Name };
    }

    public static void UpdateFromDto(this Dish dish, DishForUpdateDto dto)
    {
        dish.Name = dto.Name;
    }
}

Tip: For complex models, prefer a source-generator mapper like Mapperly (zero runtime overhead).


Parameter Binding

Parameter binding is the process of converting request data into strongly typed parameters.

flowchart LR
    Request["HTTP Request"] --> PB["Parameter Binding Engine"]
    PB --> RT["[FromRoute]\nRoute values"]
    PB --> QS["[FromQuery]\nQuery string"]
    PB --> HD["[FromHeader]\nHeaders"]
    PB --> BD["[FromBody]\nRequest body (JSON)"]
    PB --> FV["[FromForm]\nForm values"]
    PB --> DI["[FromServices]\nDependency Injection"]
    PB --> SP["Special Types\nHttpContext, ClaimsPrincipal,\nCancellationToken, etc."]

Inference rules (without attributes):

Parameter typeInferred source
Simple types (string, Guid, int) matching name in routeRoute value
Simple types NOT matching the routeQuery string
Complex types (classes)Request body (JSON)
Types registered in the DI containerDependency Injection
HttpContext, ClaimsPrincipal, CancellationTokenSpecial injection

Binding examples:

// Binding from query string (explicit)
app.MapGet("/dishes", async (
    DishesDbContext db,
    [FromQuery] string? name) =>
{
    return await db.Dishes
        .Where(d => name == null || d.Name.Contains(name))
        .ToListAsync();
});

// Automatic binding (inferred) - same result
app.MapGet("/dishes", async (DishesDbContext db, string? name) =>
{
    return await db.Dishes
        .Where(d => name == null || d.Name.Contains(name))
        .ToListAsync();
});

// Accessing the ClaimsPrincipal (current user)
app.MapGet("/dishes", async (
    DishesDbContext db,
    ClaimsPrincipal claimsPrincipal) =>
{
    var userId = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier);
    // ...
});

// Different name between parameter and query string
app.MapGet("/dishes", async (
    DishesDbContext db,
    [FromQuery(Name = "filter")] string? nameFilter) =>
{
    // ?filter=salmon → nameFilter = "salmon"
});

Module 3 — Crafting Correct Responses

Common API Features

FeatureDescriptionImplementation
FilteringLimit a collection based on a predicateQuery string ?name=salmon
SearchingSearch across multiple fieldsQuery string ?searchQuery=stew
SortingSort the collectionQuery string ?orderBy=name
PagingPaginate resultsQuery string ?pageNumber=1&pageSize=10

Important: For complex filters, always pass parameters via query string — never put filter parameters in the body of a GET (often ignored by web servers).


Status Codes and Their Importance

HTTP status codes communicate the result of a request to the non-human consumers of the API.

flowchart TD
    Request["Client Request"] --> API["API Handler"]
    API -->|"Resource found"| S200["200 OK\n(GET/PUT with body)"]
    API -->|"Resource created"| S201["201 Created\n+ Location header"]
    API -->|"Success without body"| S204["204 No Content\n(DELETE/PUT)"]
    API -->|"Resource not found"| S404["404 Not Found"]
    API -->|"Invalid data"| S400["400 Bad Request\n(validation)"]
    API -->|"Not authenticated"| S401["401 Unauthorized"]
    API -->|"Access forbidden"| S403["403 Forbidden"]
    API -->|"Server error"| S500["500 Internal Server Error"]

Common status codes table:

CodeNameUsage
200OKGeneral success (GET, PUT with return)
201CreatedResource successfully created
204No ContentSuccess without returned content (DELETE, PUT)
400Bad RequestInvalid data sent by the client
401UnauthorizedNot authenticated
403ForbiddenAuthenticated but not authorized
404Not FoundNon-existent resource
409ConflictConflict (e.g., duplicate)
422Unprocessable EntityUnprocessable entity (semantic validation)
500Internal Server ErrorUnhandled server-side error

TypedResults — Correct Responses

TypedResults provides strongly-typed helpers matching HTTP status codes. They allow the framework to generate precise OpenAPI documentation.

// Results<T1, T2> lets you declare possible return types
app.MapGet("/dishes/{dishId:guid}", async (
    DishesDbContext dishesDbContext,
    Guid dishId) =>
{
    var dish = await dishesDbContext.Dishes
        .FirstOrDefaultAsync(d => d.Id == dishId);

    if (dish == null)
        return TypedResults.NotFound();           // 404

    return TypedResults.Ok(dish.ToDishDto());     // 200 + DishDto
});

// Explicit return type declaration (required when multiple types)
app.MapGet("/dishes/{dishId:guid}",
    async Task<Results<NotFound, Ok<DishDto>>> (
        DishesDbContext dishesDbContext,
        Guid dishId) =>
{
    var dish = await dishesDbContext.Dishes
        .FirstOrDefaultAsync(d => d.Id == dishId);

    if (dish == null)
        return TypedResults.NotFound();

    return TypedResults.Ok(dish.ToDishDto());
});

Available TypedResults:

TypedResultStatus CodeDescription
TypedResults.Ok(value)200Success with body
TypedResults.Created(uri, value)201Resource created
TypedResults.CreatedAtRoute(value, routeName, routeValues)201Created with link
TypedResults.NoContent()204Success without body
TypedResults.BadRequest(detail)400Invalid request
TypedResults.NotFound()404Not found
TypedResults.Conflict()409Conflict
TypedResults.Forbid()403Access forbidden
TypedResults.Unauthorized()401Not authenticated
TypedResults.Problem(...)500Server error
TypedResults.ValidationProblem(errors)400Validation errors

Problem Details — Standardized Error Responses

RFC 9457 defines a standardized format for error responses.

Example Problem Details response:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "detail": "The dish with id 'xxx' was not found.",
  "instance": "/dishes/xxx"
}

Configuration in Program.cs:

var builder = WebApplication.CreateBuilder(args);

// 1. Register the Problem Details service
builder.Services.AddProblemDetails();

var app = builder.Build();

// 2. Middleware for unhandled errors (prod)
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler(); // returns a Problem Details in production
}

// 3. Middleware for status codes without body (404, 401, etc.)
app.UseStatusCodePages(); // automatically produces a Problem Details body

app.MapGet("/dishes", ...);

app.Run();

Note: UseStatusCodePages + AddProblemDetails = all error responses automatically return a Problem Details body.


Module 4 — Creating, Updating, and Deleting Resources

Routing Revisited — Conventions for Manipulation

URI design principles:

  • Use only nouns (not verbs) in URIs
  • Consistent singular/plural (/dishes always, never /dish)
  • Do NOT create /create-dish — the POST /dishes already implies creation
  • The URI for UPDATE and DELETE is the same as for GET of an individual resource
POST   /dishes                → Create a dish
GET    /dishes                → Read all dishes
GET    /dishes/{dishId}       → Read a specific dish
PUT    /dishes/{dishId}       → Update a dish
DELETE /dishes/{dishId}       → Delete a dish
GET    /dishes/{dishId}/ingredients  → Read ingredients of a dish

Creating a Resource (POST)

app.MapPost("/dishes", async Task<CreatedAtRoute<DishDto>> (
    DishesDbContext dishesDbContext,
    DishForCreationDto dishForCreationDto) =>
{
    // Mapping DTO → Entity
    var dishEntity = dishForCreationDto.ToDish();

    // EF Core starts tracking the entity
    dishesDbContext.Add(dishEntity);

    // Persist to database
    await dishesDbContext.SaveChangesAsync();

    var dishToReturn = dishEntity.ToDishDto();

    // 201 Created + Location header pointing to the new resource
    return TypedResults.CreatedAtRoute(
        dishToReturn,
        "GetDish",          // Route name to generate the link
        new { dishId = dishToReturn.Id });
});

// The GET route must have a name for CreatedAtRoute to work
app.MapGet("/dishes/{dishId:guid}", handler)
    .WithName("GetDish");

Approach 1: CreatedAtRoute (in a handler)

return TypedResults.CreatedAtRoute(
    dishToReturn,
    "GetDish",
    new { dishId = dishToReturn.Id });

Approach 2: LinkGenerator (usable anywhere)

app.MapPost("/dishes", async (
    DishesDbContext db,
    LinkGenerator linkGenerator,
    HttpContext httpContext,
    DishForCreationDto dto) =>
{
    var dishEntity = dto.ToDish();
    db.Add(dishEntity);
    await db.SaveChangesAsync();

    var dishToReturn = dishEntity.ToDishDto();
    var uri = linkGenerator.GetUriByName(httpContext, "GetDish",
        new { dishId = dishToReturn.Id });

    return TypedResults.Created(uri, dishToReturn);
});

Recommendation: Use CreatedAtRoute in handlers, and LinkGenerator only when links need to be generated in other classes (services, etc.).


Updating a Resource (PUT)

app.MapPut("/dishes/{dishId:guid}",
    async Task<Results<NotFound, NoContent>> (
        DishesDbContext dishesDbContext,
        Guid dishId,
        DishForUpdateDto dishForUpdateDto) =>
{
    var dishEntity = await dishesDbContext.Dishes
        .FirstOrDefaultAsync(d => d.Id == dishId);

    if (dishEntity == null)
        return TypedResults.NotFound();

    // EF Core tracked entity — just update the properties
    dishEntity.UpdateFromDto(dishForUpdateDto);

    await dishesDbContext.SaveChangesAsync();

    return TypedResults.NoContent(); // 204
});

Deleting a Resource (DELETE)

app.MapDelete("/dishes/{dishId:guid}",
    async Task<Results<NotFound, NoContent>> (
        DishesDbContext dishesDbContext,
        Guid dishId) =>
{
    var dishEntity = await dishesDbContext.Dishes
        .FirstOrDefaultAsync(d => d.Id == dishId);

    if (dishEntity == null)
        return TypedResults.NotFound();

    dishesDbContext.Dishes.Remove(dishEntity);
    await dishesDbContext.SaveChangesAsync();

    return TypedResults.NoContent(); // 204
});

Grouping Endpoints with MapGroup

MapGroup allows grouping endpoints that share a common prefix or common metadata.

graph TD
    App["app (IEndpointRouteBuilder)"]
    App --> DG["dishesEndpoints\nMapGroup('/dishes')"]
    App --> IG["ingredientsEndpoints\nMapGroup('/dishes/{dishId:guid}/ingredients')"]

    DG --> DG2["dishWithGuidIdEndpoints\nMapGroup('/{dishId:guid}')"]

    DG --> GET_ALL["MapGet('') → GetDishes\n→ GET /dishes"]
    DG --> GET_NAME["MapGet('/{dishName}') → GetDishByName\n→ GET /dishes/{name}"]
    DG --> POST["MapPost('') → CreateDish\n→ POST /dishes"]

    DG2 --> GET_ID["MapGet('') → GetDishById\n→ GET /dishes/{dishId}"]
    DG2 --> PUT["MapPut('') → UpdateDish\n→ PUT /dishes/{dishId}"]
    DG2 --> DELETE["MapDelete('') → DeleteDish\n→ DELETE /dishes/{dishId}"]

    IG --> GET_ING["MapGet('') → GetIngredients\n→ GET /dishes/{dishId}/ingredients"]

Implementation in Program.cs:

// Main group for dishes
var dishesEndpoints = app.MapGroup("/dishes")
    .RequireAuthorization()
    .WithTags("Dishes");

// Sub-group for endpoints with dishId
var dishWithGuidIdEndpoints = dishesEndpoints
    .MapGroup("/{dishId:guid}");

// Group for ingredients
var ingredientsEndpoints = app.MapGroup("/dishes/{dishId:guid}/ingredients")
    .RequireAuthorization()
    .WithTags("Ingredients");

// Mapping endpoints
dishesEndpoints.MapGet("", GetDishesAsync);
dishesEndpoints.MapGet("/{dishName}", GetDishByNameAsync);
dishesEndpoints.MapPost("", CreateDishAsync);

dishWithGuidIdEndpoints.MapGet("", GetDishByIdAsync).WithName("GetDish");
dishWithGuidIdEndpoints.MapPut("", UpdateDishAsync);
dishWithGuidIdEndpoints.MapDelete("", DeleteDishAsync);

ingredientsEndpoints.MapGet("", GetIngredientsAsync);

Content Negotiation in Minimal APIs

Minimal APIs do not support content negotiation natively.

AspectMinimal APIsMVC Controllers
Output formatJSON onlyJSON, XML, and more
Accept headerIgnoredRespected
Response Content-Typeapplication/jsonBased on Accept header

For XML support or content negotiation, use the Carter library which adds a thin layer over Minimal APIs.


Module 5 — Validation, Structure, Logging, and Exception Handling

Validation in Minimal APIs

Since .NET 9+, validation with Data Annotations is natively supported in Minimal APIs (no more need for MiniValidation or FluentValidation for simple cases).

Step 1 — Annotate the DTOs:

// Models/DishForCreationDto.cs
using System.ComponentModel.DataAnnotations;

public class DishForCreationDto
{
    // The 'required' keyword = compile-time check (C# feature)
    // The [Required] attribute = runtime validation (Data Annotations)
    [Required]
    [StringLength(200, MinimumLength = 2)]
    public required string Name { get; set; }
}

Step 2 — Register the validation service:

// Program.cs
builder.Services.AddValidation(); // Registers automatic validation

That’s it! The framework automatically validates the request body before executing the handler. On failure, it automatically returns a 400 Bad Request with a ValidationProblemDetails body.

Automatic validation response:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": [
      "The field Name must be a string with a minimum length of 2 and a maximum length of 200."
    ]
  }
}

Common Data Annotations validation attributes:

AttributeDescriptionExample
[Required]Required field[Required]
[StringLength(max, MinimumLength=min)]String length[StringLength(200, MinimumLength = 2)]
[Range(min, max)]Value range[Range(0, 100)]
[RegularExpression(pattern)]Regular expression[RegularExpression(@"^\d{5}$")]
[EmailAddress]Email format[EmailAddress]
[Url]URL format[Url]
[MinLength(n)]Minimum length (collections)[MinLength(1)]
[MaxLength(n)]Maximum length (collections)[MaxLength(10)]
[Compare("OtherProp")]Comparison with another property[Compare("Password")]

Structuring Options

Progressive structure evolution:

graph LR
    A["Approach 1\nInline lambdas\nin Program.cs"] -->|"Extract"| B["Approach 2\nLocal functions\nin Program.cs"]
    B -->|"Extract"| C["Approach 3\nStatic Handler Classes\n(DishesHandlers.cs)"]
    C -->|"Extract"| D["Approach 4\nHandler Classes\n+ Extension Methods\n(RegisterDishesEndpoints)"]
    D -->|"Third-party libs"| E["Carter\nModule pattern"]
ApproachAdvantagesDisadvantages
Inline lambdasSimple, fastDoesn’t scale, everything in Program.cs
Local functionsImproved readabilityStill everything in Program.cs
Static Handler ClassesTestable, organized by featureMap calls still in Program.cs
Handler Classes + Extension MethodsComplete, maintainable, testableA few more files
CarterPopular for large codebasesExternal dependency

Handler Classes and Extension Methods

Recommended structure:

DishesAPI/
├── Program.cs
├── EndpointHandlers/
│   ├── DishesHandlers.cs
│   └── IngredientsHandlers.cs
├── Extensions/
│   ├── EndpointRouterBuilderExtensions.cs
│   └── MappingExtensions.cs
├── Models/
│   ├── DishDto.cs
│   ├── DishForCreationDto.cs
│   ├── DishForUpdateDto.cs
│   └── IngredientDto.cs
├── Entities/
│   ├── Dish.cs
│   └── Ingredient.cs
└── DbContexts/
    └── DishesDbContext.cs

EndpointHandlers/DishesHandlers.cs:

using DishesAPI.DbContexts;
using DishesAPI.Entities;
using DishesAPI.Extensions;
using DishesAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;

namespace DishesAPI.EndpointHandlers;

public static class DishesHandlers
{
    public static async Task<Ok<IEnumerable<DishDto>>> GetDishesAsync(
        DishesDbContext dishesDbContext,
        ILogger<DishDto> logger,
        ClaimsPrincipal claimsPrincipal,
        string? name)
    {
        logger.LogInformation(
            "Getting dishes, authenticated: {IsAuthenticated}",
            claimsPrincipal.Identity?.IsAuthenticated);

        return TypedResults.Ok(
            (await dishesDbContext.Dishes
                .Where(d => name == null || d.Name.Contains(name))
                .ToListAsync())
                .ToDishDtoList());
    }

    public static async Task<Results<NotFound, Ok<DishDto>>> GetDishByIdAsync(
        DishesDbContext dishesDbContext,
        Guid dishId)
    {
        var dishEntity = await dishesDbContext.Dishes
            .FirstOrDefaultAsync(d => d.Id == dishId);

        if (dishEntity == null)
            return TypedResults.NotFound();

        return TypedResults.Ok(dishEntity.ToDishDto());
    }

    public static async Task<Results<NotFound, Ok<DishDto>>> GetDishByNameAsync(
        DishesDbContext dishesDbContext,
        string dishName)
    {
        var dishEntity = await dishesDbContext.Dishes
            .FirstOrDefaultAsync(d => d.Name == dishName);

        if (dishEntity == null)
            return TypedResults.NotFound();

        return TypedResults.Ok(dishEntity.ToDishDto());
    }

    public static async Task<CreatedAtRoute<DishDto>> CreateDishAsync(
        DishesDbContext dishesDbContext,
        DishForCreationDto dishForCreationDto)
    {
        var dishEntity = dishForCreationDto.ToDish();
        dishesDbContext.Add(dishEntity);
        await dishesDbContext.SaveChangesAsync();

        var dishToReturn = dishEntity.ToDishDto();
        return TypedResults.CreatedAtRoute(
            dishToReturn,
            "GetDish",
            new { dishId = dishToReturn.Id });
    }

    public static async Task<Results<NotFound, NoContent>> UpdateDishAsync(
        DishesDbContext dishesDbContext,
        Guid dishId,
        DishForUpdateDto dishForUpdateDto)
    {
        var dishEntity = await dishesDbContext.Dishes
            .FirstOrDefaultAsync(d => d.Id == dishId);

        if (dishEntity == null)
            return TypedResults.NotFound();

        dishEntity.UpdateFromDto(dishForUpdateDto);
        await dishesDbContext.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    public static async Task<Results<NotFound, NoContent>> DeleteDishAsync(
        DishesDbContext dishesDbContext,
        Guid dishId)
    {
        var dishEntity = await dishesDbContext.Dishes
            .FirstOrDefaultAsync(d => d.Id == dishId);

        if (dishEntity == null)
            return TypedResults.NotFound();

        dishesDbContext.Dishes.Remove(dishEntity);
        await dishesDbContext.SaveChangesAsync();
        return TypedResults.NoContent();
    }
}

EndpointHandlers/IngredientsHandlers.cs:

using DishesAPI.DbContexts;
using DishesAPI.Extensions;
using DishesAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;

namespace DishesAPI.EndpointHandlers;

public static class IngredientsHandlers
{
    public static async Task<Results<NotFound, Ok<IEnumerable<IngredientDto>>>> GetIngredientsAsync(
        DishesDbContext dishesDbContext,
        Guid dishId)
    {
        var dishEntity = await dishesDbContext.Dishes
            .Include(d => d.Ingredients)
            .FirstOrDefaultAsync(d => d.Id == dishId);

        if (dishEntity == null)
            return TypedResults.NotFound();

        return TypedResults.Ok(dishEntity.Ingredients.ToIngredientDtoList(dishId));
    }
}

Extensions/EndpointRouterBuilderExtensions.cs:

using DishesAPI.EndpointHandlers;

namespace DishesAPI.Extensions;

public static class EndpointRouterBuilderExtensions
{
    public static void RegisterDishesEndpoints(
        this IEndpointRouteBuilder endpointRouteBuilder)
    {
        var dishesEndpoints = endpointRouteBuilder
            .MapGroup("/dishes")
            .RequireAuthorization()
            .WithTags("Dishes");

        var dishWithGuidIdEndpoints = dishesEndpoints
            .MapGroup("/{dishId:guid}");

        dishesEndpoints.MapGet("", DishesHandlers.GetDishesAsync)
            .WithSummary("Get all dishes")
            .WithDescription("Returns all dishes, optionally filtered by name.");

        dishWithGuidIdEndpoints.MapGet("", DishesHandlers.GetDishByIdAsync)
            .WithName("GetDish");

        dishesEndpoints.MapGet("/{dishName}", DishesHandlers.GetDishByNameAsync)
            .AllowAnonymous()
            .WithSummary("Get a dish by name")
            .WithDescription("Returns a single dish identified by its name. Allows anonymous access.");

        dishesEndpoints.MapPost("", DishesHandlers.CreateDishAsync)
            .RequireAuthorization("RequireAdminFromBelgium")
            .WithSummary("Create a dish")
            .WithDescription("Creates a new dish. Requires admin role and country Belgium.")
            .ProducesValidationProblem(400);

        dishWithGuidIdEndpoints.MapPut("", DishesHandlers.UpdateDishAsync)
            .RequireAuthorization("RequireAdminFromBelgium");

        dishWithGuidIdEndpoints.MapDelete("", DishesHandlers.DeleteDishAsync)
            .RequireAuthorization("RequireAdminFromBelgium");
    }

    public static void RegisterIngredientsEndpoints(
        this IEndpointRouteBuilder endpointRouteBuilder)
    {
        var ingredientsEndpoints = endpointRouteBuilder
            .MapGroup("/dishes/{dishId:guid}/ingredients")
            .RequireAuthorization()
            .WithTags("Ingredients");

        ingredientsEndpoints.MapGet("", IngredientsHandlers.GetIngredientsAsync);
    }
}

Final Program.cs (clean):

using DishesAPI.DbContexts;
using DishesAPI.Extensions;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

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

builder.Services.AddDbContext<DishesDbContext>(o => o.UseSqlite(
    builder.Configuration["ConnectionStrings:DishesDBConnectionString"]));

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler();
}

app.UseHttpsRedirection();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();

app.RegisterDishesEndpoints();
app.RegisterIngredientsEndpoints();

// Recreate the DB on each run (for demos)
using (var scope = app.Services.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<DishesDbContext>();
    context.Database.EnsureDeleted();
    context.Database.Migrate();
}

app.Run();

Exception Handling

Two middleware components for exception handling:

flowchart LR
    Ex["Unhandled exception"] --> Env{"Environment?"}
    Env -->|"Development"| DEP["Developer Exception Page\n(full stack trace)"]
    Env -->|"Production"| EHM["Exception Handler Middleware\n(app.UseExceptionHandler)\n→ Problem Details without stack trace"]
    DEP --> Response["HTTP Response"]
    EHM --> Response
MiddlewareEnvironmentBehavior
Developer Exception PageDevelopment (enabled by default)Full stack trace in the response
UseExceptionHandlerProduction (configure manually)500 error with Problem Details, without stack trace

Security: Exposing stack traces in production is a security risk. Never enable the Developer Exception Page in production.

// Configuration in Program.cs
if (!app.Environment.IsDevelopment())
{
    // Intercepts unhandled exceptions → returns Problem Details
    app.UseExceptionHandler();
}

// UseStatusCodePages ensures all error responses have a body
app.UseStatusCodePages();

Logging in Minimal APIs

ASP.NET Core logging is natively integrated. WebApplication.CreateBuilder automatically configures Console and Debug providers.

// In a static handler — inject ILogger<T>
public static async Task<Ok<IEnumerable<DishDto>>> GetDishesAsync(
    DishesDbContext dishesDbContext,
    ILogger<DishDto> logger,      // ILogger injection
    ClaimsPrincipal claimsPrincipal,
    string? name)
{
    // Structured logging: use message templates, not string interpolation
    logger.LogInformation(
        "Getting dishes, authenticated: {IsAuthenticated}",
        claimsPrincipal.Identity?.IsAuthenticated);

    // DO NOT do: logger.LogInformation($"Getting dishes, authenticated: {isAuth}");
    // → loses structured logging (values are no longer separated)

    return TypedResults.Ok(...);
}

Log levels (from most verbose to most critical):

LevelMethodUsage
TraceLogTraceVery detailed information (fine-grained debugging)
DebugLogDebugDebugging information
InformationLogInformationNormal application flow events
WarningLogWarningUnexpected but recoverable events
ErrorLogErrorErrors requiring attention
CriticalLogCriticalCatastrophic failures

Benefit of structured logging:

  • Values are preserved as separate fields
  • Aggregation tools (Seq, Elasticsearch, etc.) can filter and query them
  • {IsAuthenticated} → becomes a structured field, not a baked-in string

Module 6 — Securing Your Minimal API

API Security Overview

sequenceDiagram
    participant U as User / Client App
    participant IDP as Identity Provider<br/>(Entra ID, Auth0, etc.)
    participant API as Minimal API

    U->>IDP: Token request (credentials)
    IDP-->>U: JWT Bearer Token
    U->>API: GET /dishes<br/>Authorization: Bearer {token}
    API->>API: Validates token (signature, issuer, audience, expiry)
    API-->>U: 200 OK + data (if valid)<br/>401 Unauthorized (if invalid)

Security types:

ApproachUse caseComplexity
Cookie-basedClient and API on same domainLow
JWT Bearer TokenMulti-client APIs, mobile, SPAModerate
OAuth2 + OpenID ConnectSSO, centralized identityHigh
API KeysSimple APIs / B2BLow

Token-based Security — JWT Bearer

Structure of a JWT (JSON Web Token):

Header.Payload.Signature

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiYXVkIjoibWVudS1hcGkiLCJpc3MiOiJteS1pZHAiLCJyb2xlIjoiYWRtaW4iLCJjb3VudHJ5IjoiQmVsZ2l1bSJ9.xxx

Typical claims in a JWT:

ClaimFull nameDescription
subSubjectUnique user identifier
issIssuerWho created the token
audAudienceFor whom the token is intended
expExpirationWhen the token expires
iatIssued AtWhen the token was created
roleRoleUser’s role
countryCountryCustom claim (country)

Authentication and Authorization in the Pipeline

flowchart LR
    Request["HTTP Request\n+ Bearer Token"] --> UseAuth["UseAuthentication\n→ Validates token\n→ Creates ClaimsPrincipal"]
    UseAuth --> UseAuthz["UseAuthorization\n→ Checks policies\n→ RequireAuthorization"]
    UseAuthz --> Handler["Handler executed"]
    UseAuth -->|"Invalid token"| U401["401 Unauthorized"]
    UseAuthz -->|"Policy not satisfied"| U403["403 Forbidden"]

Configuration in Program.cs:

// 1. Register authentication and authorization services
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();

// 2. Configure in appsettings.json (JWT Bearer middleware reads it automatically)
/*
{
  "Authentication": {
    "DefaultScheme": "Bearer",
    "Schemes": {
      "Bearer": {
        "Audience": "menu-api",
        "Issuer": "dotnet-user-jwts"
      }
    }
  }
}
*/

// 3. Add middlewares to the pipeline (order matters!)
app.UseAuthentication();   // Before UseAuthorization
app.UseAuthorization();

// 4. Protect endpoints
app.MapGet("/dishes", handler).RequireAuthorization();
app.MapPost("/dishes", handler).RequireAuthorization("RequireAdminFromBelgium");
// or at group level:
app.MapGroup("/dishes").RequireAuthorization();
// Allow anonymous access on an endpoint within a protected group:
app.MapGet("/dishes/{dishName}", handler).AllowAnonymous();

Authorization Policies

Policies allow fine-grained access control based on claims, roles, or custom logic.

// Define a policy in Program.cs
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("RequireAdminFromBelgium", policy =>
        policy
            .RequireAuthenticatedUser()      // Must be authenticated
            .RequireRole("admin")            // Must have "admin" role
            .RequireClaim("country", "Belgium")); // Must have country=Belgium

// Apply the policy to an endpoint
dishesEndpoints.MapPost("", CreateDishAsync)
    .RequireAuthorization("RequireAdminFromBelgium");

dishWithGuidIdEndpoints.MapPut("", UpdateDishAsync)
    .RequireAuthorization("RequireAdminFromBelgium");

dishWithGuidIdEndpoints.MapDelete("", DeleteDishAsync)
    .RequireAuthorization("RequireAdminFromBelgium");

Results based on the token:

TokenGET /dishes endpointPOST /dishes endpoint
No token401 Unauthorized401 Unauthorized
Valid token, user role200 OK403 Forbidden
Valid token, admin role, country Belgium200 OK201 Created
Valid token, admin role, country France200 OK403 Forbidden

Generating a Test Token with dotnet user-jwts

For development, dotnet user-jwts generates JWT tokens locally without an external Identity Provider.

# See available options
dotnet user-jwts create --help

# Create a simple token with audience
dotnet user-jwts create --audience menu-api

# Create a token with role and claim
dotnet user-jwts create \
  --audience menu-api \
  --role admin \
  --claim country=Belgium

Example generated token payload:

{
  "sub": "kevin",
  "aud": "menu-api",
  "iss": "dotnet-user-jwts",
  "role": "admin",
  "country": "Belgium",
  "exp": 1234567890
}

Corresponding appsettings.json configuration:

{
  "Authentication": {
    "DefaultScheme": "Bearer",
    "Schemes": {
      "Bearer": {
        "Audience": "menu-api",
        "Issuer": "dotnet-user-jwts"
      }
    }
  }
}

Note: dotnet user-jwts is for development only. In production, use an Identity Provider (Microsoft Entra ID, Auth0, Okta, IdentityServer, etc.) with OAuth2 / OpenID Connect.


Module 7 — Documenting Your Minimal API

OpenAPI in ASP.NET Core

OpenAPI (formerly Swagger) is the de facto standard for documenting HTTP APIs.

flowchart LR
    Code["Code\n(Endpoints, TypedResults,\nData Annotations)"] -->|"AddOpenApi\nMapOpenApi"| Spec["OpenAPI Spec\n/openapi/v1.json\n(JSON/YAML)"]
    Spec -->|"MapScalarApiReference"| UI["Documentation UI\n(Scalar)\n/scalar/v1"]
    Spec -->|"Import"| Clients["Generated Clients\n(Kiota, NSwag, etc.)"]

Pieces of the puzzle:

  1. OpenAPI Specification — JSON/YAML document describing the API (endpoints, parameters, responses, security)
  2. Spec generationMicrosoft.AspNetCore.OpenAPI automatically generates from endpoints
  3. Documentation UI — Scalar (or Swagger UI) generates a visual interface from the spec

Advantage of TypedResults: since we use TypedResults.Ok<DishDto>, TypedResults.NotFound, etc., the generated OpenAPI spec automatically contains the precise return types for each endpoint.


Scalar — Documentation UI

Required NuGet packages:

<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.x.x" />

Configuration in Program.cs:

using Microsoft.OpenApi;
using Scalar.AspNetCore;

// Services
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer((document, context, cancellationToken) =>
    {
        document.Info = new OpenApiInfo
        {
            Title = "DishesAPI",
            Version = "v1",
            Description = "An API for managing dishes and their ingredients."
        };

        // Configure Bearer security in the OpenAPI spec
        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
        document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            Description = "Enter a valid JWT bearer token."
        };
        document.Security ??= [];
        document.Security.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecuritySchemeReference("Bearer")] = new List<string>()
        });

        return Task.CompletedTask;
    });
});

// Pipeline
app.MapOpenApi(); // → GET /openapi/v1.json

if (app.Environment.IsDevelopment())
{
    app.MapScalarApiReference(); // → GET /scalar/v1
}

Improving Endpoint Metadata

// Groups with Tags (to group in the UI)
var dishesEndpoints = endpointRouteBuilder
    .MapGroup("/dishes")
    .RequireAuthorization()
    .WithTags("Dishes");         // ← Tag for the group

// Endpoints with Summary and Description
dishesEndpoints.MapGet("", DishesHandlers.GetDishesAsync)
    .WithSummary("Get all dishes")
    .WithDescription("Returns all dishes, optionally filtered by name.");

dishesEndpoints.MapGet("/{dishName}", DishesHandlers.GetDishByNameAsync)
    .AllowAnonymous()
    .WithSummary("Get a dish by name")
    .WithDescription("Returns a single dish identified by its name. Allows anonymous access.");

// Document validation responses
dishesEndpoints.MapPost("", DishesHandlers.CreateDishAsync)
    .RequireAuthorization("RequireAdminFromBelgium")
    .WithSummary("Create a dish")
    .WithDescription("Creates a new dish. Requires admin role and country Belgium.")
    .ProducesValidationProblem(400);   // ← Documents the validation 400

Available metadata methods:

MethodDescription
.WithName("routeName")Route name (used by CreatedAtRoute)
.WithTags("TagName")Group in the documentation UI
.WithSummary("...")Short title in the docs
.WithDescription("...")Detailed description
.Produces<T>(statusCode)Documents a return type and its status code
.ProducesProblem(statusCode)Documents a Problem Details response
.ProducesValidationProblem(400)Documents a ValidationProblemDetails response
.AllowAnonymous()Excludes the endpoint from required authorization
.RequireAuthorization()Requires authentication
.RequireAuthorization("PolicyName")Requires a specific policy
.ExcludeFromDescription()Excludes from OpenAPI documentation

Architecture Diagrams

Complete Minimal APIs Architecture

flowchart TD
    subgraph Client["Client (Browser, Mobile App, SPA)"]
        Request["HTTP Request\n+ Bearer Token"]
    end

    subgraph Pipeline["ASP.NET Core Request Pipeline"]
        HTTPS["UseHttpsRedirection"]
        SCP["UseStatusCodePages"]
        AUTH["UseAuthentication\n(JWT Bearer Middleware)"]
        AUTHZ["UseAuthorization"]
        ROUTER["Route Matching\n(MapGroup, MapGet/Post/Put/Delete)"]
    end

    subgraph Handlers["Endpoint Handlers"]
        DH["DishesHandlers\n(static methods)"]
        IH["IngredientsHandlers\n(static methods)"]
    end

    subgraph Services["Services / Infrastructure"]
        DI["DI Container\n(AddDbContext, AddAuthentication, etc.)"]
        VAL["Validation\n(AddValidation + DataAnnotations)"]
        PD["ProblemDetails\n(AddProblemDetails)"]
    end

    subgraph Data["Data Layer"]
        EF["Entity Framework Core"]
        DB["SQLite Database\nDishes.db"]
    end

    subgraph Doc["Documentation"]
        OAS["OpenAPI Spec\n/openapi/v1.json"]
        SCALAR["Scalar UI\n/scalar/v1"]
    end

    Request --> HTTPS --> SCP --> AUTH --> AUTHZ --> ROUTER
    ROUTER --> DH
    ROUTER --> IH
    DH --> EF --> DB
    IH --> EF
    DI --> DH
    DI --> IH
    VAL --> ROUTER
    PD --> AUTH
    PD --> AUTHZ
    OAS --> SCALAR
    ROUTER --> OAS

Complete Middleware Pipeline

sequenceDiagram
    participant C as Client
    participant HTTPS as UseHttpsRedirection
    participant SCP as UseStatusCodePages
    participant AUTH as UseAuthentication
    participant AUTHZ as UseAuthorization
    participant H as Handler

    C->>HTTPS: HTTPS Request
    HTTPS->>SCP: Forward
    SCP->>AUTH: Forward
    AUTH->>AUTH: Validates Bearer Token → ClaimsPrincipal
    AUTH->>AUTHZ: Forward (with ClaimsPrincipal)
    AUTHZ->>AUTHZ: Checks RequireAuthorization / Policies
    AUTHZ->>H: Forward (if authorized)
    H-->>C: 200 OK / 201 Created / 204 No Content
    AUTH-->>C: 401 Unauthorized (invalid token)
    AUTHZ-->>C: 403 Forbidden (policy not satisfied)
    SCP-->>C: Problem Details body (for responses without body)

Route Grouping — MapGroup

graph TD
    APP["app\nIEndpointRouteBuilder"]

    APP --> DG["MapGroup('/dishes')\n.RequireAuthorization()\n.WithTags('Dishes')"]
    APP --> IG["MapGroup('/dishes/{dishId:guid}/ingredients')\n.RequireAuthorization()\n.WithTags('Ingredients')"]

    DG --> SG["MapGroup('/{dishId:guid}')"]

    DG --> G1["MapGet('') → GetDishesAsync\n.WithSummary('Get all dishes')"]
    DG --> G2["MapGet('/{dishName}') → GetDishByNameAsync\n.AllowAnonymous()"]
    DG --> G3["MapPost('') → CreateDishAsync\n.RequireAuthorization('RequireAdminFromBelgium')"]

    SG --> S1["MapGet('') → GetDishByIdAsync\n.WithName('GetDish')"]
    SG --> S2["MapPut('') → UpdateDishAsync\n.RequireAuthorization('RequireAdminFromBelgium')"]
    SG --> S3["MapDelete('') → DeleteDishAsync\n.RequireAuthorization('RequireAdminFromBelgium')"]

    IG --> I1["MapGet('') → GetIngredientsAsync"]

Reference Tables

Summary Table — DishesAPI Endpoints

MethodURIHandlerAuthDescription
GET/dishesGetDishesAsyncRequiredAll dishes (filterable by name)
GET/dishes/{dishId:guid}GetDishByIdAsyncRequiredA dish by ID
GET/dishes/{dishName}GetDishByNameAsyncAnonymousA dish by name
POST/dishesCreateDishAsyncRequireAdminFromBelgiumCreate a dish
PUT/dishes/{dishId:guid}UpdateDishAsyncRequireAdminFromBelgiumUpdate a dish
DELETE/dishes/{dishId:guid}DeleteDishAsyncRequireAdminFromBelgiumDelete a dish
GET/dishes/{dishId:guid}/ingredientsGetIngredientsAsyncRequiredIngredients of a dish

Summary Table — Registered Services

ServiceMethodDescription
Problem DetailsAddProblemDetails()Standardized error responses RFC 9457
ValidationAddValidation()Automatic Data Annotations validation
AuthenticationAddAuthentication().AddJwtBearer()JWT Bearer Token authentication
AuthorizationAddAuthorization() + AddAuthorizationBuilder()Authorization policies
DbContextAddDbContext<T>()Entity Framework Core with SQLite
OpenAPIAddOpenApi()OpenAPI spec generation

Summary Table — Middleware Pipeline

MiddlewareMethodOrderDescription
HTTPS RedirectionUseHttpsRedirection()1Redirects HTTP → HTTPS
Exception HandlerUseExceptionHandler()2 (prod)Centralized exception handling
Status Code PagesUseStatusCodePages()3Adds Problem Details body to errors without body
AuthenticationUseAuthentication()4Validates the Bearer Token
AuthorizationUseAuthorization()5Checks policies (AFTER Authentication)
OpenAPIMapOpenApi()-Exposes /openapi/v1.json
Scalar UIMapScalarApiReference()-Exposes /scalar/v1 (dev only)

Comparison — Results vs TypedResults

AspectResults<T1, T2>TypedResults.X()
Type safetyStrongStrong
OpenAPI documentationAutomaticAutomatic
UsageDeclared return typeInside the handler body
Declaration exampleTask<Results<NotFound, Ok<DishDto>>>Return TypedResults.Ok(...)

DishesAPI.csproj — Key Packages

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <!-- Entity Framework Core + SQLite -->
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0" />

    <!-- JWT Bearer Authentication -->
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />

    <!-- OpenAPI Documentation -->
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />

    <!-- Scalar UI -->
    <PackageReference Include="Scalar.AspNetCore" Version="2.x.x" />
  </ItemGroup>
</Project>

Additional resources:


Search Terms

asp.net · minimal · apis · core · web · c# · .net · development · api · pipeline · resource · responses · routing · architecture · authorization · binding · comparison · correct · deleting · dependency · endpoints · entity · exception · generating

Interested in this course?

Contact us to book it or get a custom training plan for your team.