.NET y backend

Cómo diseñar una API REST mantenible en .NET

Una API se diseña en semanas y se mantiene durante años. Rutas, errores, paginación, versiones e idempotencia para no romper a los clientes después.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# de un endpoint ASP.NET Core que devuelve ProblemDetails

Construir una API es la parte rápida. Lo difícil empieza cuando ya hay una aplicación móvil, un frontend y dos integraciones de terceros usándola, y necesitas cambiar algo. Cada decisión de diseño que se tomó "por ahora" se convierte en un contrato que no puedes romper.

Una API mantenible no es la que sigue REST al pie de la letra. Es la que permite evolucionar sin romper a quien la consume.

1. Rutas por recurso, no por acción

❌ POST /api/createOrder
❌ GET  /api/getOrdersByCustomer?id=42
❌ POST /api/orders/delete/15

✅ POST   /api/orders
✅ GET    /api/customers/42/orders
✅ DELETE /api/orders/15

El verbo HTTP ya dice la acción. Rutas consistentes hacen la API predecible: si alguien conoce /orders/15, puede adivinar /orders/15/lines.

Hay acciones que no encajan en un CRUD, como cancelar o enviar un pedido. Forzarlas en un PATCH con { "status": "Cancelled" } esconde reglas de negocio. Un recurso de acción explícito suele ser más claro:

POST /api/orders/15/cancellation

2. Códigos de estado que signifiquen algo

Situación Código
Creado 201 Created con encabezado Location
Aceptado para procesar después 202 Accepted
Datos inválidos 400 Bad Request
Sin autenticar / sin permiso 401 / 403
No existe 404 Not Found
Conflicto con el estado actual 409 Conflict
Regla de negocio que no se cumple 422 Unprocessable Entity

El error más común es devolver 200 OK con { "success": false } en el cuerpo. Rompe el manejo de errores de cualquier cliente HTTP, los reintentos automáticos y el monitoreo, que cuenta esos fallos como éxitos.

3. Un formato de error único: ProblemDetails

ASP.NET Core implementa el estándar RFC 9457 (antes 7807). Con una línea, todos los errores tienen la misma forma:

builder.Services.AddProblemDetails();

Y en el endpoint:

app.MapPost("/api/orders/{id:int}/cancellation", async (int id, OrderService orders, CancellationToken ct) =>
{
    var result = await orders.CancelAsync(id, ct);

    return result switch
    {
        CancelResult.NotFound => Results.NotFound(),
        CancelResult.AlreadyShipped => Results.Problem(
            title: "El pedido ya fue enviado",
            detail: "Un pedido enviado no se puede cancelar; registra una devolución.",
            statusCode: StatusCodes.Status409Conflict),
        _ => Results.NoContent()
    };
});

El cliente siempre sabe dónde buscar title, detail y status. Puedes agregar un campo propio, por ejemplo un código de error estable ("code": "order_already_shipped"), para que los clientes no dependan del texto del mensaje, que puede cambiar.

4. Paginar desde el primer día

Un GET /api/orders que devuelve todo funciona con 50 pedidos. Con 50.000 tumba el endpoint. Y agregar paginación después es un cambio incompatible: los clientes que esperaban la lista completa empiezan a recibir solo una parte.

{
  "items": [ ... ],
  "page": 1,
  "pageSize": 50,
  "totalCount": 1284
}

Pon un límite máximo de pageSize en el servidor. Si no, alguien va a pedir pageSize=100000.

5. Versionar antes de necesitarlo

Agregar un campo nuevo a una respuesta no rompe a nadie. Quitar uno, renombrarlo o cambiar su tipo, sí. Para esos cambios, necesitas versiones. La forma más simple de mantener es la versión en la ruta:

/api/v1/orders
/api/v2/orders

El paquete Asp.Versioning.Http resuelve el enrutamiento y la documentación. Lo importante no es el mecanismo sino la política: cuánto tiempo se mantiene la versión anterior y cómo se avisa su retiro. El encabezado Sunset sirve exactamente para eso.

6. Idempotencia en operaciones que crean o cobran

Si el cliente envía POST /api/payments, la conexión se corta y no recibe respuesta, ¿el pago se hizo o no? Si reintenta, puede cobrar dos veces.

Una clave de idempotencia lo resuelve: el cliente genera un identificador único por operación y lo envía en un encabezado.

POST /api/payments
Idempotency-Key: 6f1c2a9e-3b7d-4e1a-9a2f-0c5d8e7b4a11

El servidor guarda la clave con el resultado. Si llega otra vez, devuelve el resultado guardado en lugar de procesar de nuevo. En pagos, pedidos e integraciones con reintentos automáticos, esto evita una categoría completa de incidentes.

7. No expongas tus entidades

Devolver la entidad de EF Core directamente une el contrato de la API al esquema de la base de datos: cualquier columna nueva aparece en la respuesta y cualquier cambio en la tabla puede romper a los clientes. Un DTO por respuesta evita ese acoplamiento. Le dedicamos un artículo completo a ese tema.

8. Documentación generada, no escrita a mano

Desde .NET 9, ASP.NET Core genera el documento OpenAPI con Microsoft.AspNetCore.OpenApi:

builder.Services.AddOpenApi();
app.MapOpenApi(); // /openapi/v1.json

Documenta los códigos de respuesta posibles de cada endpoint (.Produces<T>(201), .ProducesProblem(409)). Un documento que dice la verdad sobre los errores vale más que una página de documentación desactualizada.

El trade-off

Todo esto agrega trabajo al principio: DTOs, paginación, formato de errores, versiones. Para una API interna que consume un solo frontend del mismo equipo, que se despliega al mismo tiempo, parte de esto puede esperar. Cambiar el contrato es barato cuando controlas los dos lados.

Cuándo no aplicar todo esto

  • Backend exclusivo de un frontend (BFF) que se despliega junto con él: versionar es innecesario.
  • Prototipos que se van a tirar: paginación e idempotencia pueden esperar.
  • APIs que no son REST por naturaleza: si tu caso es mayormente comandos o consultas complejas, forzar recursos REST puede empeorar el diseño. gRPC o un enfoque orientado a operaciones pueden encajar mejor.

Conclusión

Una API mantenible es predecible: rutas consistentes, errores con una sola forma, listas paginadas y cambios incompatibles con una versión nueva. Cuesta un poco más al inicio y ahorra mucho cuando ya no puedes cambiar el contrato sin coordinar con quienes lo consumen.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿Necesitas un equipo .NET para tu proyecto?

Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.

Ver desarrollo a medida