CodeGym /Cursos /Spring REST & MVC /Contrato JSON: deserialización de request DTO y serializa...

Contrato JSON: deserialización de request DTO y serialización de response DTO

Spring REST & MVC
Nivel 11, Lección 1
Disponible

1. Dos procesos del contrato JSON

Cuando las personas empiezan a escribir una API REST por primera vez, a menudo piensan en JSON como «una cadena que por algún lado llega y por algún lado se va». Es similar a la situación en el aeropuerto: «simplemente de alguna manera terminaré en otro país». Formalmente — sí, pero es mejor entender dónde está el control de pasaportes. En nuestra API, ese «control de pasaportes» son los dos lados de la misma frontera: cómo se lee el JSON de entrada en un request DTO y cómo el response DTO se escribe de nuevo en JSON.

Deserialización — es la transformación del JSON de entrada en un objeto Java. Es decir, el camino «el cliente envió JSON → Spring MVC leyó el body → Jackson construyó TaskCreateRequest → el método de su controlador recibió el objeto listo».

Serialización — es lo inverso: transformación de un objeto Java a JSON de salida. Es decir, el camino «usted devolvió TaskDetailsResponse desde el controlador → Spring MVC le pidió a Jackson escribirlo en la respuesta HTTP → el cliente recibió JSON».

Es muy importante captar la idea principal de esta lección: no son dos «temas diferentes», sino dos lados del mismo contrato. Si cambias el DTO — cambias el JSON. Si cambias el contrato JSON — cambias las expectativas del cliente. Y si cambias las expectativas del cliente sin previo aviso… bueno, el cliente también cambiará — a otro servicio.

Para fijar visualmente, aquí hay un esquema de lo que ocurre en una petición típica «crear tarea»:

sequenceDiagram
    participant C as Cliente HTTP
    participant M as Spring MVC + Jackson
    participant Ctrl as TaskController
    participant Svc as TaskService
    participant Map as TaskMapper

    C->>M: "POST /api/v1/tasks (JSON body)"
    M->>M: "Deserialización JSON -> TaskCreateRequest"
    M->>Ctrl: create(request)
    Ctrl->>Map: fromCreateRequest(request)
    Map->>Ctrl: Task (modelo interno)
    Ctrl->>Svc: create(task)
    Svc->>Ctrl: Task (guardada/creada)
    Ctrl->>Map: toDetailsResponse(task)
    Map->>Ctrl: TaskDetailsResponse
    Ctrl->>M: return DTO
    M->>C: "201 Created + JSON (serialización)"

2. Deserialización: JSON → request DTO

Si la serialización es «cómo entregamos», entonces la deserialización es «cómo recibimos». Y a nivel de sensaciones para un principiante, suele ser el momento más extraño: aparentemente no analizaste nada, no escribiste JSON.parse, no ejecutaste expresiones regulares… y el objeto ya existe. Pero es importante entender: el objeto «ya existe» no porque Spring sea un mago, sino porque tiene un trabajo comprensible: leer el cuerpo de la solicitud y construir un objeto Java del tipo especificado.

En nuestro proyecto, la deserialización casi siempre comienza en el controlador con @RequestBody. Esta anotación le dice a Spring MVC: «este parámetro debe tomarse del body y construirse a partir de JSON». El punto clave: el tipo del parámetro (TaskCreateRequest) es parte del contrato. No solo aceptas «algún JSON», aceptas un JSON de una forma específica que describe tu DTO.

El ejemplo más simple del TaskController se ve así:

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class TaskController {

    @PostMapping("/api/v1/tasks")
    void create(@RequestBody TaskCreateRequest request) {
        // @RequestBody: Spring + Jackson leen JSON del body y construyen el DTO de este tipo
        // request ya está construido desde JSON — aquí no debe haber análisis manual de cadenas
    }
}

Aquí es útil decirlo en voz alta (sí, se puede en voz alta, nadie te juzgará): request es el resultado de la deserialización. No «llegó» como objeto, se «construyó» a partir de JSON.

