Advanced

Minimal APIs Deep Dive in ASP.NET Core 10

Internal architecture, advanced routing, endpoint filters, validation, versioning and streaming.

Prerequisites: Prior experience with ASP.NET Core Minimal APIs and knowledge of C#
Demo application: DishesAPI — CRUD API for dishes and their ingredients (Entity Framework Core + SQLite)


Table of Contents

  1. Internal Architecture of Minimal APIs
  2. Advanced Routing, Endpoint Composition, and Mapping
  3. Endpoint Filters and Middleware Coordination
  4. Advanced Validation and Error Handling Strategies
  5. API Versioning
  6. Streaming, Server-Sent Events, and Architectural Trade-offs
  7. Advanced Reference Tables

1. Internal Architecture of Minimal APIs

How Minimal APIs Work Under the Hood

Minimal APIs and controller-based APIs are built on the same foundation: the ASP.NET Core routing and middleware pipeline. The key difference lies in how they interact with this infrastructure.

flowchart TD
    A["MapGet / MapPost / MapPut / MapDelete\n(IEndpointRouteBuilder extension methods)"]
    B["RouteEndpoint created"]
    C1["Route Pattern\n(e.g.: /dishes/{dishId:guid})"]
    C2["Endpoint Metadata\n(auth, tags, names, OpenAPI)"]
    C3["RequestDelegate\n(the compiled handler)"]
    D["RequestDelegateFactory\n(inspects handler parameters)"]
    E1["DI injection\n(DbContext, services)"]
    E2["Route binding\n(Guid dishId)"]
    E3["Body deserialization\n(DishForCreationDto)"]
    F["Specialized delegate generated at startup"]

    A --> B
    B --> C1
    B --> C2
    B --> C3
    C3 --> D
    D --> E1
    D --> E2
    D --> E3
    D --> F

Key points:

  • MapGet, MapPost, etc. are extension methods on IEndpointRouteBuilder
  • Each call creates a RouteEndpoint object with three components: route pattern, metadata, and RequestDelegate
  • For controller-based APIs, the RequestDelegate is the MVC action invoker — powerful but heavy (controller instantiation, full MVC filter pipeline)
  • For Minimal APIs, the RequestDelegate is generated at startup by RequestDelegateFactory, which inspects the handler parameters:
    • Service-type parameter → injected from the DI container
    • Parameter whose name matches a route parameter → parsed from route values
    • Complex parameter → deserialized from the request body

This startup-time generation means zero reflection at runtime — this is the source of the Minimal APIs performance advantage.


Middleware Pipeline and Endpoint Execution

flowchart LR
    REQ[HTTP Request] --> EH[Exception Handler]
    EH --> HTTPS[HTTPS Redirection]
    HTTPS --> ROUTING[Routing Middleware\nSelects the RouteEndpoint]
    ROUTING --> AUTH_N[Authentication\nValidates the token, establishes identity]
    AUTH_N --> AUTH_Z[Authorization\nChecks policies via metadata]
    AUTH_Z --> EP[Endpoint Middleware\nExecutes the RequestDelegate]
    EP --> FILTERS["Endpoint Filters\n(before + after the handler)"]
    FILTERS --> HANDLER[Handler Function]
    HANDLER --> FILTERS
    FILTERS --> EP
    EP --> AUTH_Z
    AUTH_Z --> AUTH_N
    AUTH_N --> ROUTING
    ROUTING --> HTTPS
    HTTPS --> EH
    EH --> RES[HTTP Response]

Execution order and role of each component:

PositionMiddlewareRole
1ExceptionHandlerCatches unhandled exceptions (global wrapper)
2HTTPS RedirectionRedirects HTTP → HTTPS
3RoutingSelects the RouteEndpoint matching the URI (but does not execute it yet)
4AuthenticationValidates the JWT token, establishes HttpContext.User
5AuthorizationChecks policies based on endpoint metadata
6EndpointExecutes the RequestDelegate (which includes endpoint filters + the handler)

Crucial point: The Routing middleware selects the endpoint before Authentication/Authorization. This allows the Authorization middleware to read endpoint metadata (e.g., [Authorize], policies) to decide if the request is authorized.


2. Advanced Routing, Endpoint Composition, and Mapping

Composable Route Groups and Cascading Conventions

