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
- Internal Architecture of Minimal APIs
- Advanced Routing, Endpoint Composition, and Mapping
- Endpoint Filters and Middleware Coordination
- Advanced Validation and Error Handling Strategies
- API Versioning
- Streaming, Server-Sent Events, and Architectural Trade-offs
- 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 onIEndpointRouteBuilder- Each call creates a
RouteEndpointobject with three components: route pattern, metadata, andRequestDelegate - For controller-based APIs, the
RequestDelegateis the MVC action invoker — powerful but heavy (controller instantiation, full MVC filter pipeline) - For Minimal APIs, the
RequestDelegateis generated at startup byRequestDelegateFactory, 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:
| Position | Middleware | Role |
|---|---|---|
| 1 | ExceptionHandler | Catches unhandled exceptions (global wrapper) |
| 2 | HTTPS Redirection | Redirects HTTP → HTTPS |
| 3 | Routing | Selects the RouteEndpoint matching the URI (but does not execute it yet) |
| 4 | Authentication | Validates the JWT token, establishes HttpContext.User |
| 5 | Authorization | Checks policies based on endpoint metadata |
| 6 | Endpoint | Executes 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:
| Metadata | Consumed by |
|---|---|
| Authorization metadata | Authorization middleware |
| Tags/Description | OpenAPI generator (Scalar, Swagger) |
| CORS metadata | CORS middleware |
| Custom attribute | Endpoint 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
IEndpointDefinitionclass, without touchingProgram.cs.
Replacing Manual Mapping with Mapperly
When to Migrate from Manual Mapping?
| Signal | Recommended action |
|---|---|
| < 3 properties per class | Manual mapping is fine |
| > 5 properties per class | Consider Mapperly |
| Many entity/DTO pairs | Migrate to Mapperly |
| Mappings with complex logic (flattening) | Mapperly |
| Runtime reflection not recommended | Mapperly (source generator) |
AutoMapper vs Mapperly
| Criterion | AutoMapper | Mapperly |
|---|---|---|
| Mechanism | Runtime reflection | Source generator (compile time) |
| Performance | Reflection overhead | Zero overhead (generated code = direct assignments) |
| Errors | Detected at runtime | Detected at compile time |
| License | Non-OSS for commercial use | MIT |
| Debugging | Generated code not visible | Generated 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
partialkeyword is essential: it tells the compiler that the source generator will complete the method bodies. The generated code is visible inobj/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
| Criterion | Middleware | Endpoint Filter |
|---|---|---|
| Scope | All requests | Specific endpoint or group |
| Available context | Raw HttpContext | Strongly typed handler arguments + result |
| Position in the pipeline | Outside endpoint execution | Inside the RequestDelegate |
| Typical use cases | Compression, global logging, exception handling, auth | Business validation, response transformation, per-endpoint performance tracking |
| Access to handler parameters | No | Yes (context.Arguments) |
| Access to typed result | No | Yes (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 toIStatusCodeHttpResultwould 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:
| Scenario | Approach |
|---|---|
| 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 validation | FluentValidation |
// 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
| Strategy | URL example | Advantages | Disadvantages |
|---|---|---|---|
| URL Path | /v1/dishes, /v2/dishes | Visible, easily testable, cache-friendly | Modifies the resource URI |
| Query String | /dishes?api-version=1.0 | Resource URI unchanged | Less visible, potential caching issues |
| Header | api-version: 1.0 (HTTP header) | Clean URL | Not 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:
| Criterion | SSE | WebSockets |
|---|---|---|
| Direction | Unidirectional (server → client) | Bidirectional |
| Protocol | Standard HTTP | HTTP upgrade → TCP |
| Proxies/Firewalls | Traverses without configuration | May require configuration |
| Automatic reconnection | Yes (Last-Event-ID) | No (must be handled manually) |
| Use cases | Notifications, progress, live feed, AI streaming | Chat, gaming, real-time collaboration |
| Complexity | Simple | More 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]
| Capability | Minimal APIs | Controller-based APIs |
|---|---|---|
| Filter pipeline | IEndpointFilter (before/after) | Authorization, Resource, Action, Result, Exception filters |
| Model binding | RequestDelegateFactory (DI, route, body, query) | Custom model binding providers |
| Content types | JSON by default | Input/Output formatters for XML, CSV, etc. |
| Organization | Route groups + IEndpointDefinition | Controllers + Actions + Areas |
| Conventions | WithMetadata, AddEndpointFilter | IApplicationModelConvention, IControllerModelConvention |
| Testability | Direct (unit-testable lambdas) | Via WebApplicationFactory / mocks |
| Versioning | Asp.Versioning.Http | Asp.Versioning.Mvc |
| OpenAPI | AddOpenApi() (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
| Scenario | Impact 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 endpoints | Relevant — 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
| Method | Status Code | Return type | Use case |
|---|---|---|---|
TypedResults.Ok(value) | 200 | Ok<T> | Successful response with body |
TypedResults.Created(uri, value) | 201 | Created<T> | Resource created |
TypedResults.NoContent() | 204 | NoContent | Success without body (DELETE, PUT) |
TypedResults.NotFound() | 404 | NotFound | Resource not found |
TypedResults.BadRequest() | 400 | BadRequest | Invalid request |
TypedResults.ValidationProblem(errors) | 400 | ValidationProblem | Validation errors (RFC 9457) |
TypedResults.Problem(...) | variable | ProblemHttpResult | Error with ProblemDetails |
TypedResults.Unauthorized() | 401 | UnauthorizedHttpResult | Not authenticated |
TypedResults.Forbid() | 403 | ForbidHttpResult | Not authorized |
TypedResults.ServerSentEvents(source) | 200 | ServerSentEventsResult<T> | SSE stream |
TypedResults.Redirect(url) | 302 | RedirectHttpResult | Redirect |
Endpoint Configuration Extension Methods
| Method | Effect | Added metadata |
|---|---|---|
.WithName("name") | Route name for CreatedAtRoute | IEndpointNameMetadata |
.WithTags("tag") | OpenAPI grouping | ITagsMetadata |
.WithSummary("text") | Short OpenAPI description | IEndpointSummaryMetadata |
.WithDescription("text") | Long OpenAPI description | IEndpointDescriptionMetadata |
.RequireAuthorization() | Requires auth (inherits to children) | IAuthorizeData |
.AllowAnonymous() | Overrides RequireAuthorization | IAllowAnonymous |
.AddEndpointFilter<T>() | Adds a filter | — |
.WithMetadata(obj) | Custom metadata | custom object |
.ProducesValidationProblem(400) | OpenAPI documentation | IProducesResponseTypeMetadata |
.Produces<T>(200) | OpenAPI documentation | IProducesResponseTypeMetadata |
Key NuGet Packages
| Package | Usage |
|---|---|
Asp.Versioning.Http | API versioning (URL path, query string, header) |
Riok.Mapperly | Source generator mapper (replaces AutoMapper) |
Scalar.AspNetCore | OpenAPI UI (modern Swagger alternative) |
Microsoft.EntityFrameworkCore.Sqlite | EF 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
-
IEndpointDefinitionfor automatic module discovery -
IEndpointFilterfor cross-cutting concerns (validation, logging, security) -
IAsyncEnumerable<T>for streaming large datasets -
TypedResults.ServerSentEventsfor real-time events -
AddApiVersioning+ separate OpenAPI documents per version - Mapperly (
[Mapper]+partialmethods) 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