Ahora echemos un vistazo al DTO que esperamos recibir. En el proyecto educativo, mantenemos los DTO lo más simples posible, sin lógica ni trucos:

package com.example.tasktracker.api.dto.request;

public class TaskCreateRequest {

    // Estos nombres se convierten directamente en claves JSON: "title" y "description"
    private String title;
    private String description;

    public TaskCreateRequest() {
        // El constructor vacío suele ser necesario para que Jackson pueda deserializar
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        // Este valor proviene del campo JSON "title"
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        // Este valor proviene del campo JSON "description"
        this.description = description;
    }
}

Ahora la forma del JSON esperado se lee casi «letra por letra»: title y description. Por ejemplo, el cliente podría enviar la siguiente solicitud:

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Write docs",
  "description": "Explain serialization vs deserialization"
}

Y obtienes un TaskCreateRequest cuyo getTitle() devolverá "Write docs", y getDescription() devolverá "Explain serialization vs deserialization".

Hay una conclusión práctica importante: el request DTO no es un «clase interna», es una forma pública de entrada. Si renombras el campo title a taskTitle, la deserialización comenzará a esperar un JSON diferente. Es decir, cambiaste el contrato.

Para sentir esto, puedes imaginarte la siguiente «prueba mental»: miras el DTO y te preguntas «¿qué claves JSON espero en la entrada?». Si la respuesta no es obvia, el DTO es demasiado confuso para un contrato.

A veces un principiante quiere «ayudar» a Jackson y hace lo siguiente: acepta un String y luego analiza manualmente el JSON. Esto te devuelve a la edad de piedra, donde cada campo es trabajo manual. En nuestro curso, la disciplina básica es la siguiente: el body siempre se convierte en un DTO, no en una cadena. Si te sorprendes pensando «voy a aceptar un String json rápidamente y luego lo analizo», detente. Eso casi siempre es una señal de que estás evitando el contrato y creándote problemas.

3. Serialización: response DTO → JSON

Después de aprender a recibir la entrada, es igualmente importante entender claramente cómo entregamos la salida. La serialización es el momento en que devuelves un objeto Java desde el controlador y el cliente recibe JSON. Y nuevamente, es fácil caer en la trampa de «funciona solo». Pero el modelo de ingeniería correcto es: «yo devuelvo un DTO → Jackson escribe JSON → el cliente ve el resultado».

En TaskController, esto suele verse extremadamente aburrido (y aburrido es bueno, el código aburrido generalmente se rompe con menos frecuencia):

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.service.TaskService;
import com.example.tasktracker.api.mapper.TaskMapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
class TaskController {

    private final TaskService taskService;
    private final TaskMapper taskMapper;

    TaskController(TaskService taskService, TaskMapper taskMapper) {
        this.taskService = taskService;
        this.taskMapper = taskMapper;
    }

    @GetMapping("/api/v1/tasks/{taskId}")
    TaskDetailsResponse getById(@PathVariable String taskId) {
        // Aquí obtenemos el modelo interno...
        Task task = taskService.getById(taskId);

        // ...y devolvemos el response DTO (este será serializado a JSON)
        return taskMapper.toDetailsResponse(task);
    }
}

A continuación, un fragmento reducido de TaskDetailsResponse: solo dejamos los campos necesarios para ver el principio de serialización. La clase completa del proyecto puede contener otros campos.

package com.example.tasktracker.api.dto.response;

public class TaskDetailsResponse {

    // Fragmento del DTO: solo mostramos los campos importantes para esta explicación.
    private String id;
    private String title;

    public TaskDetailsResponse(String id, String title) {
        // En el response DTO solemos colocar valores «listos para el cliente»
        this.id = id;
        this.title = title;
    }

    public String getId() {
        // Jackson normalmente toma los valores a través de getters
        return id;
    }

    public String getTitle() {
        return title;
    }
}

Si el controlador devuelve new TaskDetailsResponse("t-1", "Write docs"), el cliente verá algo como esto:

{
  "id": "t-1",
  "title": "Write docs"
}