flowchart TD
    APP["app (WebApplication)\nIEndpointRouteBuilder"]
    API["/api group\n.RequireAuthorization()"]
    DISHES["/dishes group\n.WithTags(Dishes)"]
    DISH_GUID["/{dishId:guid} group"]
    INGR["/dishes/{dishId:guid}/ingredients group\n.WithTags(Ingredients)"]

    GET_ALL["GET /api/dishes\n← inherits auth + tag"]
    GET_ID["GET /api/dishes/{dishId}\n← inherits auth + tag"]
    POST["POST /api/dishes\n.RequireAuthorization(RequireAdminFromBelgium)"]
    PUT["PUT /api/dishes/{dishId}\n.RequireAuthorization(RequireAdminFromBelgium)"]
    DELETE["DELETE /api/dishes/{dishId}\n.RequireAuthorization(RequireAdminFromBelgium)"]
    GET_INGR["GET /api/dishes/{dishId}/ingredients\n← inherits auth + tag"]

    APP --> API
    API --> DISHES
    DISHES --> DISH_GUID
    APP --> INGR

    DISHES --> GET_ALL
    DISHES --> POST
    DISH_GUID --> GET_ID
    DISH_GUID --> PUT
    DISH_GUID --> DELETE
    INGR --> GET_INGR

MapGroup returns a RouteGroupBuilder. Groups nest and conventions inherit in cascade: route prefix, authorization, tags, filters, and other metadata from the parent are automatically applied to children.

// Program.cs — group hierarchy
var apiGroup = app.MapGroup("/api")
    .RequireAuthorization(); // entire hierarchy requires auth

var dishesEndpoints = apiGroup.MapGroup("/dishes")
    .WithTags("Dishes");

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

// GET /api/dishes — inherits RequireAuthorization + tag "Dishes"
dishesEndpoints.MapGet("", DishesHandlers.GetDishesAsync)
    .WithSummary("Get all dishes")
    .WithDescription("Returns all dishes, optionally filtered by name.");

// POST /api/dishes — additional specific authorization
dishesEndpoints.MapPost("", DishesHandlers.CreateDishAsync)
    .RequireAuthorization("RequireAdminFromBelgium")
    .WithSummary("Create a dish")
    .ProducesValidationProblem(400);

// GET /api/dishes/{dishId} — inherits everything from the hierarchy
dishWithGuidIdEndpoints.MapGet("", DishesHandlers.GetDishByIdAsync)
    .WithName("GetDish");

// PUT + DELETE — admin authorization
dishWithGuidIdEndpoints.MapPut("", DishesHandlers.UpdateDishAsync)
    .RequireAuthorization("RequireAdminFromBelgium");
dishWithGuidIdEndpoints.MapDelete("", DishesHandlers.DeleteDishAsync)
    .RequireAuthorization("RequireAdminFromBelgium");

Endpoint Metadata and Advanced Conventions

Each call to WithName, WithTags, RequireAuthorization, etc. adds metadata objects to the endpoint. These metadata drive framework behavior:

MetadataConsumed by
Authorization metadataAuthorization middleware
Tags/DescriptionOpenAPI generator (Scalar, Swagger)
CORS metadataCORS middleware
Custom attributeEndpoint filters (via context.HttpContext.GetEndpoint()?.Metadata)

Custom metadata:

// Attributes/ExperimentalAttribute.cs
public class ExperimentalAttribute : Attribute { }

// Usage on an endpoint
dishesEndpoints.MapGet("/experimental", () =>
{
    throw new NotImplementedException();
})
.WithMetadata(new ExperimentalAttribute());

// Reading in a filter
var isExperimental = context.HttpContext
    .GetEndpoint()?.Metadata
    .GetMetadata<ExperimentalAttribute>() is not null;

Organizing Endpoints at Scale

Pattern 1: Extension methods with RegisterAll

// Extensions/EndpointRouterBuilderExtensions.cs
public static class EndpointRouterBuilderExtensions
{
    public static void RegisterAllEndpoints(
        this IEndpointRouteBuilder app)
    {
        app.RegisterDishesEndpoints();
        app.RegisterIngredientsEndpoints();
        // add each new module here
    }

    public static void RegisterDishesEndpoints(
        this IEndpointRouteBuilder endpointRouteBuilder)
    {
        var dishesEndpoints = endpointRouteBuilder
            .MapGroup("/dishes")
            .RequireAuthorization()
            .WithTags("Dishes");
        // ... MapGet, MapPost, etc.
    }
}

Pattern 2: Automatic discovery via IEndpointDefinition

// Endpoints/IEndpointDefinition.cs
public interface IEndpointDefinition
{
    void RegisterEndpoints(IEndpointRouteBuilder builder);
}

