1. Acuerdo sobre los estados de error
Un cuerpo de error uniforme por sí mismo aún no responde a la pregunta principal del cliente: ¿por qué en un caso es 400, en otro 404 o 409? Si miramos el backend con ojos de principiante, es muy tentador pensar así: «Primero haré el happy path, y los errores… luego, luego». En la práctica, ese «luego» se convierte en la parte más costosa del proyecto: el cliente (Postman, el frontend u otro servicio) ya se ha acostumbrado a un comportamiento, y de repente el servidor comienza a devolver ya sea 400, ya 404, ya 500, o a veces incluso una cadena «¡Oops!». Por eso, ahora, mientras el servidor aún no está en marcha, fijamos el significado de los errores como parte del contrato.
En nuestro ReadLater Starter esto es especialmente importante, porque la API local será probada manualmente a través de Postman. Cuando pruebes escenarios negativos, debes entender exactamente lo que ves: error en la solicitud, recurso no encontrado, conflicto de datos o un fallo real. Si no tenemos un significado común para los estados, la depuración se convierte en una adivinanza: «¿Es mi solicitud incorrecta o la aplicación se ha roto?» Un buen backend trata de no obligar al cliente a jugar a «adivina la melodía», sino que explica honesta y consistentemente qué salió mal.
Estado HTTP y ErrorResponse
Imagina que el estado HTTP es el cartel en la puerta, y ErrorResponse es la nota dentro de la habitación. El cartel dice en grande: «Prohibida la entrada» (403), «No encontrado» (404), «Solicitud incorrecta» (400). Pero el cartel no está obligado a explicar los detalles de tu dominio: por qué exactamente la solicitud es incorrecta, qué recurso no se encuentra o qué entra en conflicto. Para eso existe el cuerpo de la respuesta: nuestro ErrorResponse.
Es muy importante no intentar que una capa haga el trabajo de otra. Si siempre devuelves 400 pero en errorCode escribes algo como READING_ITEM_NOT_FOUND, confundes a los clientes: el estado dice «escribiste mal la solicitud», pero el cuerpo dice «el recurso no existe». Y viceversa, si devuelves 404 pero en message escribes «JSON inválido», obligas al cliente a pensar que solicitó una ruta inexistente cuando simplemente envió un body corrupto.
Nuestro acuerdo es el siguiente: el estado HTTP se elige según la clase del problema, y errorCode según la causa concreta dentro del proyecto. message se escribe para humanos (breve y sin histeria técnica), y details para aclaraciones comprensibles (por ejemplo, «id must be a number», «externalId=OL12345M»). Y todo esto debe ser consistente: un mismo tipo de situación siempre produce el mismo estado y el mismo errorCode.
2. Árbol de decisión para los estados 400–500
Cuando empiezas a implementar una API, la mano se estira automáticamente hacia la respuesta «universal»: «Si no funcionó, devolvemos 500». Pero eso es como tratar todas las enfermedades con un único diagnóstico «algo salió mal»: parece honesto, pero sirve de poco. Por eso es útil tener en mente un esquema simple para elegir el estado, para no confundirte ni discutir contigo mismo una semana después.
flowchart TD
A[Surge un problema al procesar la solicitud] --> B{¿La solicitud es incorrecta?}
B -->|Sí| S400["400 Bad Request
INVALID_REQUEST"]
B -->|No| C{¿Falta la ruta/recurso?}
C -->|Sí| S404["404 Not Found
..._NOT_FOUND"]
C -->|No| D{¿El método no está soportado para la ruta?}
D -->|Sí| S405["405 Method Not Allowed
METHOD_NOT_ALLOWED"]
D -->|No| E{¿Conflicto con el estado actual?}
E -->|Sí| S409["409 Conflict
..._CONFLICT"]
E -->|No| S500["500 Internal Server Error
INTERNAL_ERROR"]
Observa un matiz importante: la rama 404 puede significar tanto «recurso de dominio concreto no encontrado» (por ejemplo, un elemento de la lista de lectura con ese id no existe) como «ruta en la API no encontrada». Ambos casos son formalmente «not found», pero conviene distinguir el errorCode para que el cliente pueda entender qué es exactamente lo que falta: el objeto de datos o la ruta. En un proyecto educativo se puede empezar con un conjunto mínimo de códigos e ir ampliándolo según sea necesario, pero el principio es mejor asimilarlo desde el principio.
3. Estados en los escenarios de ReadLater
400 Bad Request: problema en la solicitud del cliente
400 parece el estado más simple: «el cliente tiene la culpa». Pero precisamente es el que más a menudo se convierte en un vertedero donde todo acaba. Para que 400 siga siendo útil, mantén una idea: la solicitud no pasa la verificación de corrección, incluso antes de llegar a la lógica de negocio. Puede ser un formato incorrecto de id, un estado enum no válido, un campo obligatorio faltante o un JSON roto.
Para ReadLater, las situaciones típicas de 400 se pueden imaginar fácilmente incluso sin servidor. Si el cliente escribe id=abc donde esperamos un número, no es «recurso no encontrado», sino «has enviado algo incorrecto». Si el cliente escribe "status": "DONE" y nosotros no tenemos ese enum, también es 400. Y un detalle metodológico importante: 400 no es un castigo, sino una pista. Debemos ayudar al cliente a entender qué corregir.
Ejemplo de cuerpo de error para un id no válido:
{
"errorCode": "INVALID_REQUEST",
"message": "La solicitud contiene datos incorrectos",
"details": ["id must be a number"]
}
En nuestro proyecto es conveniente acordar que para 400 casi siempre usamos un único errorCode (INVALID_REQUEST), y los detalles concretos van en details. Así el cliente no necesita mantener 20 códigos diferentes como «INVALID_ID», «INVALID_STATUS», «INVALID_JSON»… y la persona igualmente entiende qué está mal.
Un pequeño fragmento de código que muestra la idea de «código estable + detalle concreto»:
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// Para cualquier situación 400 mantenemos un único código estable,
// y la causa concreta la ponemos en details.
ErrorResponse err = new ErrorResponse(
ErrorCode.INVALID_REQUEST.name(),
"La solicitud contiene datos incorrectos",
List.of("id must be a number")
);
Aquí no hay magia: solo el código permanece estable, y los detalles concretos viven en details. Eso es suficiente para que el test negativo en Postman se lea sin adivinanzas.
404 Not Found: la solicitud es correcta, pero el recurso necesario no existe
404 es un estado que a menudo se confunde con 400. La lógica es simple: si la solicitud es formalmente correcta, pero el objeto de datos no existe en el estado actual de la aplicación, es 404. Es decir, el cliente hizo una solicitud normal del tipo «dame el elemento con id=42», y nosotros contestamos honestamente: «no existe tal».
En la API de la lista de lectura esto sucederá a menudo, y es normal. Una base de datos vacía (o más bien un almacenamiento en memoria) no es un error del servidor. La ausencia de un elemento tampoco es un error del servidor. Es simplemente un hecho: el recurso no fue encontrado. Y aquí es importante no «compensar» con 500 o un 400 «por si acaso». El cliente hizo todo correctamente, solo que el objeto no existe.
Ejemplo de respuesta:
{
"errorCode": "READING_ITEM_NOT_FOUND",
"message": "Elemento de la lista de lectura no encontrado",
"details": ["id=42"]
}
Observa: details no tienen que ser bonitos; tienen que ser útiles. «id=42» ya es suficiente, porque la persona, mirando el log o la respuesta de Postman, correlaciona rápidamente la solicitud con el problema.
Si quieres ser aún más preciso, puedes distinguir «ruta no encontrada» y «recurso de dominio no encontrado» con diferentes errorCode, por ejemplo ROUTE_NOT_FOUND y READING_ITEM_NOT_FOUND. No es necesario hoy mismo, pero es útil tenerlo en mente para no tener que romper el contrato después.
405 Method Not Allowed: la ruta existe, pero el método no es adecuado
405 es uno de los estados que los principiantes suelen saltarse. La razón es simple: es más fácil devolver 404 para todo lo que no encaja y no pensar. Pero 405 hace que la API sea notablemente más honesta y predecible. Dice: «la ruta existe, el servidor la reconoce, pero el método HTTP concreto está prohibido aquí».
El ejemplo más simple: supongamos que /health existe, pero no soportamos POST /health. Eso no es «no encontrado». Es «método incorrecto». Y sí, para 405 en HTTP se acostumbra enviar la cabecera Allow con la lista de métodos permitidos. Hoy no implementamos cabeceras (eso ya es mecánica de la capa web), pero contractualmente ya podemos prever que en details aparezca una pista con los métodos permitidos.
Ejemplo de cuerpo de error:
{
"errorCode": "METHOD_NOT_ALLOWED",
"message": "Método no soportado para esta ruta",
"details": ["method=POST", "allowed=GET"]
}
Observa por qué es útil incluso en un proyecto educativo. Cuando pruebas la API en Postman, a veces sin querer seleccionas el método incorrecto (no eres el primero). Si el servidor devuelve honestamente 405 y dice «allowed=GET», entiendes al instante lo que pasó y no empiezas a cavar en la dirección equivocada. Esto te ahorra tiempo y reduce la probabilidad de «romper» la API con parches como «bueno, permitamos POST, que haga lo mismo que GET». No, no hace falta. Deja que la API diga la verdad.
409 Conflict: la solicitud es correcta, pero entra en conflicto con el estado actual
409 suena aterrador («¡conflicto!»), pero en realidad es una situación muy cotidiana. El significado de 409 es que el cliente envió una solicitud válida, pero no se puede ejecutar en el estado actual de la aplicación porque se viola una regla de negocio. En nuestro proyecto, esa regla será la unicidad de externalId, si está definido: no pueden existir dos elementos de la lista de lectura que hagan referencia al mismo libro externo.
¿Por qué no es 400? Porque la solicitud en sí misma es correcta. Los campos son normales, el JSON es normal, el estado es normal. El problema no está en la forma de los datos, sino en que la aplicación ya se encuentra en un estado donde esa acción no es posible. ¿Y por qué no es 500? Porque es una rama esperada de la lógica, no un fallo. El servidor no «se ha roto», simplemente se negó a realizar la operación porque se violaría una regla.
Ejemplo de respuesta:
{
"errorCode": "EXTERNAL_ID_CONFLICT",
"message": "No se pueden guardar dos elementos con el mismo externalId",
"details": ["externalId=OL12345M"]
}
Aquí details son especialmente útiles: el cliente ve qué valor causó el conflicto y puede corregir la solicitud. Y nosotros, como servidor, no parecemos un adolescente caprichoso («no, porque no»), sino un sistema con reglas claras.
500 Internal Server Error: fallo inesperado
500 no es «todo lo que me da pereza clasificar». 500 es «no esperábamos este escenario, es un problema interno de la aplicación». Es decir, un error por null, un bug en el código, un error de transformación no previsto, y cualquier otra situación que no sea una rama normal de la lógica de negocio.
La regla más importante para 500: el cliente no necesita conocer los detalles de tu cocina interna. No hay que enviar hacia afuera un NullPointerException, un stack trace o la ruta de archivos de tu máquina. Primero, es inútil (el cliente igual no va a arreglar tu código). Segundo, es perjudicial (revelas el interior de la aplicación). El cliente necesita un contrato estable: estado 500 y un mensaje neutral.
Ejemplo:
{
"errorCode": "INTERNAL_ERROR",
"message": "Error interno de la aplicación",
"details": []
}
Si tienes muchas ganas de añadir detalles, no añadas «horror técnico», sino algo controlado: por ejemplo, un identificador interno del error o un breve «unexpected error». Pero en un proyecto educativo es mejor mantenerse simple: detalles vacíos y diagnóstico normal en los logs.
4. Tabla resumen de errores de ReadLater
Para que todo lo anterior no quede en filosofía, es útil fijar un «vocabulario» mínimo de errores que sea común a todos los endpoints de la API local. Esto no significa que nunca se amplíe, pero significa que no reinventarás un nuevo errorCode cada vez según el estado de ánimo.
| Situación (en lenguaje humano) | HTTP-status | errorCode | message (ejemplo) | details (ejemplo) |
|---|---|---|---|---|
| id no es un número (/reading-list/abc) | 400 | INVALID_REQUEST | La solicitud contiene datos incorrectos | ["id must be a number"] |
| JSON roto / no se puede parsear | 400 | INVALID_REQUEST | La solicitud contiene datos incorrectos | ["malformed JSON"] |
| status desconocido | 400 | INVALID_REQUEST | La solicitud contiene datos incorrectos | ["status must be one of: PLANNED, IN_PROGRESS, FINISHED"] |
| Elemento con id no encontrado | 404 | READING_ITEM_NOT_FOUND | Elemento de la lista de lectura no encontrado | ["id=42"] |
| La ruta existe, pero el método no está soportado | 405 | METHOD_NOT_ALLOWED | Método no soportado para esta ruta | ["method=POST", "allowed=GET"] |
| externalId ya está ocupado por otro elemento | 409 | EXTERNAL_ID_CONFLICT | No se pueden guardar dos elementos con el mismo externalId | ["externalId=OL12345M"] |
| Error inesperado en el código | 500 | INTERNAL_ERROR | Error interno de la aplicación | [] |
Esta tabla tiene un objetivo simple: cuando en el futuro veas en Postman un 409 y EXTERNAL_ID_CONFLICT, no pensarás «¿por qué no es 400?» Porque acordaste de antemano que 409 es un conflicto de estado, y externalId es parte del estado. Y de manera similar, cuando veas 404 y READING_ITEM_NOT_FOUND, no empezarás a escribir un tratamiento de «solicitud no válida» donde la solicitud es válida.
5. errorCode no se esparce como cadenas por el proyecto
Cuando un desarrollador novato escribe errorCode, a menudo lo hace directamente como cadena en cada lugar: "INVALID_REQUEST", "INVALID-REQUEST", "invalid_request"… y luego se sorprende de que el cliente «no reconozca» el error. Es un dolor normal. La forma más barata de evitarlo es mantener un vocabulario estable en un único enum y usar su name() al crear ErrorResponse.
Esto no cambia el contrato JSON: hacia afuera sigue yendo una cadena. Pero dentro del código Java dejas de encontrarte con erratas como INVALID-REQUEST en lugar de INVALID_REQUEST.
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// En Java mantenemos los códigos en un solo lugar, y en JSON sigue yendo la cadena de name()
ErrorResponse conflict = new ErrorResponse(
ErrorCode.EXTERNAL_ID_CONFLICT.name(),
"No se pueden guardar dos elementos con el mismo externalId",
List.of("externalId=OL12345M")
);
Así el proyecto tiene un conjunto canónico de códigos, no un montón de cadenas repartidas por todo el código.
6. Errores típicos al acordar los errores
Error nº 1: «todos los errores son 400 porque el cliente hizo algo mal».
Esa decisión parece rápida hasta que empiezas a probar. En un momento te das cuenta de que 400 significa todo: un id inexistente, un JSON roto, un conflicto de externalId e incluso un bug interno. El cliente deja de entender qué ha pasado, y tú pierdes la principal ventaja de HTTP: los estados como lenguaje rápido de diagnóstico.
Error nº 2: confundir 404 y 400 con id.
Cuando id no es un número, es 400 («solicitud incorrecta»). Cuando id es un número pero ese recurso no existe, es 404 («recurso no encontrado»). El error aquí suele ser psicológico: «bueno, como no se encontró, entonces 404». No: si id=abc, ni siquiera pudiste formar una solicitud normal a los datos; no «buscaste un recurso», «no entendiste qué id tenías».
Error nº 3: devolver 404 en lugar de 405 para «no complicarse».
405 requiere pensar qué métodos son válidos para la ruta, y a muchos les da pereza. Pero precisamente 405 hace honesta a la API: «la ruta existe, pero el método es incorrecto». Esto ayuda especialmente en las pruebas manuales y reduce la probabilidad de «pegar» métodos extra a un endpoint solo porque así hay menos errores.
Error nº 4: devolver 500 para un conflicto esperado (externalId).
Si externalId debe ser único, el conflicto es una rama esperada. No es un fallo. 500 aquí hace que el cliente piense que el servidor se ha roto, cuando el servidor solo está protegiendo un invariante. Para estas situaciones existe 409 Conflict: «la solicitud es normal, pero no se puede ejecutar debido al estado actual».
Error nº 5: intentar hacer 500 «muy informativo» y enviar la excepción hacia afuera.
Un novato piensa que es lógico: «¿Y cómo va a entender el cliente lo que pasó si no le muestro el stack trace?» Spoiler: el cliente no debe entender eso. El cliente debe entender que ocurrió un error interno y qué puede hacer (generalmente: repetir más tarde o contactar a los desarrolladores). Los detalles técnicos deben vivir en los logs y la depuración, no en la respuesta HTTP.
GO TO FULL VERSION