Minimal APIs bien hechos en .NET 10: validación, versionado y OpenAPI sin controladores
La vez pasada publicamos la API de pedidos de Aurora Coffee Co. con Native AOT y logramos que arrancara en un parpadeo. Lo que no hicimos fue convertirla en un servicio del que alguien debería depender. Acepta con gusto un pedido de menos cinco bolsas de café. No tiene versión en la URL, así que el primer cambio incompatible que publiques rompe a todos los clientes al mismo tiempo. Y no le dice absolutamente nada sobre su propia forma al equipo que quiera integrarse — "lee el código fuente, supongo".
Eso está bien para medir tiempo de arranque. En producción es un pasivo. Este post cierra la brecha: validación, versionado y OpenAPI sobre un Minimal API, sin un solo controlador. La buena noticia es que .NET 10 por fin trae una respuesta de primera mano para el más difícil de los tres.
Dónde quedamos
El post de AOT terminó con dos endpoints y un almacén singleton:
var orders = app.MapGroup("/orders");
orders.MapGet("/{id}", (string id, OrdersStore store) =>
store.Find(id) is { } order ? Results.Ok(order) : Results.NotFound());
orders.MapPost("/", (PlaceOrderRequest request, OrdersStore store) =>
{
var order = store.Place(request.Sku, request.Quantity);
return Results.Created($"/orders/{order.Id}", order);
});
Tres huecos, en el orden en que te van a doler:
-
Nada valida la petición.
{"sku": "", "quantity": -5}se convierte en un pedido real. - Nada versiona el contrato. Renombra un campo del JSON y todos los clientes se rompen a la vez.
- Nada describe la API. Sin esquema, sin documentación, sin cliente generado.
Los arreglamos en ese mismo orden.
Paso 1: los grupos son la costura donde se engancha todo lo demás
MapGroup parece una comodidad para compartir un prefijo de URL. En realidad es el punto de extensión al que se enganchan la validación, los filtros, el versionado y los metadatos de OpenAPI — aplica una convención al grupo y todos los endpoints que contiene la heredan.
Saca los handlers de Program.cs. Los métodos con nombre dentro de una clase estática le ganan a las lambdas por tres razones: puedes probarlos unitariamente, el compilador infiere metadatos de OpenAPI más ricos a partir de sus firmas y — novedad en .NET 10 — sus comentarios XML llegan hasta el documento de OpenAPI.
internal static class OrderEndpoints
{
/// <summary>Busca un pedido por su identificador.</summary>
/// <param name="id">El identificador del pedido, por ejemplo <c>A-2001</c>.</param>
public static Results<Ok<OrderV1Response>, NotFound> GetOrderV1(string id, OrdersStore store)
=> store.Find(id) is { } order
? TypedResults.Ok(new OrderV1Response(order.Id, order.Sku, order.Quantity, order.Status))
: TypedResults.NotFound();
}
Vale la pena detenerse en dos detalles. Results<Ok<T>, NotFound> es una unión discriminada: el compilador rechaza cualquier retorno que no sea uno de los tipos declarados, y OpenAPI lee tanto el 200 como el 404 directamente de la firma — sin necesidad de .Produces<T>(). Y el handler devuelve OrderV1Response, no el Order del dominio. Esa separación parece ceremonia hasta el Paso 3, donde es la razón completa de que el versionado sea llevadero.
Los comentarios XML solo llegan al documento si habilitas el archivo de documentación:
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
Paso 2: validación, por fin integrada
Antes de .NET 10 la respuesta era FluentValidation o un filtro escrito a mano. Ahora System.ComponentModel.DataAnnotations funciona directamente sobre los parámetros de un Minimal API, conectado por un generador de código fuente:
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
Esa es toda la configuración. Microsoft.Extensions.Validation vive en el shared framework de ASP.NET Core, así que un proyecto Microsoft.NET.Sdk.Web no necesita ningún PackageReference — una biblioteca de clases común sí. Y no, no hace falta el conjuro de InterceptorsNamespaces en el csproj que aparece en los posts de la época de preview; el SDK lo agrega por ti en net10.0.
Ahora anota la petición:
public sealed record PlaceOrderRequest(
[property: Required]
[property: RegularExpression(
@"^[A-Z]{3}-[A-Z0-9]{2,4}$",
ErrorMessage = "El SKU debe verse como ETH-250 o COL-1KG.")]
string Sku,
[property: Range(1, 100, ErrorMessage = "Los pedidos están limitados a 100 unidades por línea.")]
int Quantity);
No omitas el prefijo [property:]. En un record posicional, un atributo que es válido tanto en un parámetro como en una propiedad se aplica al parámetro por defecto, y el validador lee propiedades. Si lo dejas fuera, tu validación no hace nada en silencio — el peor modo de falla posible, porque el endpoint sigue devolviendo 201 y te enteras por un ticket de soporte.
Una petición inválida ahora recibe un 400 con application/problem+json, con la forma de RFC 9457:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Sku": ["El SKU debe verse como ETH-250 o COL-1KG."],
"Quantity": ["Los pedidos están limitados a 100 unidades por línea."]
}
}
Registrar AddProblemDetails() es lo que te permite dar forma a ese payload de manera global en vez de quedarte con el predeterminado.
Tres cosas que debes saber antes de confiar en esto:
-
Desactívalo con
.DisableValidation(). Es genérico sobreIEndpointConventionBuilder, así que funciona en un endpoint o en un grupo heredado completo. -
El generador solo ve el ensamblado donde se llama a
AddValidation(). Si separas tus DTOs en otro proyecto, no encuentra nada — expón una pequeña extensiónAddMyModuleValidation()por ensamblado. -
Los parámetros de tipo valor anulable se omiten en .NET 10. Un
[Range]sobre un parámetroint?se ignora en silencio (dotnet/aspnetcore#67033); está corregido en .NET 11. Mientras tanto, usa un parámetro no anulable o valídalo tú.
Paso 3: las reglas que DataAnnotations no alcanza
DataAnnotations valida la forma: si esta cadena está presente, si este número está en rango. No puede responder "¿realmente tenemos 40 bolsas de ETH-250 en la bodega?", porque eso necesita un servicio. Para eso existe IEndpointFilter.
public sealed class StockReservationFilter(InventoryStore inventory) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var request = context.GetArgument<PlaceOrderRequest>(0);
if (!inventory.TryReserve(request.Sku, request.Quantity))
{
return TypedResults.Problem(
title: "Inventario insuficiente",
detail: $"No hay suficiente {request.Sku} en existencia para cubrir {request.Quantity} unidades.",
statusCode: StatusCodes.Status409Conflict);
}
return await next(context);
}
}
El filtro reserva en vez de consultar. Una llamada a HasStock() seguida de un descuento en el handler es la carrera clásica de comprobar-y-luego-actuar: dos peticiones ven 12 bolsas, ambas reservan 10, y acabas de vender 20 bolsas que no tienes. La reserva tiene que ser atómica, y eso significa un ciclo de comparar-e-intercambiar:
using System.Collections.Concurrent;
public sealed class InventoryStore
{
private readonly ConcurrentDictionary<string, int> _stock = new()
{
["ETH-250"] = 40,
["COL-1KG"] = 12,
};
public bool TryReserve(string sku, int quantity)
{
// La invariante se protege aquí, no en quien llama. Un almacén que un llamador
// defectuoso puede dejar en negativo es un bug aunque hoy el único llamador se porte bien.
if (quantity <= 0) return false;
while (_stock.TryGetValue(sku, out var available))
{
if (available < quantity) return false;
// Solo tiene éxito si nadie cambió el conteo desde que lo leímos.
if (_stock.TryUpdate(sku, available - quantity, available)) return true;
}
return false;
}
}
Regístralo por endpoint, no por grupo — un GET no tiene ningún PlaceOrderRequest que sacar del argumento cero:
v1.MapPost("/", OrderEndpoints.PlaceOrder).AddEndpointFilter<StockReservationFilter>();
Vale la pena memorizar el orden de los filtros, porque es asimétrico: el código antes de await next(...) corre en orden de registro, el código después corre en orden inverso, y los filtros de grupo siempre envuelven a los de endpoint sin importar en qué orden configuraste los grupos.
Una trampa: AddEndpointFilter<T>() construye el filtro una sola vez, cuando se construye el endpoint — es efectivamente un singleton. Inyectar un almacén singleton por constructor está bien; inyectar un DbContext o cualquier cosa con ámbito scoped es una dependencia cautiva que te va a entregar la misma instancia obsoleta durante toda la vida del proceso. En su lugar, resuelve los servicios scoped dentro de InvokeAsync desde context.HttpContext.RequestServices.
Paso 4: versionar, y versionar solo lo que se rompió
Agrega los dos paquetes de la comunidad a los que apunta la propia guía de Microsoft:
dotnet add package Asp.Versioning.Http
dotnet add package Asp.Versioning.OpenApi
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV"; // v1, v1.1, v2
options.SubstituteApiVersionInUrl = true; // necesario para versionar por segmento de URL
})
.AddOpenApi();
ReportApiVersions hace que cada respuesta lleve las cabeceras api-supported-versions y api-deprecated-versions, que es como un cliente descubre una deprecación sin leer tu changelog. ApiVersionReader.Combine acepta la versión desde el segmento de URL o desde una cabecera, así que un cliente que no puede cambiar la estructura de sus URLs todavía tiene salida.
Después declara las versiones:
var orders = app.NewVersionedApi("Orders");
var v1 = orders.MapGroup("/api/v{version:apiVersion}/orders")
.HasApiVersion(1.0)
.WithTags("Orders");
var v2 = orders.MapGroup("/api/v{version:apiVersion}/orders")
.HasApiVersion(2.0)
.WithTags("Orders");
Fíjate en HasApiVersion(1.0) — un double, no una cadena. No existe una sobrecarga que reciba string, por más que algunos ejemplos que circulan por ahí sugieran lo contrario.
Aquí está la parte que la mayoría de los tutoriales de versionado hace mal: versionas los endpoints que se rompieron, no la API completa. La v2 de Aurora renombra status a state, agrega placedAt e introduce un endpoint de listado. El POST no cambió en nada:
v1.MapGet("/{id}", OrderEndpoints.GetOrderV1);
v2.MapGet("/{id}", OrderEndpoints.GetOrderV2);
v2.MapGet("/", OrderEndpoints.ListOrders);
v1.MapPost("/", OrderEndpoints.PlaceOrder).AddEndpointFilter<StockReservationFilter>();
v2.MapPost("/", OrderEndpoints.PlaceOrder).AddEndpointFilter<StockReservationFilter>();
Ese único handler PlaceOrder compartido solo funciona porque devuelve 201 Created con una cabecera Location y sin cuerpo — no hay forma de respuesta versionada sobre la cual discrepar:
/// <summary>Registra un pedido nuevo y devuelve su ubicación.</summary>
public static Created PlaceOrder(PlaceOrderRequest request, OrdersStore store, HttpContext http)
{
var order = store.Place(request.Sku, request.Quantity);
return TypedResults.Created($"{http.Request.Path}/{order.Id}");
}
Como la ubicación se construye a partir de la ruta entrante, un cliente que hizo POST a /api/v2/orders recibe un apuntador a /api/v2/orders/A-2003. El cuerpo de la respuesta es donde las versiones divergen, y este endpoint no tiene uno.
Paso 5: OpenAPI sin Swashbuckle
Microsoft.AspNetCore.OpenApi reemplazó a Swashbuckle como opción predeterminada en .NET 9, y .NET 10 lo afinó: OpenAPI 3.1 ahora es la versión de documento por defecto, la salida en YAML funciona en tiempo de ejecución, y puedes transformar una sola operación sin tocar el documento completo.
Como estamos versionados, los documentos por versión vienen de Asp.Versioning.OpenApi:
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().WithDocumentPerVersion(); // /openapi/v1.json y /openapi/v2.json
app.MapScalarApiReference(); // UI interactiva en /scalar
}
Sin versionado sería solo builder.Services.AddOpenApi() más app.MapOpenApi(), sirviendo /openapi/v1.json.
Cuatro cosas que te van a ahorrar una tarde:
-
No viene ninguna UI incluida. ASP.NET Core genera el documento y ahí se detiene. Agrega
Scalar.AspNetCore(lo que usan hoy los ejemplos de Microsoft) o quédate conSwashbuckle.AspNetCore.SwaggerUisi tu memoria muscular insiste en/swagger. En cualquier caso, protégelo detrás deIsDevelopment()— un endpoint de esquema público es un mapa de reconocimiento gratis para cualquiera que ande sondeando tu servicio. -
.WithOpenApi()está obsoleto en .NET 10 (ASPDEPR002) y será eliminado. Muchos ejemplos todavía lo muestran. Usa.WithSummary(),.WithDescription(),.WithTags()o.AddOpenApiOperationTransformer(...)en su lugar. -
YAML es un argumento:
app.MapOpenApi("/openapi/{documentName}.yaml"). Solo en tiempo de ejecución — la generación del documento en tiempo de compilación sigue siendo JSON. -
La 3.1 cambia la salida del esquema. Ya no existe
nullable: true; la nulabilidad aparece como un tipo unión. Si un generador de clientes se atraganta, regresa conoptions.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0dentro deAddOpenApi().
¿Quieres una descripción en una respuesta que el tipo de retorno no puede expresar? Los atributos ahora la llevan:
[ProducesResponseType<OrderV2Response>(StatusCodes.Status200OK,
Description = "El pedido, incluida su marca de tiempo de registro.")]
Todo junto
Program.cs:
using Asp.Versioning;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<OrdersStore>();
builder.Services.AddSingleton<InventoryStore>();
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
})
.AddOpenApi();
var app = builder.Build();
var orders = app.NewVersionedApi("Orders");
var v1 = orders.MapGroup("/api/v{version:apiVersion}/orders")
.HasApiVersion(1.0)
.WithTags("Orders");
var v2 = orders.MapGroup("/api/v{version:apiVersion}/orders")
.HasApiVersion(2.0)
.WithTags("Orders");
v1.MapGet("/{id}", OrderEndpoints.GetOrderV1);
v1.MapPost("/", OrderEndpoints.PlaceOrder).AddEndpointFilter<StockReservationFilter>();
v2.MapGet("/{id}", OrderEndpoints.GetOrderV2);
v2.MapGet("/", OrderEndpoints.ListOrders);
v2.MapPost("/", OrderEndpoints.PlaceOrder).AddEndpointFilter<StockReservationFilter>();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().WithDocumentPerVersion();
app.MapScalarApiReference();
}
app.Run();
Los contratos y los handlers:
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
// Modelo de dominio — nunca sale del proceso.
public sealed record Order(string Id, string Sku, int Quantity, string Status, DateTimeOffset PlacedAt);
// Contratos expuestos al cliente — uno por versión, libres de divergir.
public sealed record OrderV1Response(string Id, string Sku, int Quantity, string Status);
public sealed record OrderV2Response(string Id, string Sku, int Quantity, string State, DateTimeOffset PlacedAt);
public sealed record PlaceOrderRequest(
[property: Required]
[property: RegularExpression(
@"^[A-Z]{3}-[A-Z0-9]{2,4}$",
ErrorMessage = "El SKU debe verse como ETH-250 o COL-1KG.")]
string Sku,
[property: Range(1, 100, ErrorMessage = "Los pedidos están limitados a 100 unidades por línea.")]
int Quantity);
internal static class OrderEndpoints
{
/// <summary>Busca un pedido por su identificador.</summary>
/// <param name="id">El identificador del pedido, por ejemplo <c>A-2001</c>.</param>
public static Results<Ok<OrderV1Response>, NotFound> GetOrderV1(string id, OrdersStore store)
=> store.Find(id) is { } order
? TypedResults.Ok(new OrderV1Response(order.Id, order.Sku, order.Quantity, order.Status))
: TypedResults.NotFound();
/// <summary>Busca un pedido, incluida su marca de tiempo de registro.</summary>
/// <param name="id">El identificador del pedido, por ejemplo <c>A-2001</c>.</param>
public static Results<Ok<OrderV2Response>, NotFound> GetOrderV2(string id, OrdersStore store)
=> store.Find(id) is { } order
? TypedResults.Ok(new OrderV2Response(
order.Id, order.Sku, order.Quantity, order.Status, order.PlacedAt))
: TypedResults.NotFound();
/// <summary>Lista todos los pedidos registrados.</summary>
public static Ok<IReadOnlyCollection<OrderV2Response>> ListOrders(OrdersStore store)
=> TypedResults.Ok<IReadOnlyCollection<OrderV2Response>>(
store.All()
.Select(o => new OrderV2Response(o.Id, o.Sku, o.Quantity, o.Status, o.PlacedAt))
.ToArray());
/// <summary>Registra un pedido nuevo y devuelve su ubicación.</summary>
public static Created PlaceOrder(PlaceOrderRequest request, OrdersStore store, HttpContext http)
{
var order = store.Place(request.Sku, request.Quantity);
return TypedResults.Created($"{http.Request.Path}/{order.Id}");
}
}
Y el almacén, seguro para concurrencia porque un singleton que atiende peticiones simultáneas no tiene otra opción:
using System.Collections.Concurrent;
public sealed class OrdersStore
{
private readonly ConcurrentDictionary<string, Order> _orders = new()
{
["A-2001"] = new("A-2001", "ETH-250", 2, "processing", DateTimeOffset.UtcNow),
["A-2002"] = new("A-2002", "COL-1KG", 1, "shipped", DateTimeOffset.UtcNow),
};
// Arranca después de los IDs precargados para que el primer pedido generado sea A-2003.
private int _nextOrderId = 2002;
public Order? Find(string id) => _orders.TryGetValue(id, out var order) ? order : null;
public IReadOnlyCollection<Order> All() => _orders.Values.ToArray();
public Order Place(string sku, int quantity)
{
var id = Interlocked.Increment(ref _nextOrderId);
var order = new Order($"A-{id}", sku, quantity, "processing", DateTimeOffset.UtcNow);
_orders[order.Id] = order;
return order;
}
}
Usar ConcurrentDictionary en vez de Dictionary no es decoración. Un almacén singleton mutado desde peticiones concurrentes a través de un Dictionary común puede corromper sus buckets internos durante un redimensionamiento — el síntoma clásico es una petición que deja girando un núcleo de CPU para siempre en vez de lanzar algo que puedas depurar a las 3 de la mañana.
Cuándo Minimal APIs — y cuándo controladores
Recurre a Minimal APIs cuando la superficie sea un conjunto de endpoints y no una jerarquía de recursos, cuando el tiempo de arranque y la huella importen (son la única opción bajo Native AOT) y — cada vez más — cuando quieras las funciones nuevas del framework primero. Ese último punto no es menor: la validación integrada de este post no soporta MVC ni Razor Pages. Solo Minimal APIs y Blazor. La inversión está fluyendo visiblemente en una sola dirección.
Quédate con controladores cuando tengas una superficie CRUD convencional y grande donde las convenciones de [ApiController] realmente te ahorren código, cuando dependas del ecosistema de filtros de MVC o de model binders que tendrías que reconstruir a mano, o cuando estés extendiendo una app MVC existente y la consistencia le gane a la novedad. "Ya tenemos cuarenta controladores" es una razón de ingeniería legítima, no una confesión.
Regla general: los servicios nuevos arrancan con Minimal; las apps MVC existentes se quedan en MVC hasta que algo concreto obligue a moverse. Y si alguien te dice que los Minimal APIs "no escalan a proyectos reales", eso normalmente significa que escribió los cuarenta endpoints como lambdas dentro de un solo Program.cs. Eso no es culpa del framework — es la misma persona que habría escrito un controlador de dos mil líneas.
Puntos Clave
-
AddValidation()hace que DataAnnotations funcione en Minimal APIs — sin FluentValidation, sin filtro escrito a mano, sin referencia de paquete en un proyecto web. En records posicionales,[property:]es obligatorio o la validación no hace nada en silencio. -
Los filtros se encargan de las reglas que necesitan servicios — DataAnnotations valida la forma,
IEndpointFiltervalida la realidad. Mantén fuera la carrera de comprobar-y-actuar haciendo que la operación misma sea atómica. -
Versiona los endpoints que se rompieron, no la API — un cambio en la forma de la respuesta se versiona; un
201 Createdcon solo una cabeceraLocationmuchas veces no necesita versionarse. -
TypedResultsyResults<T1, T2>reemplazan a.Produces<T>()— la firma se convierte en el contrato de OpenAPI, y el compilador lo hace cumplir. -
Los valores por defecto de OpenAPI cambiaron en .NET 10 — documentos 3.1, YAML en tiempo de ejecución, ninguna UI incluida y
.WithOpenApi()obsoleto. La mitad de los ejemplos en línea siguen con la forma de .NET 8.




Top comments (0)