1. Separación de entidad y DTO
Casi todo principiante en algún momento propone una optimización genial: “No multipliquemos las clases. Aquí está Product — lo guardamos en la base de datos, lo pasamos entre capas y lo devolvemos al exterior. Una clase, un amor”. Suena bonito, como “un botón, un despliegue a producción”. Pero precisamente esa “simplificación” a menudo convierte el proyecto en una frágil madeja de capas, donde cualquier cambio empieza a romperlo todo de inmediato.
Para ver el problema, imaginemos dos necesidades que parecen similares, pero en realidad son diferentes.
La primera necesidad: necesitamos almacenar un producto como un registro persistente de datos. Se trata de tabla, columnas, restricciones, identidad, reglas de JPA, ciclo de vida del objeto respecto al contexto de persistencia. La segunda necesidad: necesitamos transferir los datos del producto a algún otro lugar — por ejemplo, a otra capa de la aplicación, a una prueba, a un log, a un futuro adaptador web o simplemente “como respuesta” de alguna operación. Esto ya es sobre la forma de los datos para la transferencia: qué campos se necesitan, en qué formato, con qué garantías (immutable/mutable), y qué se puede mostrar de forma segura.
Si mezclamos estas dos necesidades en una sola clase, obtenemos una clase que al mismo tiempo debe cumplir los requisitos de JPA y los requisitos de transferencia de datos. Y estos requisitos… digamos que no son muy amigos. Es como intentar hacer una herramienta que sea a la vez martillo y microscopio. Clavar clavos con un microscopio se puede, pero luego sorprenderse de las grietas en las lentes también se puede.
2. Entidad: modelo de almacenamiento, no DTO
Una entidad en JPA no es solo “una clase que se puede guardar”. Es un objeto que vive según las reglas del modelo de persistencia: tiene identidad, está vinculado al esquema de la base de datos, obedece las restricciones del ORM y puede encontrarse en diferentes estados (recordamos transient/managed/detached/removed). Por lo tanto, la entidad conlleva requisitos y “sorpresas” que son muy incómodas de arrastrar por toda la aplicación si la tratas como un simple portador de datos.
Para sentir la diferencia, basta comparar cómo pensamos sobre Product como entidad y como DTO.
La entidad es “un objeto que representa un registro en la BD”. Nos interesa qué y cómo se almacena, qué campos son obligatorios, cuáles son únicos, qué tipos de columnas elegimos, cómo se ve la identidad y qué clave de negocio es estable. El DTO es un “mensaje” que transmitimos: no está obligado a repetir el esquema de la BD ni a ser apto para la persistencia.
Aquí hay un pequeño (no completo) fragmento de la entidad Product, solo para recordar su carácter:
package com.example.shopdatajpa.catalog.entity;
import java.math.BigDecimal;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity // Marcamos la clase como entidad JPA: esto ya es un "modelo de almacenamiento", no solo un POJO.
public class Product {
@Id // Identidad de la entidad en términos de ORM/BD.
private Long id;
// Los campos siguientes son los que normalmente se mapean a columnas de la tabla.
// Getters/setters y demás anotaciones se omiten intencionadamente: es un fragmento para demostrar el rol.
private String sku;
private String name;
private BigDecimal price;
}
Incluso en un fragmento tan corto se ve: la entidad “recuerda” a JPA (@Entity, @Id). Y no son etiquetas decorativas. Es una señal: “conmigo trabaja un ORM, tengo reglas”.
Ahora imagine que empieza a usar esta misma clase como DTO y la lleva “hacia afuera” (a otras capas). En algún momento querrá añadir un campo “representación bonita del precio”, por ejemplo priceLabel = "1999.00 USD" o “descripción corta para el catálogo”. Y ahí comienza lenta pero segura la difuminación de límites: en la entidad empiezan a aparecer campos que no son de almacenamiento sino de presentación. Parece una tontería, pero precisamente de esas tonterías nace el “monstruo DTO”, que ya no es ni una buena entidad ni un buen DTO.
Además, es importante recordar: ya sabemos que las entidades pueden estar en diferentes estados. Un DTO como idea de “simples datos” no tiene estado managed/detached. O existe o no. En cambio, una entidad puede estar “bajo supervisión” del ORM o no. Si entrega una entidad a un lugar donde no se piensa en ello, aumenta drásticamente la probabilidad de obtener efectos inesperados: el objeto se modifica “sin más”, sin entender que puede no ser un simple objeto, sino parte de la mecánica de persistencia.
3. DTO: datos de transporte y record
DTO (Data Transfer Object) es un honesto “contenedor para transferencia de datos”. No está obligado a estar vinculado a una tabla. No está obligado a tener @Entity. No está obligado a tener una clave de negocio ni a seguir las reglas de identidad de una entidad. Su objetivo es más simple: expresar la forma de los datos que se necesita en el límite entre capas (o entre un módulo y el “mundo exterior”), y hacerlo de la manera más explícita posible.
Muy a menudo es conveniente hacer un DTO inmutable, para que no se pueda modificar accidentalmente “en algún lugar del camino”. Y aquí los records de Java son casi un regalo (perdón por el juego de palabras): son cortos, legibles y dicen directamente: “esto son datos”.
Por ejemplo, un DTO para mostrar un producto “hacia afuera” (no importa si hacia afuera en la web o hacia afuera en otra capa) podría verse así:
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
public record ProductDto(
Long id, // Identificador: necesario para referencias/operaciones, pero no obligatorio en cada DTO.
String sku, // Código del producto — lo que queremos transferir entre capas.
String name, // Nombre para la visualización/lógica del caso de uso.
BigDecimal price // Precio en forma "pura", sin detalles de JPA ni formateo para la UI.
) {
// Por defecto el record es inmutable: menos posibilidades de "cambiar accidentalmente" el DTO en el camino.
}
Observe dos cosas.
Primero, el DTO puede incluir solo aquellos campos que necesitamos en un escenario concreto. Por ejemplo, si en algún lugar no necesitamos id (digamos, al crear un producto), creamos otro DTO sin id. Es normal.
Segundo, el DTO no arrastra el rol de JPA. Aquí no hay anotaciones de ORM, no hay requisitos de constructor “para Hibernate”, no hay semántica fina de identidad. Son solo datos.
Ahora comparemos esto con la entidad una vez más, no en código sino en sentido:
La entidad se encarga de la corrección del modelo de persistencia. El DTO se encarga de la transmisión cómoda de datos. Estos roles son diferentes, e intentar “pegarlos” es como obligar al cocinero a ser también camarero y contable. Hay pequeños cafés donde ocurre así, pero luego no se sorprenda de que la sopa esté salada, la cuenta incorrecta y el humor del cocinero misterioso.
4. El límite: mapeo y conversión
Las palabras “la entidad no debe ser un DTO” suenan bonitas, pero surgen preguntas del tipo “Vale. ¿Y cómo vivir?”. La respuesta es aburrida pero funcional: se necesita un mapeo explícito. Es decir, alguien en el código debe tomar una entidad y convertirla en DTO, y viceversa. Y ese “alguien” debe estar en el lugar donde realmente pasa el límite, y no repartirse “por todas partes un poco”.
El error más común de un principiante es hacer la conversión “donde sea”. Un lugar toDto vive en la entidad, otro en el servicio, otro en el controlador (que ahora no tenemos), y otro en una prueba con copia y pega. Luego cambias un campo (por ejemplo, renombras name a title) y vas a recoger una cosecha de errores de compilación por todo el proyecto.
En un proyecto de aprendizaje es mejor elegir un estilo simple y claro: una clase mapper independiente junto al paquete de la funcionalidad. Por ejemplo, ProductMapper en el contorno de catalog. Y que haga dos operaciones: toDto(Product) y fromRequest(CreateProductRequest) (o similar). Sin frameworks, sin magia, solo código Java.
Un mapper mínimo podría verse así:
package com.example.shopdatajpa.catalog.dto;
import com.example.shopdatajpa.catalog.entity.Product;
public final class ProductMapper {
private ProductMapper() {
// Prohibimos la creación de instancias: es una clase utilitaria con métodos estáticos.
}
public static ProductDto toDto(Product product) {
// Punto explícito del límite: desde la entidad (modelo de almacenamiento) hacemos un DTO (modelo de transferencia).
// Aquí es conveniente controlar qué campos salen "hacia afuera".
return new ProductDto(
product.getId(),
product.getSku(),
product.getName(),
product.getPrice()
);
}
}
El código es corto y directo. Justo lo que necesitamos ahora: que el límite sea explícito y visible. Cuando el límite es visible, la arquitectura deja de ser un “acuerdo en las mentes” y se convierte en parte del código.
Y la dirección inversa — DTO “de entrada” (por ejemplo, una solicitud para crear un producto). Podemos crear un DTO separado que refleje precisamente la forma de los datos de entrada. Por ejemplo:
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
public record CreateProductRequest(
String sku, // Lo que introduce el mundo exterior (cliente/prueba/adaptador).
String name, // Datos de entrada para la creación, no una "foto de la entidad".
BigDecimal price, // Valor de precio de entrada (luego la lógica de dominio puede validarlo).
ProductStatus status // Ejemplo de campo que puede ser parte del escenario de negocio de creación.
) {
// Importante: aquí no hay id — normalmente se genera en nuestro lado (BD/aplicación).
}
Y la conversión a entidad:
package com.example.shopdatajpa.catalog.dto;
import com.example.shopdatajpa.catalog.entity.Product;
public final class ProductMapper {
public static Product fromRequest(CreateProductRequest req) {
// Conversión "hacia dentro": el DTO externo se transforma en entidad para guardarlo.
// Aquí es conveniente hacer normalización/preparación de datos (en un proyecto real a menudo + validación).
Product p = new Product();
p.setSku(req.sku());
p.setName(req.name());
p.setPrice(req.price());
p.setStatus(req.status());
return p;
}
}
Observe el importante efecto psicológico: CreateProductRequest no intenta ser “casi una entidad”. No tiene id. Y es correcto: id no debe venir “desde fuera” como campo obligatorio al crear si lo generamos nosotros en la base de datos. Es decir, el DTO nos ayuda a mantener el modelo lejos de absurdos.
5. DTO en mini-shop alrededor de Product
Si miramos mini-shop como un proyecto real, casi de inmediato se descubre que “un DTO para todo” tampoco funciona. Pero eso ya es una buena señal: significa que hemos empezado a pensar en casos de uso. Para diferentes escenarios se necesitan diferentes formas de datos.
Para crear un producto se necesita un conjunto de campos, donde no hay id, pero sí campos necesarios para la creación. Para “mostrar el producto en la lista del catálogo” se necesita otro conjunto: quizás nombre corto, precio y estado. Para la “ficha detallada del producto” (que pronto reforzaremos con ProductDetails) se necesita una forma más rica, que incluya descripción, fabricante, garantía, etc.
Y aquí hay una disciplina muy importante: no intente resolverlo añadiendo nuevos campos a la entidad “para que todos estén cómodos”. Porque así vuelve a convertir la entidad en un objeto universal. El movimiento correcto es crear DTO para límites concretos.
Por ejemplo, una “fila del catálogo” podría ser así:
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
public record ProductCatalogRow(
Long id, // Necesario para abrir la ficha/realizar una acción sobre un producto concreto.
String sku, // Normalmente se muestra en la lista, ayuda a buscar/identificar el producto.
String name, // Nombre corto para la lista.
BigDecimal price // Precio para la lista (sin detalles de la ficha).
) {
}
Y la “ficha detallada” puede ser otra (de momento sin ProductDetails, solo la idea de forma):
package com.example.shopdatajpa.catalog.dto;
import java.math.BigDecimal;
public record ProductCardDto(
Long id, // Identificador del producto.
String sku, // Código/artículo.
String name, // Nombre completo para la ficha.
BigDecimal price, // Precio.
String description // Detalles de la ficha: esto es una "forma de datos", no una parte obligatoria del modelo de persistencia.
) {
}
Sí, son “más clases”. Pero no se multiplican sin control. Reflejan límites reales: un caso de uso, una forma de datos. Y lo más agradable: si mañana la ficha necesita añadir un campo, no está obligado a modificar la entidad. Cambia el DTO y el mapeo. El modelo de persistencia se mantiene estable y no se convierte en un “constructor para cualquier necesidad de la UI”.
Ahora un pequeño enlace con los temas de hoy sobre @Embeddable. Una trampa frecuente: “Como DeliveryAddress es una clase separada, entonces se puede usar también como DTO”. Formalmente, por supuesto, puede pasarla en algún lugar. Pero conceptualmente sigue siendo parte del modelo de persistencia. Pueden aparecer anotaciones JPA, reglas de columnas, restricciones. Por lo tanto, es más seguro y claro separar: DeliveryAddress como parte de la entidad es una cosa, DeliveryAddressDto como forma de transporte es otra. Aunque a veces coincidan en campos, esa coincidencia debe ser consciente, no accidental.
Y sí, notará que constantemente decimos “hacia afuera”, aunque hoy no haya una capa web completa en nuestro proyecto data-layer-first. Es normal. Estamos creando el hábito: no mezclar el modelo de persistencia con el modelo de transferencia de datos. Incluso si “hacia afuera” son solo pruebas, “hacia afuera” sigue existiendo. Y en el trabajo real, “hacia afuera” casi siempre está presente.
6. Errores típicos al mezclar entidad y DTO
Error n.º 1: “Una clase para todas las capas, y la vida será más fácil”.
Esta decisión parece un ahorro de tiempo, pero en la práctica es un crédito con alto interés. La entidad empieza a llenarse de campos “para visualización”, “para formulario”, “para serialización cómoda”. Al final obtiene una clase que no es cómoda ni para la base de datos ni para la transferencia de datos. Normalmente termina teniendo que introducir DTO de todas formas, pero ya es tarde y doloroso.
Error n.º 2: En el DTO se meten anotaciones JPA “por si acaso”.
A veces alguien piensa: “Bueno, el DTO se parece a una entidad, que sea también @Entity, por si acaso”. Es como llevar casco en la ducha: puede que esté protegido, pero es extraño e incómodo. El DTO debe seguir siendo una forma de transporte pura. Si necesita anotaciones, normalmente son de serialización o validación, no de ORM. Pero incluso esas conviene añadirlas con cuidado, para no volver a mezclar capas.
Error n.º 3: Se entrega la entidad al exterior y luego se sorprenden de “cambios extraños”.
Una entidad es un objeto con ciclo de vida, y ya nos hemos familiarizado con la idea de managed/detached. Cuando la entidad sale “hacia afuera”, a capas donde no se piensa en ello, empiezan a modificarla “como un objeto normal”. Luego viene la sorpresa: por qué los datos se comportan de manera extraña, por qué el objeto “a veces funciona, a veces no”. Incluso sin un conocimiento profundo de Hibernate, aquí es útil una regla de higiene: la entidad es un modelo interno de la capa de datos, no un portador universal de datos.
Error n.º 4: El mapeo está disperso por el código, y cada lugar hace “como le sale”.
Hoy escribió toDto en el servicio, mañana en la prueba, pasado mañana “rápidamente en el controlador”. Al mes añade un campo y se encuentra con una decena de formatos que no coinciden. Es mejor tener uno o dos puntos de conversión explícitos (mappers), aunque sean simples y manuales. Es aburrido, pero es precisamente lo aburrido sobre lo que se sostiene la mantenibilidad.
Error n.º 5: Se hace un DTO “como la entidad, solo sin @Entity”, sin preguntarse “¿para qué sirve realmente?”.
El DTO no es un ritual. Es necesario cuando cambia el rol del objeto: almacenamiento frente a transferencia. Si el DTO copia la entidad uno a uno y no hace nada más, no siempre está mal, pero debe ser consciente. Si el DTO aparece solo porque “así se acostumbra”, corre el riesgo de tener montones de clases sin sentido. Un buen DTO siempre responde a la pregunta: “¿qué caso de uso lo requiere y qué límite delimita?”.
GO TO FULL VERSION