DEV Community

Cover image for Minimal APIs bien hechos en .NET 10: validación, versionado y OpenAPI sin controladores
Juan Gómez
Juan Gómez

Posted on

Minimal APIs bien hechos en .NET 10: validación, versionado y OpenAPI sin controladores

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);
});
Enter fullscreen mode Exit fullscreen mode

Tres huecos, en el orden en que te van a doler:

  1. Nada valida la petición. {"sku": "", "quantity": -5} se convierte en un pedido real.
  2. Nada versiona el contrato. Renombra un campo del JSON y todos los clientes se rompen a la vez.
  3. 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();
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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."]
  }
}
Enter fullscreen mode Exit fullscreen mode

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 sobre IEndpointConventionBuilder, 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ón AddMyModuleValidation() por ensamblado.
  • Los parámetros de tipo valor anulable se omiten en .NET 10. Un [Range] sobre un parámetro int? 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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}");
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 con Swashbuckle.AspNetCore.SwaggerUi si tu memoria muscular insiste en /swagger. En cualquier caso, protégelo detrás de IsDevelopment() — 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 con options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 dentro de AddOpenApi().

¿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.")]
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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, IEndpointFilter valida 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 Created con solo una cabecera Location muchas veces no necesita versionarse.
  • TypedResults y Results<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)