Aquí nuevamente funciona la misma lógica de «contrato»: los nombres de los campos JSON se toman de tu modelo Java (más precisamente, de sus propiedades públicas). Esto significa que cualquier «cosmética» en el DTO en realidad cambia el contrato externo. Por ejemplo, si agregas otro getter en el response DTO, potencialmente agregas un campo al JSON.

Para ver esta trampa, veamos un ejemplo de un método «inocente»:

package com.example.tasktracker.api.dto.response;

public class TaskDetailsResponse {

    private final String id;
    private final String title;

    public TaskDetailsResponse(String id, String title) {
        this.id = id;
        this.title = title;
    }

    public String getId() { return id; }
    public String getTitle() { return title; }

    public String getDebugLabel() {
        // Importante: este getter también será serializado a JSON como "debugLabel"
        return "task:" + id;
    }
}

Si ese DTO se va a serializar, el cliente podría inesperadamente recibir:

{
  "id": "t-1",
  "title": "Write docs",
  "debugLabel": "task:t-1"
}

Y aquí comienza la vida adulta de los contratos: agregaste una «comodidad de depuración», pero el cliente recibió un nuevo campo. Quizás al cliente no le moleste. O quizás el cliente tiene una validación estricta de esquema y comienza a quejarse. Y esto ya no es un «error del cliente», es una consecuencia de que el DTO es un contrato.

4. Asimetría entre request y response DTO

El deseo ingenuo de un desarrollador principiante suena más o menos así: «¿y si hacemos una clase única TaskDto y la usamos tanto para recibir como para devolver?». Parece que así hay menos código, menos clases, menos dolor. En la práctica, esto es casi garantía de más dolor, solo que un poco más tarde. La asimetría entre entrada y salida no es «complejidad por la complejidad», sino el estado normal de cualquier API sensata.

Piensa en lo que suele ocurrir al crear un recurso. El cliente envía lo que puede definir: por ejemplo, title y description. Pero el servidor define lo que el cliente no debe controlar: id, alguna bandera interna, posiblemente un estado por defecto. Incluso si ahora (en el proyecto educativo) todavía no usas todos los campos del modelo de dominio, la lógica de «el cliente establece una cosa, el servidor devuelve otra» ya está presente.

Por eso en Task Tracker API mantenemos modelos diferentes:

  • Request DTO: TaskCreateRequest — lo que el cliente tiene derecho a enviar.
  • Response DTO: TaskDetailsResponse — lo que el servidor está obligado a mostrar en la respuesta.
  • Modelo interno: Task — cómo almacenamos la tarea internamente.

Para que no sea una abstracción, comparemos las formas JSON para create y para detail. La solicitud create:

{
  "title": "Write docs",
  "description": "Explain serialization vs deserialization"
}

La respuesta detail del recurso creado puede verse diferente ya porque aparece id:

{
  "id": "c0c2f6b2-7f2a-4e43-a2e5-9dcdd7ce2d2d",
  "title": "Write docs",
  "description": "Explain serialization vs deserialization"
}

Observa: ni siquiera necesitamos discutir «campos complejos» para ver la diferencia. El servidor casi siempre agrega algo propio. Por lo tanto, un «DTO universal» hace una de dos cosas malas: o empiezas a aceptar campos innecesarios (el cliente podrá enviar lo que no debe), o devuelves menos de lo que promete el mundo interno y empiezas a mezclar responsabilidades.

El enfoque normal en este curso: «a la entrada — request DTO, a la salida — response DTO». Que haya más clases, pero el contrato se lee como las instrucciones de un microondas: aburrido, pero claro, y nada explota.

5. Mapping: la frontera entre DTO y modelo

Cuando has aceptado un request DTO, todavía no tienes una «tarea», sino solo datos de entrada, además en formato de contrato. Y cuando devuelves un response DTO, devuelves una representación, no la «verdad interna». Precisamente por eso, entre el DTO y el modelo interno se necesita una capa que traduzca honesta y explícitamente uno en otro. En nuestro proyecto, esto es TaskMapper.

