1. La solicitud no llega al método del controlador
Si apenas estás empezando a escribir una API REST, hay una expectativa muy humana: «He escrito un método de controlador, por lo tanto se llamará, y dentro ya me ocuparé de lo que llegó». Por desgracia, Spring MVC no está obligado a llamar a tu método solo por respeto a tu trabajo. Antes de que el control llegue a TaskController#create(...), Spring debe asegurarse de que la solicitud se puede leer y convertir en argumentos del método.
A nosotros nos interesa exactamente este tramo: todo lo que sucede entre el cuerpo HTTP bruto y los argumentos listos del método.
Imagina el controlador como la taquilla de un cine. Antes de la taquilla hay un torniquete y un revisor. Si la entrada está rota (JSON malformado), el revisor no te dejará pasar — el taquillero (tu método) ni siquiera sabrá que viniste. Esto, en realidad, es una buena noticia: la capa de servicio no debe «arreglar» un JSON sintácticamente roto. Su tarea es trabajar con datos ya normales, no hacer de arqueólogo excavando comas.
Para fijar la imagen, aquí hay un esquema simplificado de dónde puede «romperse» la solicitud:
flowchart TD
A[Solicitud HTTP] --> B{"¿Coincide el endpoint por path+method?"}
B -->|sí| C{"¿Coincide Content-Type con consumes?"}
C -->|sí| D["Lectura de body y construcción de objeto Java para @RequestBody"]
D -->|éxito| E[Llamada al método del controlador]
E --> F[Llamada al servicio]
D -->|error| X[Respuesta con error sin llamar al controlador]
C -->|no| Y[415 Unsupported Media Type]
B -->|no| Z[404 o 405 según la situación]
En esta lección nos interesa el bloque D: todo lo relacionado con que el cuerpo no se puede leer, se lee «en el tipo incorrecto» o simplemente está ausente.
2. JSON malformado: cuando la forma está rota, no el sentido
La palabra malformed suena como si JSON simplemente «no hubiera dormido bien». Pero el significado es prosaico: JSON es sintácticamente inválido, es imposible analizarlo con un parser. Es el nivel de «no coinciden los corchetes», «coma extra», «comillas sin cerrar». Aquí aún es pronto para hablar de significado del negocio, porque ni siquiera hemos llegado al significado: los datos como objeto no existen.
La parte más insidiosa es que a ojos humanos el JSON puede parecer «casi normal». Una coma extra y ya está. Como en programación: «agregué solo un símbolo... ¿por qué se cayó la producción?»: clásico del género.
Vamos a mostrarlo con nuestro POST /api/v1/tasks. Supongamos que tenemos un endpoint create (ya lo tienes en lecciones anteriores). Para contexto, la forma mínima de la solicitud:
### OK: JSON válido
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login",
"description": "User can't login with correct password"
}
Ahora hagamos «casi lo mismo», pero añadamos una coma extra (una de las formas más populares de dispararse en el pie):
### BAD: JSON malformado (coma extra)
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login",
"description": "User can't login with correct password",
}
Lo importante de entender conceptualmente: en tal escenario, el método del controlador no se llama, el servicio no se llama, ningún try/catch dentro del método se activa. Spring simplemente no puede construir el objeto TaskCreateRequest porque no es posible leer el JSON.
Si estás depurando, el indicador más simple es que pones un breakpoint en el método del controlador, envías un JSON malformado... y el breakpoint no se activa. No es que «Spring se haya roto», es que «Spring ni siquiera comenzó a ejecutar tu código porque los datos de entrada no pasaron al nivel de objeto Java».
3. Cuerpo de solicitud vacío: cuando el servidor literalmente no tiene nada que leer
JSON malformado es cuando el cuerpo existe pero está roto. Ahora imaginemos otro escenario: el cuerpo de la solicitud está vacío. Enviaste un POST, especificaste Content-Type: application/json, y en el body hay vacío. Tal vez el cliente olvidó, o el programador «estoy probando sin cuerpo», o alguien en Postman eliminó accidentalmente el payload y presionó Enviar (incluso a los mejores les pasa).
Y aquí ocurre algo importante: por defecto, @RequestBody se considera obligatorio. Es decir, Spring espera poder leer el cuerpo y construir el objeto. Si no hay nada que leer, no se puede construir el argumento del método — y de nuevo no entramos al controlador.
Comprobemos en la práctica:
### BAD: cuerpo vacío (body ausente)
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
Obtendrás un error (normalmente será 400 Bad Request), y de nuevo — el método del controlador no se ejecutará.
Ahora un punto sutil pero muy importante: el cuerpo vacío no es lo mismo que {}.
{} es un objeto JSON válido, simplemente sin campos. Y en ese caso Spring puede crear exitosamente un TaskCreateRequest donde todos los campos serán null. Es decir, el método del controlador será llamado, el servicio será llamado, y luego puedes tener un problema de aplicación (por ejemplo, NPE o «tarea creada sin título», si aún no has hecho validaciones).
### OK a nivel de lectura de body: JSON válido, pero sin datos
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{}
Este es el límite clave de la lección de hoy: antes de que empieces a pensar «cómo validar campos», debes ver que hay dos mundos diferentes: el mundo «el objeto no se construyó en absoluto» y el mundo «el objeto se construyó, pero está vacío/incorrecto».
A veces los desarrolladores intentan «ser amables» y permitir un cuerpo vacío para que el controlador se ejecute de todos modos. Técnicamente es posible, pero para un endpoint de creación generalmente no es necesario. Solo para que conozcas este mecanismo, aquí tienes un ejemplo mínimo:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PostMapping;
@PostMapping
public ResponseEntity<Void> create(@RequestBody(required = false) TaskCreateRequest request) {
// Si el cuerpo está ausente y required=false, Spring pasará null aquí
if (request == null) return ResponseEntity.badRequest().build(); // Respondemos explícitamente 400: no se puede crear sin cuerpo
// Aquí ya hay lógica normal del manejador porque el objeto de solicitud existe
return ResponseEntity.ok().build();
}
El propósito del ejemplo no es hacerlo así en todas partes, sino entender que el cuerpo obligatorio no es una filosofía, sino una configuración. Pero en una API normal, crear una tarea sin cuerpo no es un escenario que valga la pena soportar, porque solo complica el contrato.
4. Errores de tipo: el JSON no encaja en el modelo Java
Hay una clase de problemas aún más «adulta»: el JSON es sintácticamente correcto, es decir, los corchetes y comas están en su lugar, pero Spring aún no puede construir el objeto. La razón es que la estructura o los valores no coinciden con los tipos de tu clase Java. En la vida real esto aparece constantemente: alguien envió "priority": "URGENT", y no tienes ese enum, o "dueDate": "tomorrow" en lugar de una fecha, o incluso un número en lugar de una cadena.
Para el ejemplo, añadimos prioridad de tarea al dominio:
package com.example.tasktracker.domain.model;
public enum TaskPriority {
LOW, MEDIUM, HIGH, CRITICAL
}
Y supongamos que TaskCreateRequest espera este enum:
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.model.TaskPriority;
public class TaskCreateRequest {
// Estos campos se llenan desde el JSON según los nombres de las claves
private String title;
// Aquí se espera una cadena del conjunto de valores del enum (por ejemplo, "LOW", "HIGH")
private TaskPriority priority;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public TaskPriority getPriority() { return priority; }
public void setPriority(TaskPriority priority) { this.priority = priority; }
}
Ahora enviemos un JSON donde priority tiene un valor que no está en el enum:
### BAD: JSON válido, pero el valor del enum no coincide
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login",
"priority": "URGENT"
}
En este caso el JSON es sintácticamente normal, pero no se convierte en objeto Java. El resultado es muy similar al JSON malformado desde el punto de vista de tu código: el método del controlador, probablemente, no será llamado porque el objeto no se construyó. El error ocurre en la etapa de «leer el body y convertir al tipo del argumento».
Otra variante popular es el tipo de valor incorrecto. Por ejemplo, title es una cadena, pero el cliente envió un objeto:
### BAD: el tipo del campo no coincide (esperábamos cadena, llegó objeto)
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": { "text": "Fix login" }
}
Incluso si la persona (o la biblioteca cliente) «en general quería transmitir lo mismo», el servidor no tiene obligación de adivinar. El contrato ya está fijado por tu clase Java: title es una cadena. Y si no es una cadena, la lectura del cuerpo termina a nivel de MVC, sin llegar al servicio.
Y aquí es importante no confundir esto con «datos malos en el sentido del negocio». Esto es incompatibilidad técnica de la forma de los datos con el modelo esperado. El servicio no debe ocuparse de «extraer el text del objeto y convertirlo en cadena». Eso ya es otra historia y otro contrato.
5. Content-Type y consumes: a veces el problema no está en el JSON
Después de los ejemplos anteriores, es fácil concluir: «la principal fuente de dolor es JSON». Pero a veces el JSON es perfecto, y el problema está en la cabecera Content-Type. Justo en la lección pasada fijamos consumes y produces no por belleza. Hacen que el contrato sea concreto: «aquí envíen JSON, de lo contrario ni siquiera fingiremos entender la solicitud».
Supongamos que tu controlador fija que acepta exactamente JSON:
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
// ...
}
Ahora el cliente da un paso extraño: envía cuerpo JSON, pero establece Content-Type: text/plain. Para el servidor, esto se ve como «me enviaron texto», no «me enviaron JSON».
### BAD: Content-Type no coincide con consumes (lo más probable es 415)
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
Accept: application/json
{"title":"Fix login"}
Normalmente el resultado es 415 Unsupported Media Type. Y de nuevo es la historia de «el controlador no fue llamado». Simplemente el error ocurrió aún antes: el servidor ni siquiera intentó leer el JSON como JSON, porque según el contrato le enviaron «texto plano».
En este punto es útil recordar una regla práctica: si tienes @RequestBody y esperas JSON, el cliente está obligado a enviar un Content-Type: application/json correcto. De lo contrario, tu API comienza a parecerse a «adivina la melodía con tres notas», y así no llegaremos lejos.
6. Diagnóstico: el servicio no fue llamado
A veces un estudiante dice: «Bueno, usted dice ‘el método no fue llamado’, pero quiero verlo». Es un deseo correcto, porque el cerebro recuerda mejor lo que se puede tocar con las manos. La forma más sencilla es poner un pequeño marcador en el servicio y asegurarse de que solo aparece en solicitudes válidas.
Por ejemplo, en tu TaskService puedes escribir temporalmente:
public void create(String title, TaskPriority priority) {
// Marcador temporal: si NO ves esta línea en los logs, significa que la solicitud «murió» antes del servicio
System.out.println("TaskService#create called");
}
Ahora envía un JSON normal — verás la línea en los logs/consola. Envía un JSON malformado, cuerpo vacío, enum incorrecto — y esa línea no aparecerá. Esto es una prueba directa de que la solicitud «se rompió» antes de que el controlador pudiera extraer los datos y llamar al código de aplicación.
Y esto explica una «paradoja del novato» frecuente: intenta envolver la lógica del servicio en un try/catch y se sorprende de que su catch nunca se activa con JSON malformado. No se activa porque el servicio ni siquiera se llama. El error ocurre antes.
7. Tabla de escenarios: dónde se rompe la solicitud
Cuando empiezas a diseñar una API como contrato, es útil tener en mente no solo el happy-path, sino también un mapa básico de escenarios negativos. A continuación, una tabla simplificada, sin intentar convertirla en un «estándar final de errores». Por ahora nuestro objetivo es ver dónde está el límite: antes del controlador o ya dentro.
| Escenario | Ejemplo | Generalmente ocurre | Estado típico | ¿Llega al controlador? |
|---|---|---|---|---|
| JSON malformado | coma extra, corchete sin cerrar | el cuerpo no se puede analizar | 400 Bad Request | no |
| Cuerpo vacío con @RequestBody obligatorio | body ausente | nada que leer, no se puede construir el argumento | 400 Bad Request | no |
| {} en lugar de campos esperados | {} | el objeto se crea, campos null | 200/201 (si no se verifica) o error de aplicación | sí |
| Enum incorrecto | "priority": "URGENT" | el valor no se puede convertir a enum | 400 Bad Request | generalmente no |
| Tipo de campo incorrecto | "title": { ... } | la estructura no coincide con lo esperado | 400 Bad Request | no |
| Content-Type incorrecto | text/plain con consumes=application/json | la solicitud no encaja en el contrato | 415 Unsupported Media Type | no |
La frase «estado típico» es importante aquí: el formato exacto del cuerpo del error y los detalles aún los unificaremos. Pero la idea ya es útil: muchos errores no tienen relación con la lógica de negocio. Se trata de que la solicitud no cumple con el contrato en la entrada.
8. Errores típicos al leer JSON
Este tema parece «solo teoría sobre JSON malo», pero en la práctica es aquí donde a menudo se rompe la primera experiencia de comunicación del cliente con tu API. Si entiendes de una vez que Spring puede no llamar al controlador en absoluto, dejarás de perder horas depurando «por qué mi servicio no se ejecuta» y empezarás a mirar donde realmente está el problema: en el cuerpo de la solicitud y su conformidad con el contrato.
Error n.º 1: intentar capturar JSON malformado con try/catch dentro del método del controlador.
Parece lógico: «si el JSON es malo, capturaré la excepción». Pero la excepción ocurre antes de que el método siquiera comience a ejecutarse. Como resultado, tu catch nunca se activa, y se crea la sensación de que Spring «ignora tu código». En realidad, simplemente no ha llegado a él.
Error n.º 2: considerar {} y cuerpo vacío como lo mismo.
El cuerpo vacío es ausencia de datos, que a menudo termina en un error de lectura de solicitud. {} es un objeto JSON válido, y puede convertirse tranquilamente en un objeto con campos null. Son dos escenarios diferentes, y requieren diferentes reacciones mentales al diseñar el contrato.
Error n.º 3: «pero yo envié JSON, ¿para qué Content-Type?».
Para una persona, JSON es «lo que está dentro», pero para el contrato HTTP también es el encabezado Content-Type. Si es incorrecto, el servidor puede honestamente decir 415 Unsupported Media Type, incluso si el cuerpo parece JSON. El protocolo no lee tus pensamientos, lee los encabezados.
Error n.º 4: mezclar “no se pudo leer el cuerpo” y “se leyó, pero los campos son malos”.
JSON malformado y enum incorrecto son el nivel donde el objeto no se construye, y no se llega a la lógica de aplicación. En cambio, «title vacío» o «no enviaron description» son otra clase de problemas: el objeto se construyó, y luego hay que decidir qué es aceptable. Si mezclas todo en una sola bolsa, te complicarás la vida en los próximos pasos del curso.
Error n.º 5: añadir «suposiciones mágicas» al contrato sin un acuerdo explícito.
Por ejemplo, intentar aceptar priority: 1 y «adivinar» que es HIGH. Parece conveniente, pero convierte la API en un conjunto de reglas no obvias. En este curso apuntamos a un contrato predecible: si esperamos una cadena enum, aceptamos una cadena enum. Todo lo demás es un error o una decisión consciente separada, pero no una «amabilidad del servidor» casual.
GO TO FULL VERSION