1. Invariantes de esquema en el diseño del mapeo
Cuando hablamos de «diseño del mapeo», es fácil quedarse atascado a nivel de Java: dónde poner @Column, qué FetchType, cómo nombrar el campo. Pero Hibernate al final se comunica con la base de datos. Por eso el modelo tiene un segundo «esqueleto»: el esquema, las restricciones y los índices que convierten las reglas en leyes, no en simples deseos.
Lo más molesto de los invariantes es que no se rompen de forma vistosa. Se rompen silenciosamente. Primero aparecen dos productos con el mismo sku. Luego alguien hace findBySku() y «por casualidad» obtiene el primero que encuentra. Después el informe de ventas empieza a calcularse con el producto «equivocado». Y luego pasas la tarde con café, registros y la pregunta filosófica: «¿Por qué la realidad es tan cruel si yo en el servicio verifiqué la unicidad?».
Seamos sinceros: el código del servicio puede verificar muchas cosas, pero no es el único punto de entrada a los datos. En la vida real, los datos son tocados por migraciones, administradores, scripts batch, y a veces incluso por «solo voy a echar un vistazo desde psql». Y lo que es más importante: la aplicación puede ejecutarse en varias instancias en paralelo, lo que significa que dos transacciones pueden decidir simultáneamente que el sku está libre.
Un buen modelo mental es este: las comprobaciones y validaciones del servicio son un «diálogo cortés» con el usuario y el código. Las restricciones del esquema son un «torniquete» que físicamente no permite el paso a datos incorrectos, incluso si lo pides con mucha convicción.
Un pequeño esquema de por qué «lo verifiqué en el servicio» y «lo garanticé» son cosas diferentes:
flowchart TD
%% Dos transacciones paralelas pueden ver "libre" al mismo tiempo (race condition)
A["Transacción #1: existsBySku(sku)=false"] --> B["INSERT product(sku)"]
C["Transacción #2: existsBySku(sku)=false"] --> D["INSERT product(sku)"]
%% El resultado depende de si el invariante está fijado en la propia BD
B --> E{"¿En la BD hay UNIQUE(sku)?"}
D --> E
E -->|Sí| F["Un INSERT pasa, el segundo falla con violación de restricción"]
E -->|No| G["Ambos INSERT pasan y obtienes un duplicado de datos"]
En nuestro curso esto es especialmente importante, porque la línea base del proyecto establece: el esquema es gestionado por Flyway, no usamos ddl-auto, la «generación automática de esquema a partir de anotaciones» está desactivada. Por lo tanto, si un invariante es importante, debe estar en las migraciones. Una anotación en la entidad puede ser útil como documentación, pero no reemplaza un ALTER TABLE real en la base de datos.
Y para que PromotionCampaign no se descomponga en tres esquemas alternativos, en esta parte mantenemos la variante SINGLE_TABLE con la tabla promotion_campaign: a ella se referirán UNIQUE, FK y los índices.
2. Restricciones Unique: claves de negocio y «pares únicos»
La unicidad es el tipo de invariante más comprensible: «tal sku debe ser único», «el email no puede pertenecer a dos clientes». En código es fácil expresarlo con una comprobación existsBy..., pero este enfoque falla en concurrencia y con modificaciones manuales de datos. Por eso UNIQUE vive en el esquema, donde no se puede saltar accidentalmente.
Es importante distinguir dos conceptos que a menudo se confunden. PRIMARY KEY es la unicidad del identificador técnico de la fila. Siempre es necesaria. UNIQUE es la unicidad de una regla de negocio, es decir, aquello que en el dominio «no debe repetirse». En el proyecto Commerce Persistence Lab hay bastantes reglas de este tipo, y no son decorativas: determinan si podemos confiar en nuestras «claves naturales» (sku, email, orderNumber, code).
Así se ve en un «mapa de invariantes» compacto para el esquema:
| Entidad/Tabla | Columna(s) | Invariante de dominio | Por qué es importante en el proyecto |
|---|---|---|---|
| product | sku | sku único | sku es la clave natural del producto y la base de la búsqueda de negocio |
| customer | email único | un email = un cliente (en el dominio de aprendizaje) | |
| purchase_order | order_number | orderNumber único | el pedido debe ser direccionable por su número |
| promotion_campaign | code | code único | la campaña se busca por código, no por id |
| inventory_item | product_id | un stock por producto | «un registro de stock por producto» es un invariante explícito |
| product_category_assignment | (product_id, category_id) | una relación por par | no se puede asignar un producto a una categoría dos veces |
Cómo fijarlo en Flyway
Como el esquema es gestionado por Flyway, la forma más honesta es agregar UNIQUE directamente en las migraciones. Presta atención a los nombres de las restricciones: un nombre legible luego se convierte en un mensaje de error legible. Es un detalle, pero ahorra dolores de cabeza.
Ejemplo para Product.sku y Customer.email:
-- Garantizamos la unicidad de la clave de negocio del producto (sku)
alter table product
add constraint uk_product_sku unique (sku);
-- Garantizamos que un email no puede pertenecer a dos clientes
alter table customer
add constraint uk_customer_email unique (email);
Ejemplo para el código de la campaña promocional:
-- El código de la campaña es una clave de negocio; buscamos la campaña por él en el código
alter table promotion_campaign
add constraint uk_campaign_code unique (code);
Para las entidades de enlace, la unicidad del par es literalmente «la primera regla del buen tono»:
-- Prohibimos duplicar la misma relación (producto ↔ categoría)
alter table product_category_assignment
add constraint uk_product_category unique (product_id, category_id);
Y un invariante muy importante del stock, que luego salvará muchas veces al modelo de «dos fichas de almacén para un mismo producto»:
-- Exactamente una fila de stock por producto (product_id)
alter table inventory_item
add constraint uk_inventory_product unique (product_id);
Qué obtienes en la práctica
Lo primero que obtienes es la imposibilidad de «estropear» datos silenciosamente. Si alguien intenta insertar un duplicado, PostgreSQL dice honestamente «no». El error será aproximadamente así (simplificado, pero reconocible):
ERROR: duplicate key value violates unique constraint "uk_campaign_code"
Detail: Key (code)=(SPRING-2026) already exists.
Lo segundo es una semántica más honesta de los repositorios. Por ejemplo, el método findByCode() en las campañas promocionales asume «0 o 1 registro». Es precisamente UNIQUE(code) lo que convierte esto de «esperamos que sea así» en «siempre será así».
Mini-ejemplo de repositorio (breve, sin magia extra):
package com.example.commerce.promotion.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PromotionCampaignRepository extends JpaRepository<PromotionCampaign, Long> {
// Devolvemos Optional porque por invariante esperamos "0 o 1" registro
Optional<PromotionCampaign> findByCode(String code);
}
Lo tercero es un pequeño bono agradable: en PostgreSQL, UNIQUE generalmente se implementa mediante un índice único, por lo que las consultas por sku, email, orderNumber y code obtienen automáticamente soporte de rendimiento. Aquí es importante no confundir la relación causa-efecto: el objetivo de UNIQUE es la corrección, la aceleración de la lectura es un beneficio secundario, no la razón de existencia de la restricción.
3. Foreign key: relaciones e integridad referencial
Si UNIQUE protege de «no más de uno», FOREIGN KEY protege de «no hay referencias a la nada». El ORM sabe muy bien dibujar relaciones con anotaciones, pero la base de datos no lee tus @ManyToOne. Sin FK se pueden crear filas «huérfanas» accidentalmente, y luego sorprenderse de que los joins se comporten como una película de terror.
Desde el punto de vista de la base de datos, una clave foránea es una regla: «el valor en la columna X o es NULL, o debe referirse a una fila existente en la tabla Y». Y esta regla es un elemento clave de lo que llamamos «consistencia de datos». Hibernate puede cargar el grafo, puede no cargarlo, puede hacer lazy proxy — pero si en la tabla hay target_category_id=123 y la categoría con id=123 no existe, no tienes un «problema de fetching», tienes datos corruptos.
FK en el ejemplo de PromotionCampaign
En nuestro caso de herencia restringido de PromotionCampaign, la campaña tiene una categoría objetivo (en el proyecto se llama targetCategory). Es lógico almacenarlo como target_category_id y fijar la relación con una restricción FK:
-- Prohibimos la referencia a una «categoría inexistente»
alter table promotion_campaign
add constraint fk_campaign_target_category
foreign key (target_category_id) references category(id);
Si por semántica del dominio la campaña siempre debe estar vinculada a una categoría, entonces agregas también NOT NULL en la columna. Si se permite «campaña para todo el catálogo», la columna puede ser nullable, pero FK sigue siendo útil: protege contra referencias a una categoría inexistente.
FK y la realidad del código con Hibernate
En el código Java, esto se expresa de manera habitual con @ManyToOne y @JoinColumn. Es importante entender que nullable=false en @JoinColumn es parte de la documentación del mapeo y una sugerencia para la generación de DDL, pero en un proyecto con Flyway no te creará automáticamente el NOT NULL.
Fragmento breve para que se vea la intención:
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.FetchType;
@ManyToOne(fetch = FetchType.LAZY) // LAZY: la categoría se carga solo cuando realmente se necesita
@JoinColumn(name = "target_category_id") // Mapeamos el campo a la columna FK en la tabla
private Category targetCategory;
Si necesitas exactamente NOT NULL, lo fijas en la migración, y la anotación se queda como una «señal de tráfico» para que el desarrollador no intente guardar una campaña sin categoría.
Índices en columnas FK
Aquí a menudo comienza la confusión, por eso lo digo directamente: una clave foránea no es igual a un índice. FK protege la corrección, el índice acelera el acceso. En PostgreSQL, la clave foránea no crea un índice automáticamente, y eso está bien: no siempre es necesario.
Pero hay una regla práctica que suele salvar de sorpresas desagradables: si con frecuencia haces joins o filtrados por la clave foránea, entonces un índice en la columna FK casi siempre está justificado. Para promotion_campaign.target_category_id tiene sentido si a menudo buscas campañas por categoría o haces joins de campañas con categorías.
4. Índices: elegir según consultas, no por ansiedad
La palabra «índice» en el desarrollo provoca dos extremos. El primero: «los índices son magia, que lo resuelva el DBA». El segundo: «vamos a indexar todo, incluido el estado de ánimo del desarrollador». Necesitamos un tercer camino: un índice es una estructura de datos para una consulta concreta, y por ella pagas en cada inserción y actualización.
Dicho de forma muy simple, un índice es un «directorio rápido» que ayuda a la base de datos a no escanear toda la tabla. Pero cualquier directorio tiene un coste: hay que almacenarlo en disco, mantenerlo en memoria y actualizarlo con cada escritura. Es decir, un índice no es una «aceleración gratuita», sino una elección consciente que casi siempre acelera la lectura y casi siempre encarece la escritura.
Para no convertir la indexación en una religión, es útil empezar no con «qué columnas indexar», sino con «qué consultas tenemos realmente». En nuestro proyecto hay varios casos de lectura típicos que explican bien la indexación básica:
| Caso de lectura (en lenguaje humano) | Ejemplo de condición | Qué ayuda |
|---|---|---|
| encontrar producto por sku | |
|
| encontrar cliente por email | |
|
| encontrar pedido por número | |
|
| encontrar campaña por código | |
|
| encontrar campañas activas en una fecha | |
|
| encontrar campañas para una categoría | |
|
Índice para «campañas activas en una fecha»
Este es nuestro ejemplo clave para PromotionCampaign. Imagina que el servicio necesita obtener la lista de campañas vigentes en un día concreto. A nivel de JPQL se ve muy normal:
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
public interface PromotionCampaignQueryRepository extends Repository<PromotionCampaign, Long> {
// Buscamos campañas que estén activas y dentro del rango de fechas
@Query("""
select c
from PromotionCampaign c
where c.active = true
and c.validFrom <= :day
and c.validTo >= :day
""")
List<PromotionCampaign> findActiveOnDay(@Param("day") LocalDate day);
}
En SQL, esto se reduce casi siempre a filtrar por active, valid_from, valid_to. Para tal consulta, es razonable agregar un índice que comience por la «igualdad» (active = true) y luego contenga las fechas:
-- Índice compuesto para el filtro típico "active + rango de fechas"
create index idx_campaign_active_dates
on promotion_campaign (active, valid_from, valid_to);
Aquí hay una pequeña idea de ingeniería: un índice es un «soporte» para un tipo concreto de filtro. Si nunca lees campañas por actividad y fechas, el índice solo ocupará espacio y ralentizará las actualizaciones. Si lees a menudo, será una optimización muy práctica, además sin necesidad de «ajustar Hibernate»: el índice lo usa PostgreSQL, Hibernate solo genera las consultas.
Índice en target_category_id
Si tienes consultas del tipo «dame las campañas para la categoría X», entonces un índice separado en la columna FK también tiene sentido:
-- El índice acelera el filtrado y el join por target_category_id
create index idx_campaign_target_category
on promotion_campaign (target_category_id);
Y de nuevo, un punto importante: no hay que indexar una columna solo porque «ahí hay una FK». El índice es necesario si realmente haces filtrados/joins por esa columna con frecuencia, o si tienes operaciones donde la falta de índice genera carga. En el proyecto de aprendizaje procuramos que cada índice se pueda explicar al menos con una consulta real.
Riesgos de indexarlo todo
Porque la escritura también es parte de la vida de la aplicación. Una entidad ancha + muchos índices = cada INSERT y UPDATE comienza a actualizar no solo la tabla, sino todo un «paquete de directorios». Para conjuntos de datos pequeños no lo notarás. Para grandes, lo notarás de repente, y de forma muy emocional.
Los índices son un buen ejemplo de compromiso ingenieril: cuando agregas un índice, le estás diciendo a la base de datos: «prometo que estas lecturas son importantes para mí». Y la base responde: «vale, entonces yo prometo que las escrituras serán un poco más costosas».
5. PromotionCampaign: restricciones e índices en Flyway
Vamos a reunir todo en nuestra jerarquía de aprendizaje PromotionCampaign. La mantenemos en la variante SINGLE_TABLE con una sola tabla promotion_campaign, así que ahora añadiremos a esta tabla exactamente las reglas que reflejan los invariantes de dominio y las lecturas reales.
Qué queremos garantizar a nivel de esquema
Para PromotionCampaign hay varias cosas que deben ser «de hierro». El code de la campaña debe ser único, de lo contrario findByCode() se convierte en una lotería. La referencia a la categoría objetivo debe ser correcta, de lo contrario tendremos campañas «para una categoría fantasma». Y, por último, tenemos un caso de lectura típico «campañas activas en una fecha» que pide un índice.
Esto se puede expresar en una mini-tabla para que quede claro «por qué hacemos esto»:
| Regla | Tipo de mecanismo | Cómo lo expresamos en PostgreSQL |
|---|---|---|
| code es único | corrección | |
| target_category_id referencia a category.id | corrección | |
| lectura frecuente por active + dates | rendimiento | |
| lectura frecuente por categoría | rendimiento | |
Anotaciones en la entidad: útiles, pero no como fuente de verdad
En el código Java puedes indicar la unicidad y los índices mediante @Table(uniqueConstraints = ..., indexes = ...). Es agradable de leer y ayuda a no olvidar el significado del esquema, pero una vez más: si tienes Flyway y ddl-auto desactivado, estas anotaciones no crearán restricciones en la base de datos «de forma mágica».
Mini-fragmento que muestra la intención:
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Index;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(
name = "promotion_campaign",
// Documentamos el invariante de negocio: code debe ser único
uniqueConstraints = @UniqueConstraint(name = "uk_campaign_code", columnNames = "code"),
// Documentamos el caso de lectura importante: filtro por active + rango de fechas
indexes = @Index(name = "idx_campaign_active_dates", columnList = "active, valid_from, valid_to")
)
public abstract class PromotionCampaign { }
El sentido de este fragmento es documental: ayuda al futuro lector a ver que code no es solo una cadena, sino una clave de negocio, y que active/valid_from/valid_to no son solo campos, sino un filtro importante. Pero la garantía real sigue viviendo en las migraciones.
Migración Flyway: donde las reglas se convierten en leyes
En las migraciones fijamos todo lo mismo, pero ya en el «lenguaje de la base de datos». Un conjunto aproximado de sentencias DDL (en el archivo real las colocarás junto a la creación de la tabla):
-- Unicidad del código de negocio de la campaña
alter table promotion_campaign
add constraint uk_campaign_code unique (code);
-- Integridad referencial: target_category_id debe apuntar a una category existente
alter table promotion_campaign
add constraint fk_campaign_target_category
foreign key (target_category_id) references category(id);
E índices para nuestras lecturas:
-- Índice para la consulta «campañas activas en una fecha»
create index idx_campaign_active_dates
on promotion_campaign (active, valid_from, valid_to);
-- Índice para la consulta «campañas para una categoría»
create index idx_campaign_target_category
on promotion_campaign (target_category_id);
Observa que no agregamos índices «por si acaso» en cada columna, ni intentamos convertir promotion_campaign en un escaparate de arte DBA. Nuestro objetivo es más simple y pragmático: el esquema debe soportar las reglas de dominio y las consultas principales que realmente escribimos en el código.
6. Errores típicos al trabajar con invariantes
Los errores con el esquema rara vez tienen el aspecto de «tienes una anotación mal puesta». Normalmente llegan en forma de duplicados extraños de datos, caídas repentinas en producción o listas lentas que «ayer funcionaban bien». A continuación, varios rastrillos típicos en torno a UNIQUE, FOREIGN KEY e índices que vale la pena conocer.
Error n.º 1: la unicidad está implementada solo mediante una comprobación en el servicio.
Una comprobación del tipo «si existsBySku() devolvió false, entonces se puede insertar» parece lógica hasta que ejecutas la aplicación en dos instancias o simplemente recibes dos peticiones concurrentes. Ambas transacciones ven «libre» e insertan un duplicado. UNIQUE en la base de datos convierte esta situación de «datos silenciosamente corruptos» en «una operación falla honestamente», y eso es una forma mucho más saludable de error.
Error n.º 2: creer que las anotaciones crearán restricciones en la base de datos por sí solas.
@Column(unique = true), @JoinColumn(nullable = false), @Table(indexes = ...) son excelentes sugerencias… si generas el esquema desde Hibernate. En un proyecto donde el esquema es gestionado por Flyway, no son más que documentación. Si no hay migraciones, en la base de datos no hay ni UNIQUE, ni NOT NULL, ni índices. Al final, el código «mira» una entidad bonita, pero los datos viven bajo otras reglas.
Error n.º 3: falta de nombres claros para restricciones e índices.
Cuando una restricción se llama algo como product_sku_key o, peor aún, una cadena autogenerada aleatoria, analizar los problemas se convierte en un acertijo. En cambio, uk_product_sku o fk_campaign_target_category se leen como una frase en lenguaje normal. Esto es especialmente importante cuando ves una excepción en los logs: ya tienes suficientes acertijos en el ORM, no necesitas añadir otro.
Error n.º 4: índices agregados «porque los índices son útiles».
Un índice sin relación con una consulta es como un botiquín que contiene todos los medicamentos del mundo «por si acaso». Sí, alguna vez algo será útil… pero llevarlo es pesado y caro. En la base de datos esto se manifiesta como espacio extra en disco y INSERT/UPDATE más lentos. Un buen índice casi siempre se puede explicar con una forma concreta de consulta, preferiblemente una que realmente veas en el código del repositorio.
Error n.º 5: existe una clave foránea, pero no hay índice en la columna FK, aunque las lecturas por ella son frecuentes.
FK hace que los datos sean correctos, pero no hace que el join sea rápido. Si lees constantemente campañas por target_category_id y no hay índice, la base de datos se verá obligada a escanear la tabla con más frecuencia. No es una «catástrofe» con datos pequeños, pero es una fuente clásica de ralentización repentina cuando el conjunto de datos de aprendizaje se convierte en uno de producción.
GO TO FULL VERSION