CodeGym /Cursos /Spring REST & MVC /Request/Response DTO y modelo interno

Request/Response DTO y modelo interno

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

1. Roles de los modelos: entrada, salida, dominio

Si estás empezando a escribir backend, el pensamiento natural es: «Tengo una clase Task, vamos a recibirla y devolverla — es justo, no estoy ocultando nada». El problema es que la API pública es un contrato con el mundo exterior, y el código interno es tu cocina. Y no siempre conviene mostrar la cocina al cliente: no porque haya secretos, sino porque cambia constantemente para comodidad del cocinero.

Ya vimos lo que ocurre cuando se expone directamente Task: el modelo de dominio empieza accidentalmente a dictar el JSON externo. Ahora hay que asignar los roles a su lugar y entender qué modelo se encarga de la entrada, cuál de la salida y cuál de la vida interna de la aplicación.

La idea principal de la lección de hoy es muy simple: los datos tienen roles diferentes, y un solo modelo Java casi nunca puede manejar tres roles sin efectos secundarios.

Imagina (un poco teatral, pero útil) que tenemos tres «idiomas»:

  • el cliente habla el idioma de los datos de entrada: «esto es lo que quiero crear/cambiar»;
  • el servidor responde en el idioma de los datos de salida: «esto es lo que ahora tienes»;
  • los servicios y repositorios dentro de la aplicación viven en el idioma del estado interno: «así es como lo almacenamos y con lo que trabajamos».

Si se mezclan estos idiomas en uno solo, se obtiene algo parecido a una mala traducción: las palabras parecen conocidas, pero el sentido se vuelve confuso.

Para hacerlo lo más concreto posible, es útil tener en mente la siguiente tabla (no se trata de «anotaciones», sino de significado):

Modelo Dirección Qué describe Ubicación en el proyecto Ejemplo
Request DTO del cliente al servidor qué puede enviar el cliente en una operación concreta api.dto.request TaskCreateRequest
Response DTO del servidor al cliente qué promete devolver el servidor en una respuesta concreta api.dto.response TaskDetailsResponse
Internal model dentro de la aplicación cómo la aplicación almacena y procesa el estado domain.model Task

Fíjate en las expresiones: request DTO — «puede enviar», response DTO — «promete devolver», internal model — «cómo vivimos dentro». Si mantienes estos tres significados, la mitad de los errores arquitectónicos desaparecen por sí solos (la otra mitad, por cierto, encontrará nuevas formas de aparecer, pero eso ya es tradición).

2. Request DTO: qué se puede enviar

El request DTO a menudo se percibe como «otra clase por tener otra clase». En la práctica es la forma más barata de hacer una API predecible: simplemente describes el formulario de entrada. No «cómo es nuestro objeto internamente», sino «qué campos aceptamos del cliente». Y esa es una idea muy potente: el cliente no debe adivinar qué campos «se pueden» y cuáles «mejor no, pero técnicamente funcionan».

El request DTO vive en la capa api, normalmente en un paquete como com.example.tasktracker.api.dto.request. Este es su entorno natural: existe precisamente porque existe un contrato externo. Dentro del servicio, el request DTO no debe ser el «modelo principal de la vida», de lo contrario tu capa de dominio empieza a depender de la capa API, y eso ya es una tristeza arquitectónica.

Un request DTO mínimo para crear una tarea podría verse así:

public class TaskCreateRequest {

    // Campos que el cliente tiene derecho a enviar al crear una tarea
    private String title;
    private String description;
    private String assigneeName;

    // Necesario para la deserialización JSON -> objeto Java (por ejemplo, Jackson)
    public TaskCreateRequest() { }

    // Los getters son usados por el framework/código del controlador para leer los datos de entrada
    public String getTitle() { return title; }
    public String getDescription() { return description; }
    public String getAssigneeName() { return assigneeName; }
}

Observa que aquí no hay id, status, createdAt y otras cosas que suenan como «eso el servidor lo sabe mejor». E incluso si aún no has añadido esos campos al modelo interno, el request DTO es el lugar donde entrenas la disciplina: la entrada debe ser estrecha y controlada.

Para que sea más fácil de entender, a veces es útil traducir mentalmente el request DTO al lenguaje humano. TaskCreateRequest no es un «modelo Task», sino «una carta del cliente: crea una tarea con estos datos». Una carta normalmente no contiene la frase «y además asígnale id=123 y ponle el estado DONE, que tengo prisa» — más bien, el cliente puede quererlo, pero no estamos obligados a aceptarlo.