// Endpoints/DishesEndpointDefinitions.cs
public class DishesEndpointDefinitions : IEndpointDefinition
{
    public void RegisterEndpoints(IEndpointRouteBuilder builder)
    {
        var dishesGroup = builder.MapGroup("/dishes")
            .RequireAuthorization()
            .WithTags("Dishes");
        // ... MapGet, MapPost, etc.
    }
}

// Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static void RegisterEndpointDefinitions(
        this IEndpointRouteBuilder app)
    {
        // Reflection-based discovery of all implementations
        var endpointDefinitions = typeof(Program).Assembly
            .GetTypes()
            .Where(t => !t.IsAbstract && !t.IsInterface
                && typeof(IEndpointDefinition).IsAssignableFrom(t))
            .Select(Activator.CreateInstance)
            .Cast<IEndpointDefinition>();

        foreach (var definition in endpointDefinitions)
            definition.RegisterEndpoints(app);
    }
}

// Program.cs
app.RegisterEndpointDefinitions(); // a single line

Advantage: Adding a new module = creating a new IEndpointDefinition class, without touching Program.cs.


Replacing Manual Mapping with Mapperly

When to Migrate from Manual Mapping?

SignalRecommended action
< 3 properties per classManual mapping is fine
> 5 properties per classConsider Mapperly
Many entity/DTO pairsMigrate to Mapperly
Mappings with complex logic (flattening)Mapperly
Runtime reflection not recommendedMapperly (source generator)

AutoMapper vs Mapperly

CriterionAutoMapperMapperly
MechanismRuntime reflectionSource generator (compile time)
PerformanceReflection overheadZero overhead (generated code = direct assignments)
ErrorsDetected at runtimeDetected at compile time
LicenseNon-OSS for commercial useMIT
DebuggingGenerated code not visibleGenerated code readable in .g.cs files
// Mappers/DishMapper.cs
using Riok.Mapperly.Abstractions;

[Mapper]
public static partial class DishMapper
{
    // Mapperly generates: new DishDto { Id = dish.Id, Name = dish.Name }
    [MapperIgnoreSource(nameof(Dish.Ingredients))]
    public static partial DishDto ToDishDto(this Dish dish);

    public static partial IEnumerable<DishDto> ToDishDtoList(
        this IEnumerable<Dish> dishes);

    // Entity → creation DTO mapping (ignores Id and Ingredients)
    [MapperIgnoreTarget(nameof(Dish.Id))]
    [MapperIgnoreTarget(nameof(Dish.Ingredients))]
    public static partial Dish ToDish(
        this DishForCreationDto dishForCreationDto);

    // Partial update (update in place)
    [MapperIgnoreTarget(nameof(Dish.Id))]
    [MapperIgnoreTarget(nameof(Dish.Ingredients))]
    public static partial void UpdateFromDto(
        this DishForUpdateDto dishForUpdateDto, Dish dish);

    // Private method for ingredient mapping without circular properties
    [MapperIgnoreSource(nameof(Ingredient.Dishes))]
    [MapperIgnoreTarget(nameof(IngredientDto.DishId))]
    private static partial IngredientDto MapIngredient(Ingredient ingredient);

    // Public method enriched with DishId (logic not directly mappable)
    public static IngredientDto ToIngredientDto(
        this Ingredient ingredient, Guid dishId)
    {
        var dto = MapIngredient(ingredient);
        dto.DishId = dishId;
        return dto;
    }
}