Una buena metáfora (y sí, un poco divertida): el mapper es la aduana. Decide qué pasa la frontera (hacia el dominio) y qué no. Y qué sale al exterior y qué se queda dentro como «documentos internos».

Un ejemplo de un mapper mínimo para las tareas podría verse así:

package com.example.tasktracker.api.mapper;

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.model.Task;
import java.util.UUID;

public class TaskMapper {

    public Task fromCreateRequest(TaskCreateRequest request) {
        // Aquí termina el mundo JSON/request DTO y comienza el modelo interno
        // El servidor genera el id — el cliente no debe controlarlo
        return new Task(
                UUID.randomUUID().toString(),
                request.getTitle(),
                request.getDescription()
        );
    }

    public TaskDetailsResponse toDetailsResponse(Task task) {
        // Aquí termina el modelo interno y comienza el contrato de respuesta (response DTO)
        return new TaskDetailsResponse(task.getId(), task.getTitle());
    }
}

Aquí hay dos principios que están directamente relacionados con la serialización y deserialización:

Primer principio: la deserialización termina en el request DTO. Tan pronto como el JSON se ha convertido en un TaskCreateRequest, el mundo JSON ha terminado. A continuación trabajas con objetos Java normales y tu modelo interno. Y la transición request DTO → Task debe ser explícita.

Segundo principio: la serialización comienza en el response DTO. Tan pronto como has formado un TaskDetailsResponse, has vuelto al mundo del contrato. Luego Jackson lo convertirá tranquilamente a JSON. Es decir, la transición Task → response DTO debe ser igualmente explícita.

Esto ayuda a evitar el principal error arquitectónico: «bueno, devolvamos simplemente el Task y luego vemos». Porque «vemos» generalmente termina con que en el JSON se filtra algo que no tenías previsto mostrar al cliente.

6. Cómo predecir el JSON a partir del DTO

Hay una habilidad muy práctica que distingue a un desarrollador seguro del contrato de un desarrollador «en modo lotería»: la capacidad de predecir la forma del JSON simplemente mirando el DTO. No se trata de superpoderes, se trata de disciplina. Idealmente, deberías poder responder dos preguntas incluso sin ejecutar la aplicación: «¿qué JSON recibiré?» y «¿qué JSON devolveré?».

Hagamos este ejercicio. Si vemos el DTO:

package com.example.tasktracker.api.dto.response;

import com.example.tasktracker.domain.model.TaskStatus;

public class TaskSummaryResponse {

    // Forma simple: será un objeto JSON con los campos "id", "title" y "status"
    private final String id;
    private final String title;
    private final TaskStatus status;

    public TaskSummaryResponse(String id, String title, TaskStatus status) {
        this.id = id;
        this.title = title;
        this.status = status;
    }

    public String getId() { return id; }
    public String getTitle() { return title; }
    public TaskStatus getStatus() { return status; }
}

Casi con certeza entendemos que el JSON será un objeto con las claves id, title y status. Sí, el orden de los campos puede diferir (y es mejor no contar con él), pero la forma es clara.

Ahora tomemos un DTO anidado. Incluso sin temas profundos, se puede ver que el anidamiento en Java significa anidamiento en JSON:

package com.example.tasktracker.api.dto.response;

public class TaskListItemResponse {

    private final String id;

    // DTO anidado -> objeto JSON anidado
    private final TaskTitleResponse title;

    public TaskListItemResponse(String id, TaskTitleResponse title) {
        this.id = id;
        this.title = title;
    }

    public String getId() { return id; }
    public TaskTitleResponse getTitle() { return title; }
}

Y la «pieza anidada»:

package com.example.tasktracker.api.dto.response;

public class TaskTitleResponse {

    // En JSON esto se convertirá en "value": "Write docs"
    private final String value;

    public TaskTitleResponse(String value) {
        this.value = value;
    }

    public String getValue() { return value; }
}

El JSON será aproximadamente así:

{
  "id": "t-1",
  "title": {
    "value": "Write docs"
  }
}

