Buenas Prácticas en APIs REST: Guía Práctica con ASP.NET Core
Introducción
Construir una API REST no es solo exponer endpoints. Es diseñar un contrato claro, predecible y mantenible que otros desarrolladores usarán. Una API mal diseñada genera frustración, errores costosos y deuda técnica. Esta guía recoge las prácticas que aplico día a día en proyectos .NET y que marcan la diferencia entre una API que "funciona" y una API que escala.
1. Diseña Recursos, No Acciones
El error más común: usar verbos en las URLs.
Mal:
GET /api/getUsers
POST /api/createUser
DELETE /api/deleteUser/123
Bien:
GET /api/users # Listar usuarios
GET /api/users/123 # Obtener usuario 123
POST /api/users # Crear usuario
PUT /api/users/123 # Reemplazar usuario 123
PATCH /api/users/123 # Actualizar parcialmente
DELETE /api/users/123 # Eliminar usuario 123
Las URLs identifican recursos (sustantivos). Los métodos HTTP indican la acción.
2. Usa los Códigos de Estado HTTP Correctamente
No devuelvas 200 OK para todo. Los códigos de estado comunican intención:
| Código | Cuándo usarlo |
|---|---|
200 OK |
Solicitud exitosa (GET, PUT, PATCH) |
201 Created |
Recurso creado exitosamente (POST) |
204 No Content |
Operación exitosa sin cuerpo (DELETE, PUT) |
400 Bad Request |
Datos de entrada inválidos |
401 Unauthorized |
Falta autenticación |
403 Forbidden |
Sin permisos para el recurso |
404 Not Found |
Recurso no existe |
409 Conflict |
Conflicto de negocio (ej: email duplicado) |
422 Unprocessable Entity |
Validación semántica falló |
500 Internal Server Error |
Error inesperado del servidor |
3. Implementa un Modelo de Respuesta Consistente
Todas las respuestas deben seguir la misma estructura:
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? Message { get; set; }
public List<ApiError>? Errors { get; set; }
}
public class ApiError
{
public string Field { get; set; } = "";
public string Message { get; set; } = "";
}
Ejemplo de respuesta exitosa:
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Juan Pérez",
"email": "juan@example.com"
},
"message": null,
"errors": null
}
Ejemplo de respuesta con errores:
{
"success": false,
"data": null,
"message": "Validation failed",
"errors": [
{ "field": "email", "message": "Email is already registered" },
{ "field": "password", "message": "Password must be at least 8 characters" }
]
}
4. Manejo de Errores Centralizado
No repitas lógica de errores en cada controller. Usa un middleware global:
public class GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
await HandleValidationExceptionAsync(context, ex);
}
catch (NotFoundException ex)
{
await HandleNotFoundExceptionAsync(context, ex);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
await HandleExceptionAsync(context, ex);
}
}
private static Task HandleValidationExceptionAsync(HttpContext context, ValidationException ex)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
var response = new ApiResponse<object>
{
Success = false,
Message = "Validation failed",
Errors = ex.Errors.Select(e => new ApiError { Field = e.PropertyName, Message = e.ErrorMessage }).ToList()
};
return context.Response.WriteAsJsonAsync(response);
}
private static Task HandleNotFoundExceptionAsync(HttpContext context, NotFoundException ex)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
var response = new ApiResponse<object>
{
Success = false,
Message = ex.Message
};
return context.Response.WriteAsJsonAsync(response);
}
private static Task HandleExceptionAsync(HttpContext context, Exception ex)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
var response = new ApiResponse<object>
{
Success = false,
Message = "An unexpected error occurred"
};
return context.Response.WriteAsJsonAsync(response);
}
}
Registro en Program.cs:
app.UseMiddleware<GlobalExceptionMiddleware>();
5. Paginación desde el Día 1
Nunca devuelvas listas sin paginación. Un endpoint GET /api/users sin límite es una bomba de tiempo:
public class PagedResult<T>
{
public List<T> Items { get; set; } = [];
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber < TotalPages;
}
// En el controller
[HttpGet]
public async Task<ActionResult<PagedResult<UserDto>>> GetUsers(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
pageSize = Math.Min(pageSize, 100); // Límite máximo
var result = await _userService.GetPagedAsync(pageNumber, pageSize);
return Ok(result);
}
URL de ejemplo:
GET /api/users?pageNumber=2&pageSize=20
6. Versionado que No Rompe Contratos
El versionado en URL es simple y explícito:
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class UsersController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public ActionResult<List<UserV1>> GetUsersV1() => Ok(_service.GetV1());
[HttpGet]
[MapToApiVersion("2.0")]
public ActionResult<List<UserV2>> GetUsersV2() => Ok(_service.GetV2());
}
Reglas de oro:
- Nunca modifiques un endpoint existente en una versión publicada
- Añade campos opcionales, nunca los elimines
- Si necesitas cambios breaking, crea
v2
7. Validación con FluentValidation
DataAnnotations es básico. Para APIs complejas, usa FluentValidation:
public class CreateUserRequest
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string Password { get; set; } = "";
}
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter")
.Matches(@"[0-9]").WithMessage("Password must contain at least one number");
}
}
Registro en Program.cs:
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
8. Documentación con Swagger que Sirve
Swagger no es opcional. Configúralo bien:
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
Description = "API documentation with examples"
});
// Añade ejemplos de request/response
options.ExampleFilters();
// Autenticación JWT en Swagger
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "JWT Authorization header"
});
});
9. Rate Limiting para Producción
Protege tus endpoints de abuso:
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
// Aplica en controllers
[EnableRateLimiting("fixed")]
[ApiController]
public class UsersController : ControllerBase { }
10. Health Checks y Observabilidad
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database")
.AddCheck<ExternalApiHealthCheck>("external-api");
// Endpoint de health
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
var result = new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
exception = e.Value.Exception?.Message
})
};
await context.Response.WriteAsJsonAsync(result);
}
});
Checklist para Lanzar a Producción
- Todos los endpoints devuelven códigos HTTP apropiados
- Validación de entrada en todos los endpoints
- Manejo de errores centralizado implementado
- Paginación en todos los endpoints de listado
- Rate limiting configurado
- Swagger documentado y accesible
- Health checks configurados
- Logs estructurados (Serilog)
- CORS configurado explícitamente
- HTTPS redireccionamiento activo
Conclusión
Una API REST bien diseñada reduce el tiempo de integración, minimiza errores y escala sin dolor. Las prácticas de esta guía no son teoría: son el resultado de años de construir APIs en producción y ver qué funciona y qué no.
Empieza por los fundamentos: recursos bien definidos, códigos HTTP correctos y validación estricta. Luego añade capas de robustez: manejo de errores, paginación y rate limiting. Tu yo futuro (y tus consumidores de API) te lo agradecerán.
Referencias y Recursos
- Microsoft REST API Guidelines - Guía oficial de Microsoft para diseño de APIs REST
- RFC 9110: HTTP Semantics - Especificación oficial del protocolo HTTP
- ASP.NET Core Web API Documentation - Documentación oficial de Microsoft
- FluentValidation Documentation - Documentación de FluentValidation para .NET
- OpenAPI Specification - Estándar para documentación de APIs REST
- Richardson Maturity Model - Niveles de madurez REST por Martin Fowler
- API Versioning in ASP.NET Core - Librería y patrones de versionado
- Rate Limiting in ASP.NET Core - Documentación oficial sobre rate limiting