1. Product plano y campos desperdigados
Si estás escribiendo un proyecto de aprendizaje, la mano misma se estira para crear una gran clase Product y meterlo todo ahí: precio, estado, descripción, fabricante, país, garantía, y luego, "por si acaso", un campo notesForAdmins (y otro más — notesForAdminsV2, porque el primero "no era el adecuado"). Al principio parece cómodo: todo está junto. Luego abres esa clase de 200 líneas y te das cuenta de que el modelo se ha vuelto como un cajón de cocina donde hay tenedores, pilas y un destornillador triste.
En la data-layer esto es especialmente molesto porque un modelo plano casi siempre trae dos problemas. El primero es la mala legibilidad: dejas de ver el "núcleo" de la entidad. El segundo es la desdibujación de límites: cuando una entity se convierte en un "contenedor universal para todo", empiezan a filtrarse campos "para la respuesta", "para la UI", "para el panel de administración", "para el futuro". Y así, sin darte cuenta, el proyecto se convierte en un museo de decisiones aleatorias.
Nuestra tarea de hoy es hacer el modelo más rico, pero no hacerlo informe. Añadiremos ProductDetails para que el producto tenga un ordenado "compartimento de pasaporte" para descripciones y características, y añadiremos DeliveryAddress como objeto incrustable, para que el pedido tenga una dirección como concepto unificado. Al mismo tiempo, deliberadamente no entramos en el tema de las relaciones entre entidades: no construimos @OneToOne, no modelamos OrderItem y no incluimos un "desfile de anotaciones". Ahora necesitamos una variante funcional del modelo que podamos llevar tranquilamente más adelante en el proyecto.
Para ver la diferencia "antes/después", es conveniente representar el modelo así:
classDiagram
class Product {
+Long id
+String sku
+String name
+BigDecimal price
+ProductStatus status
}
class ProductDetails {
+Long id
+String description
+String manufacturer
+String countryOfOrigin
+Integer warrantyMonths
}
class CustomerOrder {
+Long id
+String orderNumber
+String customerEmail
+DeliveryAddress deliveryAddress
}
class DeliveryAddress {
+String city
+String street
+String house
+String postalCode
}
CustomerOrder "1" o-- "1" DeliveryAddress : "embedded (value)"
Product "1" .. "0..1" ProductDetails : "details (sin relación JPA por ahora)"
Fíjate en la línea punteada entre Product y ProductDetails: no está aquí porque "sea correcto en JPA", sino porque hoy no incluimos la mecánica de relaciones. Estamos haciendo una preparación del modelo para que más adelante tengamos algo que relacionar, y que relacionarlo sea ordenado.
2. ProductDetails: modelo más profundo, no más ancho
Cuando decimos "añadamos ProductDetails", el objetivo no es crear otra clase por la clase misma. El objetivo es que a Product le quede un núcleo que participe en casi todos los escenarios, y que los detalles vivan aparte y no conviertan al producto en un "objeto-dios". En una tienda real, por ejemplo, un producto casi siempre tiene SKU, nombre, precio y estado — y esto se necesita en todas partes. En cambio, la descripción y la garantía no se necesitan en cada lectura.
Aquí es importante separar dos situaciones diferentes. DeliveryAddress no tiene identidad propia y no se necesita fuera del pedido, por lo que le conviene el enfoque embedded: es parte de una sola fila del pedido. ProductDetails es otro caso: aquí separamos deliberadamente los detalles del producto en un bloque persistence independiente con su propio registro, en lugar de dejarlos simplemente como un conjunto de columnas de la misma entidad. Por eso ahora configuramos ProductDetails como una entity independiente. La relación JPA con Product no la dibujamos todavía: para esta parte nos es más importante primero separar el núcleo del producto y sus detalles, sin mezclar esta idea con la mecánica de relaciones.
Dónde poner ProductDetails en los paquetes
Por ahora nos mantenemos en el principio de package-by-feature: todo lo que se refiere al catálogo — en catalog, no en una "bolsa" general de entities. Por lo tanto, creamos la clase aquí: com.example.shopdatajpa.catalog.entity.ProductDetails.
Esto es más importante de lo que parece. Dentro de un par de semanas de estudio, cuando aparezcan ordering e inventory, agradecerás no haber metido todo en un solo paquete, como calcetines después de lavarlos.
Esqueleto mínimo de ProductDetails como entity
Haremos de ProductDetails una entity completa, para que Hibernate pueda crear la tabla y podamos persistirla.
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.*;
@Entity // Entity: una fila separada en la tabla
@Table(name = "product_details") // Fijamos explícitamente el nombre de la tabla en la BD
public class ProductDetails {
@Id // Identificador técnico de la entidad
@GeneratedValue(strategy = GenerationType.IDENTITY) // id asignado por la base de datos al hacer insert
private Long id;
}
Ahora ProductDetails es una entidad separada en el esquema y un bloque de datos independiente en el modelo. Esto ya es suficiente para separar roles sin mezclar esta cuestión con la mecánica de relaciones.
Sí, parece casi ridículamente corto. Pero es un buen comienzo: una entity no son "campos", sino un rol y un contrato.
Ahora añadamos campos. Para no convertir el ejemplo en una novela de 600 páginas, añadiremos solo los clave:
import jakarta.persistence.Column;
@Column(name = "manufacturer", nullable = false, length = 120) // Fabricante obligatorio, limitamos la longitud explícitamente
private String manufacturer;
@Column(name = "country_of_origin", length = 80) // El país puede faltar, por lo tanto nullable por defecto
private String countryOfOrigin;
@Column(name = "warranty_months") // La garantía puede ser desconocida -> Integer, no int
private Integer warrantyMonths;
Y aparte, la descripción. Para un principiante hay dos preguntas "normales": "¿por qué length?" y "¿por qué no text?". La respuesta hoy es lo más honesta posible: estamos aprendiendo a mantener el modelo ordenado, y nos es importante definir explícitamente los límites de los campos. El tipo text también lo veremos, pero por ahora no lo complicamos. El sentido es mostrar disciplina en el mapeo.
import jakarta.persistence.Column;
@Column(name = "description", length = 2000) // Texto largo, pero aún con un límite explícito
private String description;
Constructores y sensación "value-like"
Aunque ProductDetails es una entity (tiene id), por su sensación a menudo se comporta como "parte del producto": rara vez se crea sin un producto. Por eso es conveniente tener un constructor que llene los campos significativos, y dejar un constructor vacío para JPA.
public ProductDetails() {
// JPA requiere un constructor público/protegido sin argumentos
}
public ProductDetails(String description, String manufacturer) {
// Constructor "vital" conveniente: creamos el objeto ya en un estado significativo
this.description = description;
this.manufacturer = manufacturer;
}
Ahora parece simple. Pero ya afecta la calidad del código: "dispersas" menos la entidad a través de setters, y la creas más en un estado significativo. Y las entidades aman los estados significativos. Así tienen menos motivos para vengarse.
toString() — solo diagnóstico breve
Y aquí aplicamos la regla de la lección 2: toString() no es serializar todo el universo. Para ProductDetails basta con un par de campos para que los logs sean agradables de leer.
@Override
public String toString() {
// No imprimimos description: de lo contrario es fácil obtener un "ladrillo" en los logs
return "ProductDetails{id=%s, manufacturer='%s'}"
.formatted(id, manufacturer);
}
Fíjate en el estilo: no imprimimos toda la descripción de 2000 caracteres. De lo contrario tus logs empezarán a parecer un chat de soporte.
3. DeliveryAddress y el CustomerOrder mínimo
Con DeliveryAddress la lógica es diferente. Una dirección de entrega no se necesita como entidad independiente: no tiene id propio, ni vida separada, ni significado fuera de un pedido concreto. Por lo tanto, no desenrollamos de nuevo toda la teoría sobre @Embeddable, sino que fijamos inmediatamente la base de trabajo del mini-shop: la dirección es una parte value-like de CustomerOrder, sus campos viven en la misma fila de la tabla del pedido.
DeliveryAddress como @Embeddable
Crearemos la clase: com.example.shopdatajpa.ordering.entity.DeliveryAddress.
package com.example.shopdatajpa.ordering.entity;
import jakarta.persistence.Embeddable;
@Embeddable // Objeto incrustable: sus campos se convertirán en columnas en la tabla de la entidad propietaria
public class DeliveryAddress {
// Campos de la dirección: parte del pedido, no una entidad separada con id propio
private String city;
private String street;
private String house;
private String postalCode;
}
Pero en la práctica casi siempre se desea que las columnas en la tabla del pedido no se llamen simplemente city, sino, por ejemplo, delivery_city, de lo contrario tarde o temprano te encontrarás con un conflicto de nombres o confusión del tipo "¿qué city es esta?". En la base de trabajo del mini-shop es más conveniente definir estos nombres directamente en los campos de DeliveryAddress.
import jakarta.persistence.Column;
@Column(name = "delivery_city", nullable = false, length = 80) // El prefijo delivery_ ayuda a evitar conflictos de nombres
private String city;
@Column(name = "delivery_street", nullable = false, length = 120)
private String street;
Muestro intencionadamente solo dos campos para no convertir el código en un muro. La lógica es la misma para house y postalCode.
Ahora añadimos constructores: vacío para JPA y normal para la vida real.
public DeliveryAddress() {
// for JPA
}
public DeliveryAddress(String city, String street, String house, String postalCode) {
this.city = city;
this.street = street;
this.house = house;
this.postalCode = postalCode;
}
CustomerOrder mínimo para tener dónde incrustar la dirección
Para este fragmento basta un CustomerOrder mínimo: id, número, email y dirección incrustada. Esto ya es suficiente para ver el propietario de DeliveryAddress sin ruido innecesario.
package com.example.shopdatajpa.ordering.entity;
import jakarta.persistence.*;
@Entity // Owner-entity: es la que se almacena como una fila separada en la tabla
@Table(name = "customer_order")
public class CustomerOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // id aparece después de persist/flush
private Long id;
@Embedded // Los campos de DeliveryAddress se desplegarán en columnas de la tabla customer_order
private DeliveryAddress deliveryAddress;
}
Y añadimos un par de campos de negocio para que el pedido sea un pedido, y no solo una "tablilla para la dirección".
import jakarta.persistence.Column;
@Column(name = "order_number", nullable = false, unique = true, length = 40)
private String orderNumber;
@Column(name = "customer_email", nullable = false, length = 120)
private String customerEmail;
equals()/hashCode para CustomerOrder: con cuidado y con fundamento
CustomerOrder es un buen candidato para business key: orderNumber normalmente es único y estable. Por lo tanto, podemos aplicar la regla de la lección 2: comparamos por él, pero con protección contra null.
import java.util.Objects;
@Override
public boolean equals(Object o) {
if (this == o) return true;
// instanceof con pattern variable: seguro y legible
if (!(o instanceof CustomerOrder other)) return false;
// Comparación por business key: id puede ser null antes de persistir
return orderNumber != null && orderNumber.equals(other.orderNumber);
}
@Override
public int hashCode() {
// hashCode debe corresponder a equals y ser estable para la clave
return Objects.hashCode(orderNumber);
}
Sí, parece "rutinario". Pero es precisamente esta rutina la que te ahorra horas de depuración cuando un día metas un pedido en un HashSet y luego descubras de repente que "ha desaparecido".
4. Smoke-check: persistimos y vemos SQL
Ahora viene lo más agradable: no solo dibujamos clases, sino que comprobaremos que esta parte del modelo realmente cobra vida en el mundo persistence. Aquí no se necesita una capa adicional sobre el ejemplo: basta con EntityManager en un pequeño inicializador de desarrollo.
Y otro hecho técnico que necesitamos ahora mismo: la escritura en la BD vive dentro de una transacción.
Esqueleto del componente (fijarse en @Profile("dev"), para no ensuciar otros modos):
package com.example.shopdatajpa.common.dev;
import jakarta.persistence.EntityManager;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component // Registramos el componente en el contexto de Spring
@Profile("dev") // Lo activamos solo en el perfil dev
public class DevSmokeData {
private final EntityManager em;
public DevSmokeData(EntityManager em) {
// EntityManager — forma de bajo nivel para trabajar con el contexto de persistence
this.em = em;
}
}
Y ahora el método que crea datos de prueba. Mostraré solo la "esencia" — unas pocas líneas para que veas que todo se guarda y que los id aparecen.
import org.springframework.transaction.annotation.Transactional;
@Transactional // Sin transacción, persist no "empuja" el insert (en configuración normal)
public void createSampleOrder() {
// Creamos el pedido directamente en estado válido: número, email y dirección
CustomerOrder order = new CustomerOrder(
"ORD-1001",
"alice@example.com",
new DeliveryAddress("Boston", "Main st", "10A", "02101")
);
em.persist(order); // Colocamos la entidad en el contexto de persistence
// id aparece después del insert (puede ser inmediato si es IDENTITY, y/o después de flush)
System.out.println("order.id = " + order.getId()); // order.id = 1
}
Sí, aquí uso el constructor CustomerOrder(...) que aún no había mostrado. Es una práctica normal en la lección: primero entiendes la idea, y luego añades con cuidado el constructor "cómodo" para que el código cree objetos en estado válido.
Mini-constructor para CustomerOrder:
public CustomerOrder(String orderNumber, String customerEmail, DeliveryAddress deliveryAddress) {
this.orderNumber = orderNumber;
this.customerEmail = customerEmail;
this.deliveryAddress = deliveryAddress;
}
Ahora — crearemos ProductDetails y lo persistiremos. Todavía no construimos la relación con Product, por lo que será simplemente un registro independiente.
@Transactional
public void createSampleDetails() {
ProductDetails details = new ProductDetails(
"Simple phone with a big battery",
"Acme"
);
em.persist(details);
System.out.println("details.id = " + details.getId()); // details.id = 1
}
Y aquí es útil ver el SQL con los ojos. Será más o menos en este espíritu (el formato depende de la configuración de logging):
insert into customer_order
(order_number, customer_email, delivery_city, delivery_street, delivery_house, delivery_postal_code)
values
(?, ?, ?, ?, ?, ?);
La observación más importante del día: @Embedded no crea una tabla separada. Incrusta los campos de la dirección en la tabla del pedido como columnas. Es exactamente lo que queríamos: la dirección es parte del pedido, no una entidad independiente.
5. Límite del modelo: entity, embeddable y DTO
Aquí confluyen tres roles diferentes, y en este fragmento es conveniente separarlos. ProductDetails es una entity: tiene su propio registro y un id. DeliveryAddress es un embeddable: no vive separado del pedido y se despliega en columnas de la misma tabla. DTO no es parte del modelo persistence: es una forma de datos que adaptamos a un caso de uso concreto.
Por lo tanto, no debemos adquirir el hábito de "entregar la entity tal cual" a partir de un snapshot así. Incluso si el límite externo es aún muy fino, sigue existiendo: una lista de productos, un test o un adaptador generalmente necesitan una forma de datos separada, no una entidad ORM con toda su mecánica interna.
Si mantienes ese límite, el código resulta más tranquilo: Product no se convierte en un contenedor universal, DeliveryAddress no pide una tabla separada sin motivo, y los datos para la transmisión se pueden recopilar por separado para cada escenario concreto.
6. Errores típicos al modelar
Error n.º 1: convertir Product en una clase "todo-en-uno" y justificarlo con las palabras "así es más simple".
En los primeros pasos realmente es más simple meterlo todo en Product. El problema es que "más simple" termina rápido: dejas de ver el núcleo de la entidad, empiezas a temer las modificaciones, y cualquier nueva característica añade otros cinco campos "por si acaso". ProductDetails sirve precisamente para que puedas decir: "aquí está el núcleo del producto, y aquí sus detalles", y al leer el código el cerebro no se ahogue en detalles.
Error n.º 2: hacer de DeliveryAddress una @Entity separada porque "si hay una clase, entonces hay una tabla".
Un principiante suele tener la sensación de que cualquier cosa del modelo debe convertirse en una entidad separada. La dirección es un contraejemplo clásico: no necesita identidad propia. Cuando haces de la dirección una entity separada, casi inevitablemente llegas a preguntas extrañas: "¿se puede referenciar la misma dirección desde dos pedidos?", "¿quién es el propietario de la dirección?", "la dirección cambió — ¿y ahora qué?". Si la dirección es parte del pedido, entonces @Embeddable da justo en el sentido.
Error n.º 3: escribir un toString() enorme y, sin querer, montarse una serialización de toda la base de datos en los logs.
Cuando una entidad pasa a formar parte del mundo ORM, "imprimirlo todo" deja de ser una idea inofensiva. Un toString() grande convierte los logs en un flujo de texto, dificulta la depuración y, en el futuro, crea fácilmente problemas con las relaciones (cuando aparezcan). Un buen toString() es corto, de diagnóstico y no intenta contar la biografía del objeto desde el nacimiento hasta el momento actual.
Error n.º 4: generar equals()/hashCode "como para DTO" y luego sorprenderse del comportamiento extraño de las colecciones.
Si equals()/hashCode se basan en campos mutables, el objeto puede "perderse" dentro de un HashSet o volverse una clave no localizable en un HashMap. Si equals se basa en id, las entidades nuevas empiezan a dar sorpresas porque id no aparece inmediatamente. Para CustomerOrder elegimos orderNumber como business key porque es comprensible y estable. Para otras entidades, a veces es más honesto dejarlo todo como está que hacer una "astucia incorrecta".
Error n.º 5: mezclar los roles de entity y DTO y añadir a la entity campos "para la respuesta" o "para el formulario".
Este es uno de los errores más pegajosos: una vez añades un campo a la entity "para que el frontend esté cómodo", luego otro, luego "bueno, esto es solo", y ya el modelo persistence se ha vuelto dependiente de la forma de la API. En un proyecto de aprendizaje esto es especialmente traicionero porque parece "no pasa nada", pero el hábito se fija. Mantén el límite firme: entity es para almacenamiento e invariantes, DTO es para transferencia de datos.
GO TO FULL VERSION