Y aquí empiezas a pensar en términos de contrato: «¿Quiero que title sea un objeto con value? ¿O necesito simplemente un title como cadena?» No es una cuestión de «cómo serializa Jackson», es una cuestión de «qué contrato es conveniente para el cliente». Jackson simplemente ejecuta fielmente tu modelo.

Otro pequeño orientador muy útil es la tabla «Tipo Java → Forma JSON». No es un manual de 200 páginas, sino algo que ayuda a no confundirse rápidamente:

Tipo Java en el DTO En qué suele convertirse en JSON Ejemplo
String cadena "title": "Write docs"
int / long / double número "size": 20
boolean booleano "archived": false
List<String> array "tags": ["backend", "rest"]
otra clase DTO objeto "owner": { ... }

Eso es suficiente. Con esto puedes hablar con confianza a nivel básico sobre la forma del contrato, sin convertir la lección en una enciclopedia.

Finalmente, un hábito importante: si cambias un DTO, «reconstruye» mentalmente la forma JSON. Especialmente si es un response DTO. Porque eso es exactamente lo que verá el cliente. Y el cliente, a diferencia de tus clases internas, rara vez lee tus pensamientos y no puede «adivinar lo que quisiste decir».

7. Errores típicos al trabajar con DTO

Error nº1: confundir la dirección y llamar a todo «serialización».
Confusión muy común: «nos llegó la serialización» o «deserializamos la respuesta». En la práctica, esto rompe el razonamiento, porque dejas de ver dónde está el contrato de entrada y dónde el de salida. Si mantienes en mente la regla simple «entrada — deserialización, salida — serialización», se hace más fácil razonar sobre las causas de los errores y qué capa es la culpable.

Error nº2: aceptar un String en lugar de un request DTO y «analizarlo después».
Esto parece un hack rápido, pero en realidad estás omitiendo toda la capa de contrato, renunciando al ensamblaje automático del objeto, perdiendo legibilidad en la firma del controlador y creándote trabajo manual. En nuestro curso, esto es casi siempre un antipatrón: el body debe convertirse en un DTO directamente en la frontera del controlador.

Error nº3: devolver el modelo interno (Task) directamente desde el controlador.
Serializar el modelo de dominio hacia afuera generalmente significa fuga de detalles internos, exposición accidental de campos que no forman parte de la API pública y pérdida de control sobre la evolución del contrato. Incluso si hoy «simplemente funciona», mañana agregarás un campo al modelo interno y aparecerá inesperadamente en el JSON, rompiendo al cliente.

Error nº4: usar un único DTO universal «tanto para entrada como para salida».
Ese DTO casi siempre se vuelve demasiado flexible: el cliente obtiene la capacidad de enviar campos adicionales, y el servidor se ve obligado a ignorarlos en silencio o tomar decisiones extrañas. Como resultado, el contrato se vuelve implícito: a partir de un único DTO ya no está claro qué está obligado a transmitir el cliente, qué ignora el servidor y qué se considera una entrada válida.

Error nº5: ocultar el mapping dentro del controlador y mezclarlo con la lógica HTTP.
Cuando el controlador recibe el @RequestBody, genera el id, construye el Task y además al final mapea a response DTO, deja de ser una frontera web y se convierte en el «protagonista principal de todo el sistema». Esto rápidamente lleva a controladores gordos (fat controllers) que son difíciles de leer y aún más difíciles de probar. El mapping debe ser un paso separado y explícito para que la serialización/deserialización sigan siendo procesos claros alrededor del DTO.

1
Tarea
Spring REST & MVC, nivel 11, lección 1
Bloqueada
Vista previa de nota mediante DTO de solicitud y DTO de respuesta
Vista previa de nota mediante DTO de solicitud y DTO de respuesta
1
Tarea
Spring REST & MVC, nivel 11, lección 1
Bloqueada
Creación de anuncio mediante modelo interno y mapeo explícito
Creación de anuncio mediante modelo interno y mapeo explícito
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION