Estructura contra significado: por qué 400, 422 y 409 no son lo mismo

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ódigoSignificaLo decide
400Estructura: falta un campo o es del tipo equivocadoEl ValidationPipe, antes de que el dominio vea nada
422Significado: el cuerpo está bien formado y el dominio lo rechazaEl agregado, con sus invariantes
409Estado: la operación no procede ahora mismoEl agregado, con su máquina de estados
500Un fallo de verdadNadie. 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.

Leave a Comment