API mínimas en .NET: creación de API HTTP ligeras
Si ha estado creando API con ASP.NET Core durante algún tiempo, probablemente esté muy familiarizado con el enfoque basado en controladores: cree una clase de controlador, decórela con atributos, inyecte sus servicios a través del constructor y conecte sus rutas. Funciona, y funciona bien, pero a veces parece que estás escribiendo mucha ceremonia sobre lo que equivale a “aceptar esta solicitud, hacer algo, devolver una respuesta”.
Ese es exactamente el problema que las API mínimas se propusieron resolver. Introducidas en .NET 6, las API mínimas le permiten definir puntos finales HTTP con muy poco texto repetitivo. Sin controladores, sin atributos, sin malabarismos con las clases de inicio: solo asignaciones directas de ruta a controlador en un estilo limpio y funcional.
En esta publicación quiero explicarle todo lo que necesita saber sobre las API mínimas: desde su primer punto final hasta la organización de aplicaciones grandes, el manejo de la autenticación, la validación, los documentos OpenAPI y las consideraciones de rendimiento. Vayamos a ello.
¿Por qué API mínimas?
Antes de comenzar, hablemos de por qué elegirías API mínimas en lugar del enfoque tradicional basado en controladores.
Los controladores son excelentes cuando necesitas un marco estructurado y obstinado. Le brindan vinculación de modelos, filtros, negociación de contenido y una separación clara de inquietudes listas para usar. Para aplicaciones empresariales grandes con docenas de desarrolladores, la coherencia que imponen los controladores puede ser un beneficio real.
API mínimas, por otro lado, brillan cuando quieras:
- Menos texto repetitivo: sin clases de controlador, sin atributos
[ApiController], sin configuración de inicio separada. - Inicio más rápido: menos operaciones basadas en reflexión en el momento del arranque.
- Modelo mental más simple: una ruta se asigna directamente a un controlador. Eso es todo.
- Compatible con microservicios: cuando su API tiene entre 5 y 10 puntos finales, una configuración completa del controlador puede parecer excesiva.
La buena noticia es que no se trata de una decisión de uno u otro. Puede combinar controladores y API mínimas en el mismo proyecto. Pero una vez que se sienta cómodo con el enfoque mínimo, es posible que se encuentre recurriendo a él con más frecuencia de lo esperado.
Empezando
Creemos una API mínima desde cero. Si tiene instalado el SDK de .NET, es tan simple como:
dotnet new web -n MyMinimalApi
cd MyMinimalApi
Esto te da un Program.cs que se parece a esto:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
Eso es todo. Esa es una API que funciona. Sin Startup.cs, sin clase de controlador, sin configuración de enrutamiento. Ejecutas dotnet run, presionas http://localhost:5000 y obtienes “¡Hola mundo!” atrás. La simplicidad aquí es el punto.
Hagámoslo un poco más útil con una API de tareas clásica:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var todos = new List<Todo>();
app.MapGet("/todos", () => todos);
app.MapGet("/todos/{id:int}", (int id) =>
todos.FirstOrDefault(t => t.Id == id) is { } todo
? Results.Ok(todo)
: Results.NotFound());
app.MapPost("/todos", (Todo todo) =>
{
todos.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
});
app.MapDelete("/todos/{id:int}", (int id) =>
{
var todo = todos.FirstOrDefault(t => t.Id == id);
if (todo is null) return Results.NotFound();
todos.Remove(todo);
return Results.NoContent();
});
app.Run();
record Todo(int Id, string Title, bool IsComplete);
Tenemos CRUD completo en menos de 30 líneas. Ése es el poder del enfoque minimalista.
Controladores de ruta
Los controladores de ruta son las funciones que se ejecutan cuando una solicitud coincide con una ruta. Tienes varias opciones para definirlos.
Expresiones Lambda
El enfoque más común y lo que verá en la mayoría de los ejemplos:
app.MapGet("/hello", () => "Hello!");
app.MapGet("/hello/{name}", (string name) => $"Hello, {name}!");
Grupos de métodos
Para una lógica más compleja, puede señalar un método con nombre:
app.MapGet("/products", GetProducts);
app.MapPost("/products", CreateProduct);
static IResult GetProducts(AppDbContext db)
=> Results.Ok(db.Products.ToList());
static IResult CreateProduct(Product product, AppDbContext db)
{
db.Products.Add(product);
db.SaveChanges();
return Results.Created($"/products/{product.Id}", product);
}
```Este es mi enfoque preferido para cualquier cosa más allá de una sola línea. Mantiene la sección de mapeo de rutas limpia y legible: puede ver de un vistazo qué puntos finales existen sin tener que revisar los detalles de implementación.
### Funciones locales
También puedes usar funciones locales, lo cual es útil cuando deseas mantener los controladores cerca de sus definiciones de ruta:
```csharp
app.MapGet("/health", CheckHealth);
IResult CheckHealth()
{
// Some health check logic
return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}
Enlace de parámetros
Una de las cosas que realmente aprecio de las API mínimas es lo intuitivo que es el enlace de parámetros. El marco determina de dónde extraer valores según el contexto y el tipo.
Parámetros de ruta
app.MapGet("/users/{id:int}", (int id) => $"User {id}");
app.MapGet("/files/{*path}", (string path) => $"File: {path}"); // catch-all
Parámetros de cadena de consulta
app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
Results.Ok(new { query, page, pageSize }));
Los tipos que aceptan valores NULL se convierten en parámetros opcionales. Los valores predeterminados funcionan exactamente como cabría esperar.
Cuerpo de solicitud
app.MapPost("/orders", (Order order) =>
{
// 'order' is automatically deserialized from the JSON body
return Results.Created($"/orders/{order.Id}", order);
});
Encabezado y enlace de servicio
app.MapGet("/protected", (
[FromHeader(Name = "X-Api-Key")] string apiKey,
[FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
return Results.Ok("Authorized");
});
HttpContext y HttpRequest
Cuando necesita acceso de nivel inferior:
app.MapGet("/info", (HttpContext context) =>
{
var userAgent = context.Request.Headers.UserAgent.ToString();
var ip = context.Connection.RemoteIpAddress?.ToString();
return Results.Ok(new { userAgent, ip });
});
Enlace personalizado con BindAsync
Para tipos complejos, puede implementar un método BindAsync estático:
public record PaginationParams(int Page, int PageSize)
{
public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
{
int.TryParse(context.Request.Query["page"], out var page);
int.TryParse(context.Request.Query["pageSize"], out var pageSize);
var result = new PaginationParams(
Page: page > 0 ? page : 1,
PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
);
return ValueTask.FromResult<PaginationParams?>(result);
}
}
app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
var items = db.Items
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToList();
return Results.Ok(items);
});
Esto es increíblemente poderoso. Usted define la lógica de enlace una vez y la reutiliza en todos sus puntos finales.
Validación
Un área en la que las API mínimas no ofrecen tanto en comparación con los controladores es la validación de modelos. No hay aplicación automática de [Required] o [StringLength]. Pero existen patrones claros para manejarlo.
Validación manual
El enfoque más simple: simplemente validar en el controlador:
app.MapPost("/users", (CreateUserRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Email))
return Results.BadRequest(new { Error = "Email is required" });
if (request.Name?.Length > 100)
return Results.BadRequest(new { Error = "Name must be 100 characters or less" });
// Create user...
return Results.Created($"/users/{request.Email}", request);
});
Usando una biblioteca de validación
Para cualquier cosa que no sea trivial, recomiendo buscar FluentValidation o una biblioteca similar:
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Age).InclusiveBetween(0, 150);
}
}
Puede conectar esto a sus puntos finales a través de filtros de puntos finales, lo que nos lleva a la siguiente sección.
Filtros de terminales
Los filtros de punto final son una de las mejores características de las API mínimas. Piense en ellos como middleware, pero dirigidos a puntos finales específicos en lugar de a todo el proceso. Se introdujeron en .NET 7 y son fantásticos para cuestiones transversales.
Filtro básico
app.MapPost("/todos", (Todo todo) =>
{
// Handle the request
return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
var todo = context.GetArgument<Todo>(0);
if (string.IsNullOrEmpty(todo.Title))
{
return Results.BadRequest(new { Error = "Title is required" });
}
return await next(context);
});
Filtro de validación con FluentValidation
Aquí es donde se vuelve realmente poderoso: un filtro de validación reutilizable:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var model = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (model is null)
return Results.BadRequest(new { Error = "Request body is required" });
var result = await _validator.ValidateAsync(model);
if (!result.IsValid)
{
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return Results.ValidationProblem(errors);
}
return await next(context);
}
}
// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
// Only reached if validation passes
return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();
Filtro de registro
app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
.AddEndpointFilter(async (context, next) =>
{
var logger = context.HttpContext
.RequestServices.GetRequiredService<ILogger<Program>>();
var sw = Stopwatch.StartNew();
var result = await next(context);
sw.Stop();
logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);
return result;
});
Puede encadenar varios filtros y se ejecutan en el orden en que se agregan, como el middleware.
Integración OpenAPI/Swagger
Una buena documentación API ya no es opcional. Afortunadamente, las API Minimal tienen soporte de primera clase para OpenAPI a través del paquete Microsoft.AspNetCore.OpenApi.
Configuración básica
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapGet("/todos", () => new List<Todo>())
.WithName("GetTodos")
.WithDescription("Retrieves all todo items")
.WithTags("Todos");
app.Run();
Metadatos enriquecidos de terminales
Puede proporcionar información detallada sobre sus puntos finales:
app.MapPost("/todos", (Todo todo) =>
{
return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();
Uso de WithOpenApi para personalización
Para un control detallado sobre el documento OpenAPI generado:
app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
.WithOpenApi(operation =>
{
operation.Summary = "Get a specific todo";
operation.Description = "Retrieves a single todo item by its unique identifier.";
operation.Parameters[0].Description = "The unique identifier of the todo item";
return operation;
});
Esto le brinda el mismo nivel de control de documentación que obtendría con los comentarios XML de Swashbuckle en los controladores, pero de una manera más explícita, con código primero.
Autenticación y autorización
La protección de puntos finales de API mínima sigue los mismos patrones que el resto de ASP.NET Core; simplemente los aplica de manera diferente.
Configuración básica```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });
builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));
var app = builder.Build();
app.UseAuthentication(); app.UseAuthorization();
### Aplicación de autorización a puntos finales
```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
Name = user.FindFirstValue(ClaimTypes.Name),
Email = user.FindFirstValue(ClaimTypes.Email)
});
}).RequireAuthorization();
// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
// Delete user logic
return Results.NoContent();
}).RequireAuthorization("AdminOnly");
// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
.AllowAnonymous();
Observe cómo puede inyectar ClaimsPrincipal directamente en los parámetros de su controlador: el marco se encarga del resto. Esta es una de esas pequeñas cosas que hace que las API mínimas parezcan realmente elegantes.
Organización de API grandes
El elefante en la sala con API mínimas es la organización. Cuando tu Program.cs tiene 50 puntos finales, se vuelve un desastre. Estos son los patrones que uso para mantener las cosas manejables.
Grupos de rutas
Los grupos de rutas (introducidos en .NET 7) le permiten compartir la configuración entre puntos finales relacionados:
var todos = app.MapGroup("/todos")
.WithTags("Todos")
.RequireAuthorization();
todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
Todos los puntos finales del grupo comparten el prefijo /todos, la etiqueta Todos y el requisito de autorización. Limpio.
Métodos de extensión
Este es el patrón que realmente escala. Mueva cada grupo de puntos finales a su propia clase estática:
// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
{
var group = app.MapGroup("/todos").WithTags("Todos");
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id:int}", Update);
group.MapDelete("/{id:int}", Delete);
return group;
}
private static async Task<IResult> GetAll(AppDbContext db)
=> Results.Ok(await db.Todos.ToListAsync());
private static async Task<IResult> GetById(int id, AppDbContext db)
=> await db.Todos.FindAsync(id) is { } todo
? Results.Ok(todo)
: Results.NotFound();
private static async Task<IResult> Create(Todo todo, AppDbContext db)
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
}
private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
todo.Title = updated.Title;
todo.IsComplete = updated.IsComplete;
await db.SaveChangesAsync();
return Results.Ok(todo);
}
private static async Task<IResult> Delete(int id, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.NoContent();
}
}
// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();
app.Run();
Tu Program.cs se convierte en una tabla de contenidos para tu API. Cada grupo de puntos finales vive en su propio archivo. Este es el enfoque que recomiendo para aplicaciones de producción.
La biblioteca Carter
Si desea aún más estructura, la biblioteca Carter proporciona un enfoque basado en módulos:
public class TodoModule : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/todos", async (AppDbContext db) =>
await db.Todos.ToListAsync());
app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
}
}
Carter descubre y registra automáticamente todos los módulos. Es un buen punto medio entre el enfoque de API minimalista y los controladores completos.
Resultados escritos y tipos de respuesta
A partir de .NET 7, puede usar TypedResults en lugar de Results para respuestas con seguridad de escritura. Esto puede parecer un pequeño cambio, pero tiene beneficios reales para la documentación y la capacidad de prueba de OpenAPI.
app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is not null
? TypedResults.Ok(todo)
: TypedResults.NotFound();
});
El tipo de retorno Results<Ok<Todo>, NotFound> le dice explícitamente al marco (y a sus documentos OpenAPI) exactamente qué tipos de respuesta puede producir este punto final. No más conjeturas, no más llamadas manuales Produces<>() para casos básicos.
Para múltiples resultados posibles:
app.MapPost("/todos",
async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
(Todo todo, AppDbContext db) =>
{
if (string.IsNullOrEmpty(todo.Title))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
{ "Title", new[] { "Title is required" } }
});
if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
return TypedResults.Conflict();
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
Empecé a usar TypedResults en todos los proyectos nuevos. El compilador detecta discrepancias entre los tipos de devolución declarados y lo que realmente devuelve, lo que elimina toda una clase de sorpresas en tiempo de ejecución.
Consideraciones de rendimiento
Uno de los puntos de venta de las API mínimas es el rendimiento, y vale la pena entender por qué son más rápidas.
Reducción de la sobrecarga de inicio. Los controladores dependen en gran medida de la reflexión para descubrir puntos finales, vincular modelos y aplicar filtros. Las API mínimas utilizan generadores de código fuente (a partir de .NET 7) para generar código vinculante en tiempo de compilación. Esto significa menos trabajo al inicio y menos asignación de memoria por solicitud.
Sin canalización MVC. Las API basadas en controladores pasan por la canalización MVC completa: selección de acciones, enlace de modelos, filtros de acciones, ejecución de resultados. Las API mínimas omiten todo eso y pasan directamente del enrutamiento a su controlador.
Compilación de RequestDelegate. El marco compila sus expresiones lambda en instancias RequestDelegate optimizadas. El código resultante es muy parecido a lo que escribirías a mano si estuvieras trabajando directamente con HttpContext.
A continuación se ofrecen algunos consejos prácticos para maximizar el rendimiento:
// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.AsNoTracking().ToListAsync());
// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
await db.Products.CountAsync());
// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
await db.Orders
.AsNoTracking()
.GroupBy(o => o.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(ct));
```También vale la pena mencionar que la brecha de rendimiento entre los controladores y las API mínimas sigue reduciéndose con cada versión de .NET. Para la mayoría de las aplicaciones, la diferencia no será su cuello de botella: las consultas a la base de datos y las llamadas de servicios externos sí lo serán. Elija según la experiencia del desarrollador y las necesidades del proyecto, no según los puntos de referencia.
## Conclusión
Las API mínimas han recorrido un largo camino desde su introducción en .NET 6. Lo que comenzó como una función de demostración de "hola mundo" ha madurado hasta convertirse en una opción legítima para las API de producción. Con filtros de puntos finales, grupos de rutas, resultados escritos y una sólida compatibilidad con OpenAPI, tiene todo lo que necesita para crear servicios bien estructurados y fáciles de mantener.
¿Mi recomendación? Si está iniciando un nuevo proyecto de API, especialmente un microservicio o una API interna enfocada, pruebe seriamente las API mínimas. Utilice el patrón del método de extensión para la organización, apóyese en los filtros de punto final para inquietudes transversales y aproveche `TypedResults` para la seguridad de tipos.
Para los proyectos existentes basados en controladores, no hay prisa por migrar. Ambos enfoques funcionan bien e incluso puedes usarlos uno al lado del otro. Pero la próxima vez que necesite agregar un pequeño servicio o una API interna rápida, omita los controladores y opte por el mínimo. Quizás no regreses.
¡Feliz codificación!