Hay una decisión que se toma mal en casi todas las APIs que reviso, y no es de arquitectura ni de rendimiento: es qué código de estado devolver cuando algo va mal. La mayoría acaba en un 400 genérico para todo, o peor, en un 500 sin cuerpo.
La distinción que lo resuelve cabe en una frase: un tipo equivocado es estructura; un valor equivocado es significado.
Tres peticiones, tres respuestas
POST /products {\"name\":\"X\",\"description\":\"Suficientemente larga\",\"price\":\"cuarenta\"}
→ 400 el precio no es un número
POST /products {\"name\":\"X\",\"description\":\"Suficientemente larga\",\"price\":0}
→ 422 es un número perfectamente válido, pero no puede ser un precio
POST /orders/:id/confirm {}
→ 409 nada está mal formado; el pedido está vacío y no se puede confirmar
Fíjate en la segunda. 0 es un número correcto en cualquier sentido técnico. Ningún validador de transporte puede saber que un precio no puede serlo, porque eso no es información sobre el formato: es conocimiento de negocio. Y el conocimiento de negocio vive en el dominio.
Quién juzga qué
| Código | Significa | Lo decide |
|---|---|---|
| 400 | Estructura: falta un campo o es del tipo equivocado | El ValidationPipe, antes de que el dominio vea nada |
| 422 | Significado: el cuerpo está bien formado y el dominio lo rechaza | El agregado, con sus invariantes |
| 409 | Estado: la operación no procede ahora mismo | El agregado, con su máquina de estados |
| 500 | Un fallo de verdad | Nadie. Es un bug, y debe seguir siendo un 500 |
Esa última fila importa más de lo que parece. La tentación al montar un filtro de excepciones es capturarlo todo y traducirlo a algo bonito. No lo hagas: un TypeError convertido en 400 es un bug de producción que nadie va a encontrar nunca, porque los 400 no se investigan — se asume que el cliente se equivocó.
Lo que gana el que llama
Cuando el 422 lleva las reglas rotas estructuradas, un formulario puede resaltar los dos campos correctos a la vez:
{
\"statusCode\": 422,
\"error\": \"Unprocessable Entity\",
\"message\": \"Product is invalid\",
\"brokenRules\": [
{ \"property\": \"props.price\", \"message\": \"Price must be greater than 0\" },
{ \"property\": \"props.description\", \"message\": \"Description must be longer than the name\" }
]
}
El campo property es lo que hace la diferencia entre un aviso genérico arriba del formulario y dos campos marcados en rojo. Y para que llegue hasta ahí, el handler tiene que lanzar una excepción que lleve las reglas — no aplanarlas a texto:
// mal: la estructura se pierde y ya no hay forma de recuperarla
throw new Error(rules.map(r => r.message).join(\', \'));
// bien: el filtro puede responder 422 y nombrar cada campo
throw new BrokenRulesException(\'Product\', product.brokenRules.getBrokenRules());
Es un cambio de una línea que decide si tu API es utilizable desde un formulario o no.
Para seguir
El mapeo completo, con el filtro de excepciones entero, está en Mapear errores a HTTP.