The partial keyword is essential: it tells the compiler that the source generator will complete the method bodies. The generated code is visible in obj/Debug/net10.0/*.g.cs.


3. Endpoint Filters and Middleware Coordination

Filters vs. Middleware — When to Use Which

flowchart LR
    subgraph MW ["Middleware (global)"]
        EH2[Exception Handler]
        HTTPS2[HTTPS Redirect]
        AUTH_N2[Authentication]
        AUTH_Z2[Authorization]
    end
    subgraph EP_EXEC ["Endpoint Execution (RequestDelegate)"]
        subgraph FILTER_CHAIN ["Endpoint Filter Chain"]
            F1[Filter 1\nPerformanceTracking]
            F2[Filter 2\nDishIsLocked]
            F3[Filter 3\nLogNotFound]
            HANDLER2[Handler]
        end
    end

    MW --> EP_EXEC
    F1 --> F2
    F2 --> F3
    F3 --> HANDLER2
    HANDLER2 --> F3
    F3 --> F2
    F2 --> F1
CriterionMiddlewareEndpoint Filter
ScopeAll requestsSpecific endpoint or group
Available contextRaw HttpContextStrongly typed handler arguments + result
Position in the pipelineOutside endpoint executionInside the RequestDelegate
Typical use casesCompression, global logging, exception handling, authBusiness validation, response transformation, per-endpoint performance tracking
Access to handler parametersNoYes (context.Arguments)
Access to typed resultNoYes (via INestedHttpResult)

Building Reusable Filter Chains

Resource locking filter

// EndpointFilters/DishIsLockedFilter.cs
public class DishIsLockedFilter : IEndpointFilter
{
    private readonly Guid _lockedDishId =
        Guid.Parse("fd630a57-2352-4731-b25c-db9cc7601b16");

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        // Reading via HttpContext — works regardless of the handler signature
        var dishIdValue = context.HttpContext.Request.RouteValues["dishId"];

        if (dishIdValue is not null
            && Guid.TryParse(dishIdValue.ToString(), out var dishId)
            && dishId == _lockedDishId)
        {
            return TypedResults.Problem(
                statusCode: StatusCodes.Status403Forbidden,
                title: "Dish is locked",
                detail: $"The dish with id {dishId} is locked and cannot be modified.");
        }

        return await next(context); // continue to the handler
    }
}

Performance tracking filter

// EndpointFilters/PerformanceTrackingFilter.cs
public class PerformanceTrackingFilter : IEndpointFilter
{
    private readonly ILogger<PerformanceTrackingFilter> _logger;

    public PerformanceTrackingFilter(ILogger<PerformanceTrackingFilter> logger)
    {
        _logger = logger;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var stopwatch = Stopwatch.StartNew();
        var result = await next(context); // execute the handler
        stopwatch.Stop();

        _logger.LogInformation(
            "Endpoint {Method} {Path} completed in {ElapsedMs}ms",
            context.HttpContext.Request.Method,
            context.HttpContext.Request.Path,
            stopwatch.ElapsedMilliseconds);

        return result;
    }
}

Filters Reacting to Responses

When the handler uses union return types like Results<NotFound, Ok<DishDto>>, the result is wrapped in a nested container. It must be unwrapped via INestedHttpResult:

// EndpointFilters/LockNotFoundResponseFilter.cs
public class LockNotFoundResponseFilter : IEndpointFilter
{
    private readonly ILogger<LockNotFoundResponseFilter> _logger;

    public LockNotFoundResponseFilter(
        ILogger<LockNotFoundResponseFilter> logger)
    {
        _logger = logger;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var result = await next(context); // let the handler execute

        // Unwrap the nested result (union types)
        var unwrapped = result;
        while (unwrapped is INestedHttpResult nested)
            unwrapped = nested.Result;

        // Check if it's a 404
        if (unwrapped is IStatusCodeHttpResult { StatusCode: 404 })
        {
            _logger.LogWarning(
                "Resource not found at {Path}",
                context.HttpContext.Request.Path);
        }

        return result;
    }
}

Why INestedHttpResult? Results<NotFound, Ok<DishDto>> is a generic type that encapsulates the actual result. Without unwrapping, the cast to IStatusCodeHttpResult would fail.


Applying Filters to Groups

public void RegisterEndpoints(IEndpointRouteBuilder builder)
{
    var dishesEndpoints = builder.MapGroup("/dishes")
        .RequireAuthorization()
        .WithTags("Dishes")
        // Filter applied to ALL endpoints in the group
        .AddEndpointFilter<PerformanceTrackingFilter>();

    var dishWithGuidIdEndpoints = dishesEndpoints
        .MapGroup("/{dishId:guid}")
        // Filter applied to endpoints by ID (GET, PUT, DELETE)
        .AddEndpointFilter<LockNotFoundResponseFilter>();

    dishesEndpoints.MapGet("", DishesHandlers.GetDishesAsync);

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

    // Filters specific to PUT and DELETE only
    dishWithGuidIdEndpoints.MapPut("", DishesHandlers.UpdateDishAsync)
        .RequireAuthorization("RequireAdminFromBelgium")
        .AddEndpointFilter<DishIsLockedFilter>();

    dishWithGuidIdEndpoints.MapDelete("", DishesHandlers.DeleteDishAsync)
        .RequireAuthorization("RequireAdminFromBelgium")
        .AddEndpointFilter<DishIsLockedFilter>();
}

Execution order of filters in a chain:

sequenceDiagram
    participant Client
    participant PerformanceFilter
    participant LockNotFoundFilter
    participant DishIsLockedFilter
    participant Handler

    Client->>PerformanceFilter: Request (Stopwatch.Start)
    PerformanceFilter->>LockNotFoundFilter: next(context)
    LockNotFoundFilter->>DishIsLockedFilter: next(context)
    DishIsLockedFilter->>Handler: next(context) [if not locked]
    Handler-->>DishIsLockedFilter: result
    DishIsLockedFilter-->>LockNotFoundFilter: result
    LockNotFoundFilter-->>PerformanceFilter: result [log if 404]
    PerformanceFilter-->>Client: result [Stopwatch.Stop + log]

4. Advanced Validation and Error Handling Strategies

Custom Validation Logic Beyond Data Annotations

Data Annotations cover simple cases (Required, StringLength, Range, RegularExpression). To go further:

ScenarioApproach
Cross-field validation (StartDate < EndDate)IValidatableObject
Conditional validation (field required if another field = value X)IValidatableObject
Validation dependent on external state (database, feature flag)IValidatableObject + service injected via DI
Complex reusable multi-project validationFluentValidation
// Models/DishForCreationDto.cs
public class DishForCreationDto : IValidatableObject
{
    [Required]
    [StringLength(100, MinimumLength = 2)]
    public required string Name { get; set; }

    // IValidatableObject — called AFTER data annotations
    public IEnumerable<ValidationResult> Validate(
        ValidationContext validationContext)
    {
        // Business rule: the name cannot contain "test"
        if (Name.Contains("test", StringComparison.OrdinalIgnoreCase))
        {
            yield return new ValidationResult(
                "The name cannot contain 'test'.",
                new[] { nameof(Name) });
        }
    }
}

Filter-Based Validation Pipeline

A generic validation filter that works with any DTO type:

// EndpointFilters/ValidateAnnotationsFilter.cs
public class ValidateAnnotationsFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        foreach (var argument in context.Arguments)
        {
            if (argument is null) continue;

            var validationResults = new List<ValidationResult>();
            var validationContext = new ValidationContext(argument);

            // Data Annotations validation
            if (!Validator.TryValidateObject(
                argument, validationContext, validationResults,
                validateAllProperties: true))
            {
                return TypedResults.ValidationProblem(
                    validationResults
                        .Where(vr => vr.ErrorMessage is not null)
                        .ToDictionary(
                            vr => vr.MemberNames.FirstOrDefault() ?? string.Empty,
                            vr => new[] { vr.ErrorMessage! }));
            }

            // IValidatableObject validation (cross-field, conditional)
            if (argument is IValidatableObject validatable)
            {
                var customResults = validatable
                    .Validate(validationContext).ToList();

                if (customResults.Count > 0
                    && customResults.Any(r => r != ValidationResult.Success))
                {
                    return TypedResults.ValidationProblem(
                        customResults
                            .Where(vr => vr != ValidationResult.Success
                                && vr.ErrorMessage is not null)
                            .ToDictionary(
                                vr => vr.MemberNames.FirstOrDefault() ?? string.Empty,
                                vr => new[] { vr.ErrorMessage! }));
                }
            }
        }

        return await next(context);
    }
}

Advantage vs AddValidation(): This filter can be applied selectively to certain groups only, and allows differentiating validation by context (creation vs update).

// Program.cs — choose one:
// builder.Services.AddValidation();  // global, built-in .NET 10
// OR apply the filter to the group:
dishesEndpoints.AddEndpointFilter<ValidateAnnotationsFilter>();

Centralized Error Handling Strategy

flowchart TD
    subgraph Error sources
        V["Validation failures\n(AddValidation or ValidateAnnotationsFilter)\n→ 400 ValidationProblem"]
        NF["Handler NotFound\n(TypedResults.NotFound)\n→ 404 ProblemDetails"]
        LOCK["DishIsLockedFilter\n(TypedResults.Problem)\n→ 403 ProblemDetails"]
        AUTH_N3["Authentication failure\n→ 401 ProblemDetails"]
        AUTH_Z3["Authorization failure\n→ 403 ProblemDetails"]
        EX["Unhandled exceptions\n(ExceptionHandler middleware)\n→ 500 ProblemDetails"]
    end

    subgraph Normalization
        PD["AddProblemDetails()\n(Program.cs)"]
    end

    V --> PD
    NF --> PD
    LOCK --> PD
    AUTH_N3 --> PD
    AUTH_Z3 --> PD
    EX --> PD

    PD --> RFC["RFC 9457 Problem Details\nContent-Type: application/problem+json"]

Configuration in Program.cs:

builder.Services.AddProblemDetails(); // normalizes ALL errors to ProblemDetails
builder.Services.AddValidation();     // built-in .NET 10 validation → ProblemDetails

// Exception handler middleware (must be first)
app.UseExceptionHandler();

Standard ProblemDetails response (RFC 9457):

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": ["The name cannot contain 'test'."],
    "": ["The Name field is required."]
  }
}

5. API Versioning

Versioning Strategies

StrategyURL exampleAdvantagesDisadvantages
URL Path/v1/dishes, /v2/dishesVisible, easily testable, cache-friendlyModifies the resource URI
Query String/dishes?api-version=1.0Resource URI unchangedLess visible, potential caching issues
Headerapi-version: 1.0 (HTTP header)Clean URLNot visible in the URL, less convenient to test

The course uses URL Path versioning — the most common in practice and the simplest to demonstrate.


Implementing Versioning with Asp.Versioning.Http

// Program.cs
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true; // v1 by default
    options.ReportApiVersions = true; // api-supported-versions header
});
// Models/DishV2Dto.cs — new v2 contract (breaking change)
public class DishV2Dto
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int IngredientCount { get; set; } // new field → breaking change
}
// Mappers/DishMapper.cs — v2 mapping with custom logic
public static DishV2Dto ToDishV2Dto(this Dish dish)
{
    return new DishV2Dto
    {
        Id = dish.Id,
        Name = dish.Name,
        // IngredientCount requires Ingredients to be loaded (Include)
        IngredientCount = dish.Ingredients?.Count ?? 0
    };
}
// Endpoints/DishesEndpointDefinitions.cs — versioned endpoints
public class DishesEndpointDefinitions : IEndpointDefinition
{
    public void RegisterEndpoints(IEndpointRouteBuilder builder)
    {
        // v1 group
        var dishesV1 = builder.MapGroup("/v1/dishes")
            .WithGroupName("v1")
            .RequireAuthorization()
            .WithTags("Dishes");

        dishesV1.MapGet("", DishesHandlers.GetDishesAsync);
        dishesV1.MapGet("/{dishId:guid}", DishesHandlers.GetDishByIdAsync);
        // ...

        // v2 group — new or enriched handlers
        var dishesV2 = builder.MapGroup("/v2/dishes")
            .WithGroupName("v2")
            .RequireAuthorization()
            .WithTags("Dishes");

        dishesV2.MapGet("", DishesHandlers.GetDishesV2Async);
        dishesV2.MapGet("/{dishId:guid}", DishesHandlers.GetDishByIdV2Async);
    }
}

Automatic response header:

api-supported-versions: 1.0, 2.0

Version-Aware OpenAPI Generation

// Program.cs — two separate OpenAPI documents
builder.Services.AddOpenApi("v1", options =>
{
    options.AddDocumentTransformer((document, context, ct) =>
    {
        document.Info = new OpenApiInfo
        {
            Title = "DishesAPI",
            Version = "1",
            Description = "An API for managing dishes and their ingredients."
        };

        // JWT Bearer security
        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."
        };

        // Replace {version} with the version number in paths
        var paths = document.Paths.ToDictionary(
            p => p.Key.Replace("{version}", document.Info.Version),
            p => p.Value);
        document.Paths.Clear();
        foreach (var (key, value) in paths)
            document.Paths.Add(key, value);

        return Task.CompletedTask;
    });
});

builder.Services.AddOpenApi("v2", options =>
{
    options.AddDocumentTransformer((document, context, ct) =>
    {
        document.Info = new OpenApiInfo { Title = "DishesAPI", Version = "2" };
        // ... same security configuration
        return Task.CompletedTask;
    });
});

// app.MapOpenApi() exposes the documents:
// /openapi/v1.json and /openapi/v2.json
flowchart LR
    V1["/openapi/v1.json\nGET/POST/PUT/DELETE /v1/dishes\nDishDto (without IngredientCount)"]
    V2["/openapi/v2.json\nGET /v2/dishes\nDishV2Dto (with IngredientCount)"]
    SCALAR["/scalar\nScalar UI (Swagger-like)"]

    V1 --> SCALAR
    V2 --> SCALAR

6. Streaming, Server-Sent Events, and Architectural Trade-offs

Streaming with IAsyncEnumerable

flowchart LR
    subgraph Standard ["Standard response (List)"]
        DB1[Database] -->|ToListAsync\nloads EVERYTHING into memory| MEM["Memory\n(full dataset)"]
        MEM -->|Serializes all| RESP1[Response\nContent-Length: X]
        RESP1 -->|Client waits| CLIENT1[Client receives\neverything at once]
    end

    subgraph Streaming ["Streaming (IAsyncEnumerable)"]
        DB2[Database] -->|AsAsyncEnumerable\nrow by row| ITER[Iterator]
        ITER -->|yield return| RESP2["Response\nTransfer-Encoding: chunked"]
        RESP2 -->|Items as they arrive| CLIENT2[Client receives\nincrementally]
    end
// EndpointHandlers/DishesStreamingHandlers.cs
public static class DishesStreamingHandlers
{
    // IAsyncEnumerable<T> → ASP.NET Core streams automatically as JSON chunked
    public static async IAsyncEnumerable<DishDto> GetDishesStreamAsync(
        DishesDbContext dishesDbContext,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        // AsAsyncEnumerable() — direct streaming from EF Core
        // NO ToListAsync() — avoids loading everything into memory
        await foreach (var dish in dishesDbContext.Dishes
            .AsAsyncEnumerable()
            .WithCancellation(cancellationToken)) // cancel if client disconnects
        {
            yield return dish.ToDishDto();
        }
    }
}
// Registering the streaming endpoint
dishesEndpoints.MapGet("/stream", DishesStreamingHandlers.GetDishesStreamAsync);

Resulting HTTP response:

HTTP/1.1 200 OK
Transfer-Encoding: chunked   ← no Content-Length, size unknown in advance
Content-Type: application/json

Important nuance: Streaming only helps if the data source produces data incrementally. Calling ToListAsync() then iterating is loading everything into memory then simulating streaming — the worst of both worlds.


Server-Sent Events (SSE)

SSE is a standard HTTP mechanism for pushing events from the server to the client over a long-lived connection. ASP.NET Core 10 includes native support via TypedResults.ServerSentEvents.

SSE vs WebSockets:

CriterionSSEWebSockets
DirectionUnidirectional (server → client)Bidirectional
ProtocolStandard HTTPHTTP upgrade → TCP
Proxies/FirewallsTraverses without configurationMay require configuration
Automatic reconnectionYes (Last-Event-ID)No (must be handled manually)
Use casesNotifications, progress, live feed, AI streamingChat, gaming, real-time collaboration
ComplexitySimpleMore complex

SSE event format:

id: 1
event: dish
data: {"id":"fd630a57-...","name":"Rendang"}

id: 2
event: dish
data: {"id":"a1b2c3d4-...","name":"Paella"}

event: done
data: stream complete
// EndpointHandlers/DishesStreamingHandlers.cs
public static class DishesStreamingHandlers
{
    // Returns ServerSentEventsResult<string> — Typed Result for SSE
    public static ServerSentEventsResult<string> GetDishesSseStream(
        DishesDbContext dishesDbContext,
        CancellationToken cancellationToken)
    {
        return TypedResults.ServerSentEvents(
            GenerateDishEvents(dishesDbContext, cancellationToken));
    }

    // IAsyncEnumerable<SseItem<string>> is required by TypedResults.ServerSentEvents
    private static async IAsyncEnumerable<SseItem<string>> GenerateDishEvents(
        DishesDbContext dishesDbContext,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var dishes = await dishesDbContext.Dishes
            .ToListAsync(cancellationToken);

        foreach (var dish in dishes)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var dto = dish.ToDishDto();
            var json = JsonSerializer.Serialize(dto);

            // SseItem<string>(data, eventType)
            yield return new SseItem<string>(json, "dish");

            // Simulate a delay between events (e.g., queue drain, computation)
            await Task.Delay(500, cancellationToken);
        }

        // Final event — end-of-stream signal
        yield return new SseItem<string>("stream complete", "done");
    }
}
// Registering the SSE endpoint
dishesEndpoints.MapGet("/sse", DishesStreamingHandlers.GetDishesSseStream);

HTTP response:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Trade-offs: Extensibility and Testability

quadrantChart
    title Minimal APIs vs Controller-based APIs
    x-axis Simplicity --> Feature richness
    y-axis Low overhead --> High overhead
    quadrant-1 Overkill for most cases
    quadrant-2 Ideal for complex APIs
    quadrant-3 Simple cases / microservices
    quadrant-4 Good balance
    Minimal APIs: [0.35, 0.2]
    Controller-based APIs: [0.75, 0.75]
CapabilityMinimal APIsController-based APIs
Filter pipelineIEndpointFilter (before/after)Authorization, Resource, Action, Result, Exception filters
Model bindingRequestDelegateFactory (DI, route, body, query)Custom model binding providers
Content typesJSON by defaultInput/Output formatters for XML, CSV, etc.
OrganizationRoute groups + IEndpointDefinitionControllers + Actions + Areas
ConventionsWithMetadata, AddEndpointFilterIApplicationModelConvention, IControllerModelConvention
TestabilityDirect (unit-testable lambdas)Via WebApplicationFactory / mocks
VersioningAsp.Versioning.HttpAsp.Versioning.Mvc
OpenAPIAddOpenApi() (native .NET 9+)AddOpenApi() or Swashbuckle

When controller-based APIs are necessary:

  • Custom model binding providers for exotic content types
  • Non-JSON support (XML, CSV, binary) with custom formatters
  • Result filters transforming each response in a complex way
  • Deep integration with OData or other MVC-specific frameworks
  • Team with strong MVC experience and legacy codebase

Trade-offs: Performance and Team Conventions

Performance differences:

flowchart LR
    subgraph Minimal ["Minimal APIs (per request)"]
        RDF["RequestDelegate\n(generated at startup)"]
        HANDLER3["Direct handler"]
        RDF --> HANDLER3
    end

    subgraph Controllers ["Controller-based APIs (per request)"]
        CTRL_INST["Controller instantiation"]
        MVC_PIPE["MVC filter pipeline\n(full traversal)"]
        AD["ActionDescriptor lookup"]
        ACTION["Action method"]
        CTRL_INST --> MVC_PIPE --> AD --> ACTION
    end
ScenarioImpact of the performance difference
Classic CRUD API (DB calls 5-50ms)Negligible — network and DB dominate
High-frequency microservices (>10k req/s, minimal logic)Significant — framework overhead is a non-negligible percentage
Serverless cold starts (Lambda, Azure Functions)Important — faster startup with Minimal APIs
Health check endpointsRelevant — ultra-frequent requests with no logic

Practical rule: Choose the model that makes the team most productive. Performance only matters in specific high-frequency architectural scenarios.


7. Advanced Reference Tables

Typed Results — Complete Reference

MethodStatus CodeReturn typeUse case
TypedResults.Ok(value)200Ok<T>Successful response with body
TypedResults.Created(uri, value)201Created<T>Resource created
TypedResults.NoContent()204NoContentSuccess without body (DELETE, PUT)
TypedResults.NotFound()404NotFoundResource not found
TypedResults.BadRequest()400BadRequestInvalid request
TypedResults.ValidationProblem(errors)400ValidationProblemValidation errors (RFC 9457)
TypedResults.Problem(...)variableProblemHttpResultError with ProblemDetails
TypedResults.Unauthorized()401UnauthorizedHttpResultNot authenticated
TypedResults.Forbid()403ForbidHttpResultNot authorized
TypedResults.ServerSentEvents(source)200ServerSentEventsResult<T>SSE stream
TypedResults.Redirect(url)302RedirectHttpResultRedirect

Endpoint Configuration Extension Methods

MethodEffectAdded metadata
.WithName("name")Route name for CreatedAtRouteIEndpointNameMetadata
.WithTags("tag")OpenAPI groupingITagsMetadata
.WithSummary("text")Short OpenAPI descriptionIEndpointSummaryMetadata
.WithDescription("text")Long OpenAPI descriptionIEndpointDescriptionMetadata
.RequireAuthorization()Requires auth (inherits to children)IAuthorizeData
.AllowAnonymous()Overrides RequireAuthorizationIAllowAnonymous
.AddEndpointFilter<T>()Adds a filter
.WithMetadata(obj)Custom metadatacustom object
.ProducesValidationProblem(400)OpenAPI documentationIProducesResponseTypeMetadata
.Produces<T>(200)OpenAPI documentationIProducesResponseTypeMetadata

Key NuGet Packages

PackageUsage
Asp.Versioning.HttpAPI versioning (URL path, query string, header)
Riok.MapperlySource generator mapper (replaces AutoMapper)
Scalar.AspNetCoreOpenAPI UI (modern Swagger alternative)
Microsoft.EntityFrameworkCore.SqliteEF Core + SQLite

Advanced Minimal APIs Architecture Checklist

  • AddProblemDetails() configured → all errors as ProblemDetails RFC 9457
  • UseExceptionHandler() first in the pipeline
  • Route groups organized in a hierarchy with cascading conventions
  • IEndpointDefinition for automatic module discovery
  • IEndpointFilter for cross-cutting concerns (validation, logging, security)
  • IAsyncEnumerable<T> for streaming large datasets
  • TypedResults.ServerSentEvents for real-time events
  • AddApiVersioning + separate OpenAPI documents per version
  • Mapperly ([Mapper] + partial methods) to replace manual mapping
  • Typed Results with union types (Results<Ok<T>, NotFound>) for type safety

Search Terms

asp.net · minimal · apis · deep · dive · core · web · c# · .net · development · endpoint · filters · conventions · filter · mapping · middleware · trade-offs · validation · versioning · architecture · error · events · extension · groups

Interested in this course?

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