Un pequeño ejemplo JSON que encaja bien en ese DTO (solo para tener una idea de la forma, sin detalles de serialización):

{
  "title": "Arreglar la compilación",
  "description": "Gradle se queja de las dependencias",
  "assigneeName": "Alex"
}

El request DTO es bueno porque responde de inmediato a la pregunta: «¿qué debo enviar?». Si el DTO no responde a esa pregunta, entonces o es demasiado genérico o no está cumpliendo su función.

3. Response DTO: qué promete devolver el servidor

El response DTO es el modelo que el servidor envía al cliente. Y aquí hay una trampa sutil para principiantes: dan ganas de hacer «simetría». Es decir, si el cliente envió title, description, assigneeName, entonces el servidor devolverá lo mismo más un par de campos, y así se obtiene un «modelo universal».

A veces eso ocurre accidentalmente. Pero tan pronto como la API se vuelve un poco más rica, la simetría empieza a estorbar: la entrada y la salida son lógicamente diferentes. La entrada es el conjunto permitido de cambios. La salida es la representación prometida del recurso.

Por eso el response DTO suele vivir en un paquete separado com.example.tasktracker.api.dto.response y tiene su propia forma. Por ejemplo, una respuesta detallada de una tarea:

public class TaskDetailsResponse {

    // A menudo el response DTO se hace inmutable: es una «captura» de lo que devolvemos al cliente
    private final String id;
    private final String title;
    private final String description;
    private final String status;

    public TaskDetailsResponse(String id, String title, String description, String status) {
        // Aquí fijamos la representación que prometemos devolver al exterior
        this.id = id;
        this.title = title;
        this.description = description;
        this.status = status;
    }

    // Normalmente se necesitan getters para todos los campos para la serialización JSON (a continuación se muestra la idea)
    public String getId() { return id; }
    public String getTitle() { return title; }
}

Sí, aquí ya hay id y status, aunque el cliente no los envió. Eso es normal. Es más, es esperable: el cliente pregunta «¿qué salió?» y el servidor responde «salió esto». Es como un ticket de compra: traes los productos (datos de entrada) y la caja emite un ticket con el número de operación, la hora, el total — eso no lo «enviaste», pero es importante como resultado.

Un punto práctico muy importante: conviene hacer el response DTO lo más simple y «tonto» posible. Cuanto menos comportamiento y métodos «inteligentes» tenga, mejor cumple su función de contrato. No debe ir al repositorio, ejecutar lógica de negocio ni «actualizarse a sí mismo». Su trabajo es ser una forma comprensible de datos que se pueda serializar y exponer.

4. Internal model: el modelo interno de dominio

El modelo interno (internal model) es aquello con lo que vive la capa domain. No existe porque nos gusten las «capas por las capas», sino porque los servicios y repositorios necesitan un modelo funcional: cómodo para almacenar, actualizar, ordenar, aplicar reglas de negocio y, en general, para una vida normal.

En el proyecto Task Tracker API, el modelo interno no es un contrato público. Son tus «documentos internos». Si mañana decides almacenar algunos campos adicionales o cambiar la estructura del almacenamiento de etiquetas, el cliente no debería recibir eso de repente en la respuesta JSON solo porque añadiste un campo en la clase Java.

Un ejemplo simplificado de modelo interno Task (en el paquete com.example.tasktracker.domain.model) podría ser:

import java.time.Instant;

public class Task {

    // Lo que el sistema posee internamente (incluyendo campos de servicio)
    private String id;
    private String title;
    private String description;
    private String status;

    // El estado interno puede ser más rico que el DTO: por ejemplo, el momento de creación
    private Instant createdAt;

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

¿Por qué aquí puede haber createdAt y en el request DTO no? Porque es un hecho de estado del recurso que establece el servidor. El cliente puede querer controlar el tiempo (especialmente si el cliente es una máquina del tiempo), pero nuestro sistema normalmente vive en una realidad menos fantástica.

¿Por qué el modelo interno puede cambiar más a menudo que el DTO? Porque refleja tu implementación. Cambias la implementación cuando mejoras el código, añades campos por comodidad, optimizas el almacenamiento, introduces nuevas reglas de negocio. Los clientes, en cambio, quieren estabilidad. Los DTO actúan como un «amortiguador» entre «nosotros cambiamos internamente» y «el cliente todavía lo entiende y no le duele».

Otra perspectiva útil: el modelo interno existiría incluso si no hubiera HTTP. Imagina que mañana quieres invocar tu TaskService no a través de REST, sino desde otro código Java (por ejemplo, un proceso batch). Al modelo interno le daría igual. En cambio, los request/response DTO son pura historia de la API.

5. Frontera: controlador y servicio

El controlador en Spring MVC no es un lugar para la lógica de negocio, pero sí es un lugar adecuado para la traducción de formas de datos. Está en la frontera: fuera están JSON y DTO, dentro están los servicios y el modelo interno. Por eso el controlador suele verse como un despachador ordenado: recibe el request DTO, llama al servicio, convierte el resultado interno en response DTO.

Y aquí aparece una regla que al principio parece pesada, pero luego salva el proyecto: la capa de servicio no debe depender de los DTO de la capa API. No porque «está escrito en los libros inteligentes», sino porque de lo contrario estás vinculando la lógica de negocio al formato de entrada/salida HTTP.

Una mala opción (pero muy tentadora) se ve así:

public interface TaskService {

