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