    // Mal: el servicio de dominio empieza a «pensar» con DTOs de la API externa
    TaskDetailsResponse create(TaskCreateRequest request);
}

A primera vista es cómodo: el servicio «lo hace todo». A segunda vista ya has infiltrado api.dto.request y api.dto.response en domain.service. Es decir, el dominio ahora sabe del contrato externo. Y por tanto, cualquier cambio en el contrato externo empieza a afectar al dominio, y viceversa. Es como diseñar la cocina de un restaurante basándose en el color del menú. Se puede, pero luego cuesta explicarse a uno mismo por qué.

Para el principio en sí, incluso esta variante es suficiente: el servicio recibe los datos necesarios, pero no empieza a vivir con DTOs de la API externa:

public interface TaskService {

    // Bien: la firma describe una acción de dominio y los datos necesarios para ella
    // (no la forma de la petición HTTP)
    Task create(String title, String description, String assigneeName);
}

La forma de la firma aquí no es sagrada. Los mismos datos se pueden pasar como un conjunto de parámetros, un comando separado o un borrador de dominio Task. Lo importante es la frontera: domain.service no depende de api.dto.*.

El servicio devuelve el modelo interno Task. Y el controlador ya decide qué y en qué forma devolver al exterior.

Un ejemplo de método de controlador que muestra los tres roles en un solo lugar (y eso es normal, porque el controlador es la frontera):

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@PostMapping("/api/v1/tasks") // Ruta HTTP: es responsabilidad del controlador
public TaskDetailsResponse create(@RequestBody TaskCreateRequest request) {
    // 1) Entrada: request DTO (lo que envió el cliente)
    Task task = taskService.create(
        request.getTitle(),
        request.getDescription(),
        request.getAssigneeName()
    );

    // 2) Internamente: internal model (con lo que trabaja el dominio)
    // 3) Salida: response DTO (lo que prometemos devolver al cliente)
    return new TaskDetailsResponse(
        task.getId(),
        task.getTitle(),
        task.getDescription(),
        task.getStatus()
    );
}

Incluso si en lugar de un conjunto de parámetros luego montas un borrador de dominio Task y lo pasas al servicio, el significado no cambia: el request DTO se queda en la frontera de la API y el servicio no empieza a depender del modelo HTTP.

Aquí es importante ver la estructura del pensamiento: request DTO en la entrada, internal model dentro después del servicio, response DTO en la salida. Y cada modelo tiene su propio rol. Si aprendes a ver esto como «tres capas de datos», luego te resultará mucho más fácil mantener la disciplina de la API.

6. Flujo de datos en el escenario de creación

Para que todo esto no quede como «teoría sobre arquitectura correcta», es útil recorrer mentalmente una simple petición de principio a fin. El escenario de creación es bueno porque muestra la dirección de los datos: el cliente envía la entrada, el servidor forma el estado, el servidor devuelve la salida. Y en ese punto se ve por qué es difícil hacer todo con un solo modelo, aunque «se quiera más rápido».

Vamos a fijar el flujo sin detalles innecesarios y sin entrar en estados de respuesta HTTP. Desde el punto de vista de los datos, se ve así:

flowchart TD
    %% Fuera: el JSON de la petición se convierte en request DTO
    A["JSON (cuerpo de la petición)"] --> B["TaskCreateRequest (request DTO)"]
    %% Dentro: el servicio de dominio crea y devuelve el modelo interno
    B --> C["TaskService (domain)"]
    C --> D["Task (internal model)"]
    %% A la salida: el modelo interno se mapea a response DTO y se serializa a JSON
    D --> E["TaskDetailsResponse (response DTO)"]
    E --> F["JSON (cuerpo de la respuesta)"]

El centro de esta cadena se puede implementar de diferentes formas, pero dos reglas se mantienen: el request DTO no fluye directamente al dominio, y el response DTO aparece después de la operación de dominio.

Si lo describimos con palabras humanas, resulta casi aburrido (y eso es una buena señal): el cliente envió datos para crear, el servidor creó la tarea, el servidor devolvió la representación de la tarea creada. Y aquí ocurre la magia… más bien, la traducción: los datos pasan por diferentes formas, pero el sentido se vuelve más claro.

Para ser aún más concreto, podemos imaginar una de las variantes cortas de cómo «nace» una tarea dentro del servicio:

import java.time.Instant;
import java.util.UUID;

public Task create(String title, String description, String assigneeName) {
    // Creamos el modelo interno: este es el objeto con el que «vive» el dominio
    Task task = new Task();

    // El servidor asigna lo que el cliente no debe dictar directamente
    task.setId(UUID.randomUUID().toString()); // identificador único
    task.setTitle(title);                     // datos del usuario
    task.setDescription(description);         // datos del usuario
    task.setStatus("TODO");                   // el estado inicial lo asigna el sistema
    task.setCreatedAt(Instant.now());         // el momento también lo asigna el sistema

    // Se asume que Task tiene setters (en el ejemplo anterior no se muestran)
    return task;
}

Sí, aquí aparecen valores que no estaban en la entrada: id, status, createdAt. Esa es precisamente la diferencia de roles: el request DTO no debe contener todo lo que aparecerá al final. Contiene lo que el cliente ingresa, y el modelo interno contiene lo que el sistema posee.

7. Errores típicos al separar modelos

Los errores en este tema normalmente no se ven como un «error de compilación rojo». Se ven como «todo funcionaba, y al cabo de dos semanas se volvió doloroso de cambiar». Por eso es útil saber de antemano dónde se suele pisar el rastrillo y, si es posible, pisarlo al menos con zapatillas suaves.

Error nº 1: hacer el request DTO y el response DTO iguales «por si acaso».
Parece un ahorro, pero en realidad estás mezclando dos roles diferentes. El modelo de entrada empieza a hincharse con campos que el cliente no necesita enviar, y el modelo de salida con campos que el cliente no necesita ver. El resultado es un contrato difuso y la eterna pregunta «¿se puede enviar/cambiar este campo?».

Error nº 2: añadir campos de servicio al modelo de entrada «por comodidad».
El síntoma más frecuente es la aparición de id, status, createdAt en el request DTO. Incluso si por ahora no ves el problema, esto hace que el contrato sea «demasiado amplio»: el cliente empieza a controlar lo que debería asignar el servidor. Luego es muy difícil «volver atrás», porque el cliente ya se ha acostumbrado a que «se puede».

Error nº 3: el servicio o repositorio empiezan a trabajar con response DTO.
Cuando domain.service devuelve TaskDetailsResponse, significa que la capa de negocio ahora piensa en categorías de respuesta HTTP. Hoy parece cómodo, y mañana querrás devolver otro formato de respuesta (o crear un segundo endpoint con otra representación) y de repente la lógica de negocio empieza a romperse por el cambio en la forma de la salida.

Error nº 4: la creencia de que «si un campo está en la respuesta, debe estar también en la entrada».
Psicológicamente se parece a «la simetría es bonita». Pero la API no es un espejo. Los campos en la respuesta suelen ser más amplios, porque el servidor comunica hechos: id, status, momento de creación. Si obligas a la entrada a «repetir» la salida, le das al cliente palancas adicionales para influir en el estado.

Error nº 5: la lógica HTTP se filtra a la capa de servicio.
Si el servicio empieza a devolver ResponseEntity, a recibir HttpServletRequest o a decidir qué código de estado poner, estás perdiendo la frontera: el dominio se vuelve dependiente de la web. El controlador en ese momento se convierte en un «redireccionador» y el servicio en un «controlador disfrazado». En un proyecto de aprendizaje esto se nota especialmente: el código parece pequeño, pero la sensación es como si te hubieras perdido en un bosque.

1
Tarea
Spring REST & MVC, nivel 10, lección 1
Bloqueada
Creacion de nota con modelos separados de entrada y salida
Creacion de nota con modelos separados de entrada y salida
1
Tarea
Spring REST & MVC, nivel 10, lección 1
Bloqueada
Creación y lectura de película a través de request DTO, response DTO y modelo interno
Creación y lectura de película a través de request DTO, response DTO y modelo interno
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION