Queda un cabo suelto que arrastramos desde 04-02. spring.jpa.hibernate.ddl-auto: update sigue creando y modificando tablas por su cuenta cada vez que arranca la aplicación. Ha sido cómodo mientras diseñábamos entidades y relaciones, pero nadie sabe exactamente qué SQL se ha ejecutado sobre la base de datos, no hay registro de los cambios, no hay forma de reproducir el esquema en otro entorno y no hay manera de deshacer nada. Eso no puede llegar a producción.
Esta lección cierra el módulo sustituyendo ese automatismo por migraciones versionadas: el esquema deja de ser un efecto secundario de las clases Java y pasa a ser código fuente, escrito, revisado y versionado en Git como cualquier otro. Escribiremos el esquema inicial completo de CicloUrbana en SQL de PostgreSQL, la migración que jubila al CargadorEstacionesDemo del módulo 1, y aprenderemos a evolucionar un esquema en producción sin parar el servicio.
Contenido
- Por qué
ddl-autono vale en producción - El esquema como código versionado
- Flyway frente a Liquibase
- Integración con Spring Boot
- Convención de nombres de los scripts
- La tabla
flyway_schema_history V1: el esquema inicial de CicloUrbanaV2: los datos de Ribalta- Migraciones y despliegue continuo
- Callbacks y migraciones Java
- Migraciones por entorno
- Validar la coherencia con las entidades
- Errores Comunes y Consejos
- Ejercicios
- Por qué
ddl-auto no vale en producción
ddl-auto no vale en producciónRepasemos los cinco valores de 04-02 con el criterio de un entorno real:
| Valor | Riesgo en producción |
|---|---|
create / create-drop |
Borra toda la base de datos. Catastrófico |
update |
Cambios no revisados, incompletos y sin registro |
validate |
Ninguno: solo comprueba. El correcto |
none |
Ninguno: no hace nada |
El problema con update es más profundo que «podría borrar datos» —de hecho no borra columnas—. Son cinco limitaciones estructurales: no puede modificar lo existente (cambiar varchar(80) a varchar(40), añadir un NOT NULL a una tabla con filas o alterar el tipo de una columna); no borra nada, así que renombrar capacidad a plazas_totales produce las dos columnas, con datos en la vieja y nulos en la nueva; no deja registro de qué se aplicó, cuándo ni quién lo revisó; no es reproducible, porque el esquema depende del orden histórico de arranques y no de un estado declarado; y no sabe migrar datos, como rellenar una columna nueva a partir de otra.
Y hay un problema humano peor que los cinco técnicos: el cambio de esquema deja de ser una decisión y pasa a ser un efecto secundario. Alguien añade un campo a una entidad para una funcionalidad y, al desplegar, la base de datos de producción cambia sin que nadie haya revisado ese ALTER TABLE.
La comparación resume el módulo:
ddl-auto: update |
Flyway | |
|---|---|---|
| Quién decide el SQL | Hibernate | Tú |
| Revisable en Git | No | Sí |
| Registro de lo aplicado | No | Tabla de historial |
| Reproducible | No | Sí, determinista |
| Migración de datos | No | Sí |
| Renombrar columnas | No | Sí |
| Reversible | No | Con scripts de deshacer |
- El esquema como código versionado
La idea central es simple: cada cambio del esquema es un fichero SQL numerado que se aplica una sola vez y en orden.
src/main/resources/db/migration/
├── V1__crear_esquema_inicial.sql ├── V3__anadir_tabla_incidencias.sql
├── V2__cargar_estaciones_ribalta.sql └── V4__indice_alquileres_por_fecha.sqlFlyway mantiene en la propia base de datos una tabla con las migraciones ya aplicadas. Al arrancar compara, ejecuta solo las nuevas en orden de versión y registra el resultado.
graph TD
A["La aplicación arranca"] --> B["Flyway lee flyway_schema_history"]
B --> C["Escanea db/migration"]
C --> D{"¿Hay versiones sin aplicar?"}
D -->|No| E["Valida checksums y continúa"]
D -->|Sí| F["Ejecuta en orden: V1, V2, V3..."]
F --> G["Registra cada una con su checksum"]
G --> E
E --> H["Hibernate valida entidades contra el esquema"]
H --> I["Aplicación lista"]
Las ventajas que esto desbloquea: cualquier entorno se reconstruye desde cero ejecutando las migraciones en orden; el cambio de esquema pasa por revisión de código, como cualquier otro; el historial de Git explica la evolución del modelo de datos; y desarrollo y producción convergen, porque ejecutan el mismo SQL.
- Flyway frente a Liquibase
Son las dos herramientas de referencia en el ecosistema Java, y Spring Boot autoconfigura ambas.
| Aspecto | Flyway | Liquibase |
|---|---|---|
| Formato | SQL nativo (o Java) | XML, YAML, JSON o SQL |
| Curva de aprendizaje | Baja: es SQL | Media: hay que aprender su lenguaje |
| Abstracción del motor | Ninguna: escribes SQL del motor | Alta: genera SQL por motor |
| Deshacer | De pago en la edición Pro | Gratuito |
| Refactorizaciones predefinidas | No | Sí (renameColumn, etc.) |
| Filosofía | Explícita y minimalista | Declarativa y completa |
| Adecuada cuando | Un solo motor y equipo cómodo con SQL | Varios motores o necesidad de deshacer |
CicloUrbana usa Flyway por tres razones concretas: la base de datos es PostgreSQL y no va a cambiar, así que la abstracción de Liquibase no aporta nada; escribir SQL directo hace que el fichero sea exactamente lo que se ejecuta, sin traducciones intermedias; y cualquiera que sepa SQL puede revisar una migración sin aprender un formato nuevo. Si tu contexto es distinto —un producto que se instala sobre Oracle, SQL Server o PostgreSQL según el cliente—, Liquibase es probablemente mejor.
- Integración con Spring Boot
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>La segunda dependencia es obligatoria desde Flyway 10, que separó el soporte de cada motor en módulos propios. Olvidarla produce un error muy característico:
Con las dependencias en el classpath, la autoconfiguración hace el resto: crea un bean Flyway, lo apunta al DataSource y ejecuta las migraciones antes de que Hibernate inicialice el EntityManagerFactory. Ese orden es esencial: cuando Hibernate valide las entidades, las tablas ya existen.
La configuración de CicloUrbana:
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false
validate-on-migrate: true
clean-disabled: true
table: flyway_schema_history
jpa:
hibernate:
ddl-auto: validate # ¡ya no update!| Propiedad | Qué hace | Valor recomendado |
|---|---|---|
enabled |
Activa Flyway | true |
locations |
Dónde buscar migraciones | classpath:db/migration |
baseline-on-migrate |
Marca una BD existente como línea base | false, salvo adopción en un proyecto existente |
baseline-version |
Versión de esa línea base | 1 |
validate-on-migrate |
Verifica los checksums al arrancar | true |
clean-disabled |
Impide flyway clean |
true. Nunca lo pongas a false |
out-of-order |
Permite aplicar versiones anteriores tardías | false |
table |
Nombre de la tabla de historial | flyway_schema_history |
clean-disabled: true es innegociable. El comando clean borra todos los objetos del esquema: tablas, datos e índices. Ejecutarlo por error contra producción es una de esas historias que se cuentan durante años; desde Flyway 9 viene desactivado por defecto, y conviene dejarlo así explícitamente.
El cambio a ddl-auto: validate es el otro momento clave: a partir de aquí Hibernate no toca el esquema, solo comprueba que coincide con las entidades y falla al arrancar si no. Es la red de seguridad que impide que migraciones y clases Java se desincronicen.
- Convención de nombres de los scripts
V2__cargar_estaciones_ribalta.sql
│ │ ││
│ │ │└── Descripción (los _ se convierten en espacios)
│ │ └─── Separador: DOS guiones bajos, obligatorio
│ └───── Versión
└─────── Prefijo| Prefijo | Tipo | Cuándo se ejecuta |
|---|---|---|
V |
Versionada | Una sola vez, en orden de versión |
R |
Repetible | Cada vez que cambia su checksum, después de las versionadas |
U |
De deshacer | Solo con flyway undo (edición de pago) |
Migraciones versionadas (V). Son el 95 % del trabajo: crear tablas, añadir columnas, insertar datos de referencia. Las versiones pueden ser 1, 2, 2.1 o 20260901.1; en un equipo grande, un esquema de fecha y hora evita que dos ramas reclamen la misma versión.
Migraciones repetibles (R). No llevan versión (R__vista_ocupacion_estaciones.sql) y se reejecutan cuando su contenido cambia, lo que las hace ideales para objetos que se redefinen enteros: vistas, funciones y procedimientos. En lugar de V5__crear_vista, V9__modificar_vista y V14__modificar_vista_otra_vez, hay un único fichero cuyo historial en Git es el historial de la vista.
-- R__vista_ocupacion_estaciones.sql
CREATE OR REPLACE VIEW vista_ocupacion_estaciones AS
SELECT e.id, e.nombre, e.capacidad,
COUNT(b.id) FILTER (WHERE b.estado = 'DISPONIBLE') AS bicicletas_disponibles
FROM estaciones e
LEFT JOIN bicicletas b ON b.estacion_id = e.id
WHERE e.activa = true
GROUP BY e.id, e.nombre, e.capacidad;Nombra bien la descripción: aparece en la tabla de historial y en los mensajes de error. V7__anadir_columna_nivel_bateria_bicicletas.sql es informativo; V7__cambios.sql no dice nada.
- La tabla
flyway_schema_history
flyway_schema_historyFlyway crea esta tabla en su primera ejecución: es el registro de todo lo aplicado.
| Columna | Contenido |
|---|---|
installed_rank |
Orden de aplicación |
version |
Versión (NULL en las repetibles) |
description |
Descripción legible |
type |
SQL, JDBC, BASELINE |
script |
Nombre del fichero |
checksum |
Huella del contenido del script |
installed_by |
Usuario de base de datos |
installed_on |
Momento de aplicación |
execution_time |
Milisegundos que tardó |
success |
Si terminó correctamente |
SELECT version, description, success, installed_on, execution_time
FROM flyway_schema_history ORDER BY installed_rank; version | description | success | installed_on | execution_time
---------+---------------------------+---------+---------------------+----------------
1 | crear esquema inicial | t | 2026-09-01 09:14:22 | 184
2 | cargar estaciones ribalta | t | 2026-09-01 09:14:22 | 12El checksum es el mecanismo central. Al arrancar, Flyway recalcula la huella de cada script y la compara con la registrada; si un script ya aplicado ha cambiado, el arranque falla:
FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : 1554682513
-> Resolved locally : 987654321Es un fallo deliberado y valioso: significa que alguien editó una migración ya aplicada, rompiendo la premisa fundamental de que las migraciones son inmutables. El fichero dice una cosa y las bases de datos donde se aplicó dicen otra: nadie sabe ya cuál es el esquema real.
Qué hacer si te pasa. Si el script solo se aplicó en tu entorno local, borra la base de datos y vuelve a migrar. Si llegó a un entorno compartido, la respuesta es una sola: crea una migración nueva con el cambio adicional y revierte el fichero anterior a su contenido original. flyway repair reescribe los checksums, pero es un último recurso: enmascara el problema en lugar de resolverlo.
V1: el esquema inicial de CicloUrbana
V1: el esquema inicial de CicloUrbanaEste es el esquema completo, coherente con las entidades de 04-03 y las relaciones de 04-04.
-- V1__crear_esquema_inicial.sql
-- Esquema inicial de CicloUrbana: red de bicicletas eléctricas de Ribalta.
-- ============================================================
-- Secuencias (allocationSize = 50 en las entidades, INCREMENT BY 50 aquí)
-- ============================================================
CREATE SEQUENCE estaciones_id_seq START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE bicicletas_id_seq START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE usuarios_id_seq START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE alquileres_id_seq START WITH 1 INCREMENT BY 50;
-- ============================================================
-- estaciones
-- ============================================================
CREATE TABLE estaciones (
id BIGINT NOT NULL,
nombre VARCHAR(80) NOT NULL,
direccion VARCHAR(200) NOT NULL,
capacidad INTEGER NOT NULL,
latitud NUMERIC(9,6) NOT NULL,
longitud NUMERIC(9,6) NOT NULL,
activa BOOLEAN NOT NULL DEFAULT TRUE,
version BIGINT NOT NULL DEFAULT 0,
creado_en TIMESTAMPTZ NOT NULL,
modificado_en TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_estaciones PRIMARY KEY (id),
CONSTRAINT uk_estaciones_nombre UNIQUE (nombre),
CONSTRAINT ck_estaciones_capacidad CHECK (capacidad > 0 AND capacidad <= 200),
CONSTRAINT ck_estaciones_latitud CHECK (latitud BETWEEN -90 AND 90),
CONSTRAINT ck_estaciones_longitud CHECK (longitud BETWEEN -180 AND 180)
);
CREATE INDEX idx_estaciones_activa ON estaciones (activa);
CREATE INDEX idx_estaciones_ubicacion ON estaciones (latitud, longitud);
-- ============================================================
-- bicicletas
-- ============================================================
CREATE TABLE bicicletas (
id BIGINT NOT NULL,
matricula VARCHAR(10) NOT NULL,
estado VARCHAR(20) NOT NULL,
nivel_bateria INTEGER NOT NULL,
estacion_id BIGINT,
version BIGINT NOT NULL DEFAULT 0,
creado_en TIMESTAMPTZ NOT NULL,
modificado_en TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_bicicletas PRIMARY KEY (id),
CONSTRAINT uk_bicicletas_matricula UNIQUE (matricula),
CONSTRAINT fk_bicicletas_estacion FOREIGN KEY (estacion_id)
REFERENCES estaciones (id) ON DELETE SET NULL,
CONSTRAINT ck_bicicletas_estado CHECK (estado IN
('DISPONIBLE', 'EN_USO', 'MANTENIMIENTO', 'RETIRADA')),
CONSTRAINT ck_bicicletas_bateria CHECK (nivel_bateria BETWEEN 0 AND 100),
CONSTRAINT ck_bicicletas_matricula CHECK (matricula ~ '^RB-[0-9]{4}$')
);
CREATE INDEX idx_bicicletas_estado ON bicicletas (estado);
CREATE INDEX idx_bicicletas_estacion ON bicicletas (estacion_id);
-- ============================================================
-- usuarios
-- ============================================================
CREATE TABLE usuarios (
id BIGINT NOT NULL,
correo VARCHAR(120) NOT NULL,
nombre VARCHAR(120) NOT NULL,
tipo_tarifa VARCHAR(20) NOT NULL DEFAULT 'ESTANDAR',
fecha_alta DATE NOT NULL,
activo BOOLEAN NOT NULL DEFAULT TRUE,
version BIGINT NOT NULL DEFAULT 0,
creado_en TIMESTAMPTZ NOT NULL,
modificado_en TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_usuarios PRIMARY KEY (id),
CONSTRAINT uk_usuarios_correo UNIQUE (correo),
CONSTRAINT ck_usuarios_tarifa CHECK (tipo_tarifa IN
('ESTANDAR', 'ESTUDIANTE', 'JUBILADO'))
);
-- ============================================================
-- alquileres
-- ============================================================
CREATE TABLE alquileres (
id BIGINT NOT NULL,
usuario_id BIGINT NOT NULL,
bicicleta_id BIGINT NOT NULL,
estacion_origen_id BIGINT NOT NULL,
estacion_destino_id BIGINT,
inicio TIMESTAMPTZ NOT NULL,
fin TIMESTAMPTZ,
importe NUMERIC(8,2),
estado VARCHAR(20) NOT NULL DEFAULT 'EN_CURSO',
version BIGINT NOT NULL DEFAULT 0,
creado_en TIMESTAMPTZ NOT NULL,
modificado_en TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_alquileres PRIMARY KEY (id),
CONSTRAINT fk_alquileres_usuario FOREIGN KEY (usuario_id)
REFERENCES usuarios (id),
CONSTRAINT fk_alquileres_bicicleta FOREIGN KEY (bicicleta_id)
REFERENCES bicicletas (id),
CONSTRAINT fk_alquileres_estacion_origen FOREIGN KEY (estacion_origen_id)
REFERENCES estaciones (id),
CONSTRAINT fk_alquileres_estacion_destino FOREIGN KEY (estacion_destino_id)
REFERENCES estaciones (id),
CONSTRAINT ck_alquileres_estado CHECK (estado IN
('EN_CURSO', 'FINALIZADO', 'CADUCADO', 'CANCELADO')),
CONSTRAINT ck_alquileres_fin CHECK (fin IS NULL OR fin >= inicio),
CONSTRAINT ck_alquileres_importe CHECK (importe IS NULL OR importe >= 0)
);
CREATE INDEX idx_alquileres_usuario ON alquileres (usuario_id);
CREATE INDEX idx_alquileres_bicicleta ON alquileres (bicicleta_id);
CREATE INDEX idx_alquileres_inicio ON alquileres (inicio DESC);
-- Un usuario no puede tener dos alquileres en curso a la vez
CREATE UNIQUE INDEX uk_alquileres_usuario_en_curso
ON alquileres (usuario_id) WHERE fin IS NULL;Cinco decisiones del script merecen comentario:
INCREMENT BY 50 en las secuencias, que debe coincidir exactamente con el allocationSize = 50 de las entidades (04-03); si no coinciden, Hibernate genera ids que colisionan.
Restricciones CHECK sobre los enumerados. ck_bicicletas_estado garantiza en la base de datos lo que @Enumerated(EnumType.STRING) garantiza en Java, y defiende ante escrituras que no pasen por la aplicación. Su contrapartida: añadir un valor al enumerado exige una migración que actualice la restricción.
ON DELETE SET NULL en bicicletas.estacion_id, que refleja la decisión de dominio de 04-04: borrar una estación no borra sus bicicletas, las deja sin estación asignada.
Índices sobre las claves ajenas, porque PostgreSQL no los crea automáticamente a diferencia de MySQL: sin idx_alquileres_usuario, la consulta «mis alquileres» recorrería secuencialmente toda la tabla.
El índice único parcial uk_alquileres_usuario_en_curso. Es la joya del script. WHERE fin IS NULL hace que la unicidad se aplique solo a los alquileres en curso: un usuario puede tener cientos de alquileres finalizados, pero como máximo uno abierto. Es la regla de negocio central de CicloUrbana garantizada por la base de datos, inmune a condiciones de carrera y a cualquier fallo de la lógica de aplicación. Ninguna comprobación en Java ofrece esa garantía.
V2: los datos de Ribalta
V2: los datos de RibaltaEsta migración jubila al CargadorEstacionesDemo del módulo 1, que llevaba desde 01-05 recreando las cuatro estaciones en cada arranque.
-- V2__cargar_estaciones_ribalta.sql
-- Estaciones iniciales de la red municipal de Ribalta.
INSERT INTO estaciones (id, nombre, direccion, capacidad, latitud, longitud,
activa, version, creado_en, modificado_en)
VALUES
(1, 'Plaza Mayor', 'Plaza Mayor, 1', 24, 40.416775, -3.703790,
TRUE, 0, NOW(), NOW()),
(2, 'Estación Norte', 'Avenida del Norte, 45', 30, 40.428900, -3.698120,
TRUE, 0, NOW(), NOW()),
(3, 'Parque del Río', 'Paseo Fluvial, s/n', 18, 40.409330, -3.712450,
TRUE, 0, NOW(), NOW()),
(4, 'Universidad', 'Campus Universitario, s/n', 36, 40.435210, -3.689870,
TRUE, 0, NOW(), NOW());
-- La secuencia debe quedar por encima de los ids insertados a mano
SELECT setval('estaciones_id_seq', 100, false);
-- Tarifas del ayuntamiento
INSERT INTO tarifas (codigo, descripcion, precio_minuto, importe_minimo)
VALUES ('ESTANDAR', 'Tarifa general', 0.15, 0.50),
('ESTUDIANTE', 'Tarifa estudiante (-40%)', 0.09, 0.30),
('JUBILADO', 'Tarifa jubilado (-50%)', 0.075, 0.25);Dos puntos críticos:
setval sobre la secuencia. Al insertar ids explícitos, la secuencia no avanza. Si no la ajustas, el primer alta desde la API pedirá el id 1 y chocará con «Plaza Mayor». setval(..., 100, false) la deja empezando en 100, con margen de sobra.
Idempotencia. Una migración V se ejecuta una sola vez, así que no necesita ser idempotente; pero escribirla con ON CONFLICT (id) DO NOTHING permite reutilizar el mismo SQL en otros contextos sin riesgo.
Qué datos van en una migración y cuáles no. Los datos de referencia —tarifas, tipos de incidencia, la red inicial de estaciones— son parte del esquema funcional y van en migraciones. Los datos de prueba —usuarios ficticios, alquileres de ejemplo— no deben aparecer en producción: van en migraciones separadas por entorno (apartado 11).
Con esto, CargadorEstacionesDemo se elimina del proyecto: sus datos ya no dependen del arranque de la aplicación, viven en la base de datos y sobreviven a los reinicios. Las cuatro estaciones de Ribalta han sobrevivido a un reinicio.
- Migraciones y despliegue continuo
Aquí está la parte que separa un proyecto pequeño de uno en producción real. Durante un despliegue sin parada, conviven dos versiones de la aplicación contra una sola base de datos:
graph TD
A["v1.4 en 3 réplicas"] --> B["Migración V8"]
B --> C["v1.4 (2 réplicas) + v1.5 (1 réplica)"]
C --> D["v1.5 en 3 réplicas"]
style C fill:#ffe6cc
Durante la fase intermedia, la versión antigua sigue ejecutando consultas contra el esquema ya migrado. De ahí la regla de oro: toda migración debe ser compatible hacia atrás con la versión anterior de la aplicación.
| Cambio | ¿Compatible? | Por qué |
|---|---|---|
| Añadir una tabla | Sí | La versión antigua la ignora |
| Añadir una columna nulable | Sí | Los INSERT antiguos la dejan nula |
| Añadir un índice | Sí | Transparente (con CONCURRENTLY) |
Añadir una columna NOT NULL con DEFAULT |
Sí | Los INSERT antiguos toman el valor por defecto |
Añadir una columna NOT NULL sin DEFAULT |
No | Los INSERT de la versión antigua fallan |
| Eliminar una columna | No | La versión antigua sigue leyéndola |
| Renombrar una columna | No | Equivale a eliminar y añadir |
Reducir la longitud de un VARCHAR |
No | Los datos existentes pueden no caber |
Añadir una restricción NOT NULL |
No | Rompe los INSERT que la omitían |
El patrón expand/contract es la técnica para hacer un cambio incompatible en pasos compatibles. Renombrar capacidad a plazas_totales sin parar CicloUrbana:
Fase 1 — Expand (V8): añadir la nueva columna y copiar los datos.
-- V8__expand_plazas_totales.sql
ALTER TABLE estaciones ADD COLUMN plazas_totales INTEGER;
UPDATE estaciones SET plazas_totales = capacidad;
-- Un disparador mantiene ambas columnas sincronizadas durante la transición
CREATE OR REPLACE FUNCTION sincronizar_plazas() RETURNS TRIGGER AS $$
BEGIN
IF NEW.plazas_totales IS DISTINCT FROM OLD.plazas_totales THEN
NEW.capacidad := NEW.plazas_totales;
ELSE
NEW.plazas_totales := NEW.capacidad;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sincronizar_plazas
BEFORE INSERT OR UPDATE ON estaciones
FOR EACH ROW EXECUTE FUNCTION sincronizar_plazas();Fase 2 — Desplegar la versión que usa plazas_totales. Ambas columnas existen y el disparador las mantiene coherentes, así que la versión antigua y la nueva conviven sin problema.
Fase 3 — Contract (V9), tras confirmar que la versión antigua ya no está desplegada:
-- V9__contract_eliminar_capacidad.sql
DROP TRIGGER trg_sincronizar_plazas ON estaciones;
DROP FUNCTION sincronizar_plazas();
ALTER TABLE estaciones DROP COLUMN capacidad;
ALTER TABLE estaciones ALTER COLUMN plazas_totales SET NOT NULL;Son tres despliegues para un renombrado. Parece excesivo, y es exactamente lo que separa un sistema desplegable a cualquier hora de uno que necesita una ventana de mantenimiento.
La regla inviolable: nunca se edita una migración ya aplicada. Si V5 tiene un error, se corrige con V6; editarla rompe el checksum en todos los entornos y deja el esquema real en estado desconocido.
Dos consejos operativos de PostgreSQL: usa CREATE INDEX CONCURRENTLY en tablas grandes, porque un CREATE INDEX normal bloquea las escrituras durante toda la construcción (necesita su propia migración, al no poder ir en una transacción); y recuerda que ADD COLUMN ... DEFAULT es instantáneo desde PostgreSQL 11, pero ALTER COLUMN ... TYPE reescribe la tabla entera.
- Callbacks y migraciones Java
Callbacks. Flyway ejecuta ficheros SQL en momentos concretos del ciclo si siguen la convención de nombres: beforeMigrate.sql (antes de todas), afterMigrate.sql (después de todas), beforeEachMigrate.sql (antes de cada una) y afterMigrateError.sql (si alguna falla). Un afterMigrate.sql con ANALYZE estaciones; ANALYZE bicicletas; ANALYZE alquileres; recalcula las estadísticas del planificador de PostgreSQL, especialmente útil tras una migración que haya movido muchos datos.
Migraciones en Java. Cuando la transformación no cabe en SQL —hay que descifrar valores, llamar a un servicio o procesar por lotes con lógica compleja—, se escribe una clase que extienda BaseJavaMigration:
package db.migration; // paquete obligatorio
public class V7__normalizar_matriculas extends BaseJavaMigration {
@Override
public void migrate(Context contexto) throws Exception {
try (Statement lectura = contexto.getConnection().createStatement();
ResultSet filas = lectura.executeQuery(
"SELECT id, matricula FROM bicicletas WHERE matricula !~ '^RB-[0-9]{4}$'")) {
try (PreparedStatement escritura = contexto.getConnection()
.prepareStatement("UPDATE bicicletas SET matricula = ? WHERE id = ?")) {
while (filas.next()) {
escritura.setString(1, normalizar(filas.getString("matricula")));
escritura.setLong(2, filas.getLong("id"));
escritura.addBatch();
}
escritura.executeBatch();
}
}
}
private String normalizar(String original) { // "rb142" -> "RB-0142"
return "RB-" + String.format("%04d",
Integer.parseInt(original.replaceAll("\\D", "")));
}
}Cuatro requisitos: la clase debe estar en el paquete db.migration; su nombre sigue la misma convención (V7__descripcion); usa JDBC directo, nunca los repositorios de la aplicación, que aún no están inicializados; y no debe gestionar la transacción, de la que ya se encarga Flyway. Úsalas con moderación: el SQL es más transparente y revisable, y una migración Java solo se justifica cuando la lógica es inexpresable en SQL.
- Migraciones por entorno
Los datos de prueba no pueden llegar a producción. spring.flyway.locations acepta varias rutas:
src/main/resources/db/
├── migration/ # esquema y datos de referencia: TODOS los entornos
│ ├── V1__crear_esquema_inicial.sql
│ └── V2__cargar_estaciones_ribalta.sql
└── datos-dev/ # datos de prueba: SOLO desarrollo
└── V900__usuarios_y_alquileres_prueba.sqlLas versiones altas (V900) mantienen los datos de prueba siempre al final y evitan colisiones con las migraciones reales del esquema.
Advertencia importante: cada entorno tiene su propia tabla de historial, así que no hay conflicto entre ellos. Lo que sí ocurre es que si un desarrollador ejecuta el perfil prod sobre su base de datos local, Flyway detectará que V900 está aplicada pero ya no aparece en locations y avisará con Detected applied migration not resolved locally: es un aviso, no un error. Los perfiles se estudian a fondo en 07-02.
- Validar la coherencia con las entidades
Con Flyway gestionando el esquema, ddl-auto: validate cobra todo su sentido: Hibernate compara cada entidad con las tablas reales y falla al arrancar si algo no cuadra.
Ese mensaje significa que alguien añadió un campo a la entidad y olvidó la migración. Es exactamente el fallo que quieres, y llega antes de que ninguna petición lo sufra.
validate comprueba la existencia de tablas y columnas, sus tipos y nulabilidad, y las secuencias. No comprueba índices, restricciones CHECK ni claves ajenas, así que no sustituye a la revisión del SQL.
El flujo de trabajo completo al añadir un campo a CicloUrbana: añadir el campo a la entidad con sus anotaciones; escribir la migración V<n>__...sql correspondiente; arrancar, con Flyway aplicando y Hibernate validando; y si validate falla, corregir la discrepancia con otra migración nueva, nunca editando la anterior.
Los comandos de Flyway desde Maven, útiles fuera del arranque de la aplicación:
./mvnw flyway:info # estado de cada migración: aplicada, pendiente, fallida
./mvnw flyway:validate # comprueba los checksums sin aplicar nada
./mvnw flyway:migrate # aplica las pendientes
./mvnw flyway:baseline # marca una BD existente como línea base
./mvnw flyway:repair # repara checksums y limpia entradas fallidasRequieren configurar el plugin en el pom.xml con la URL, el usuario y la contraseña tomados de variables de entorno, como en 04-02. flyway:info es especialmente útil en integración continua, para comprobar el estado de un entorno antes de desplegar (módulo 8).
Errores Comunes y Consejos
Editar una migración ya aplicada. Rompe el checksum y el arranque falla en todos los entornos donde se aplicó. Corrige siempre con una migración nueva.
Olvidar flyway-database-postgresql. Desde Flyway 10 es obligatoria: Unsupported Database: PostgreSQL 16.
Dejar ddl-auto: update con Flyway activo. Los dos compiten por el esquema y el resultado es impredecible. Con Flyway, siempre validate o none.
Olvidar setval tras insertar ids explícitos. El primer alta desde la API colisiona con los datos precargados.
Poner datos de prueba en db/migration. Acabarán en producción. Sepáralos por locations.
Añadir una columna NOT NULL sin DEFAULT en despliegue continuo. Los INSERT de la versión antigua fallan durante la transición.
Habilitar flyway clean. Borra el esquema completo. clean-disabled: true, siempre.
No indexar las claves ajenas. PostgreSQL no lo hace solo, y las consultas por relación acaban en recorridos secuenciales.
Consejo: una migración, un cambio lógico. Es más fácil de revisar y, si falla, más fácil de diagnosticar. Y prueba cada migración contra una copia de producción antes de desplegar: un ALTER TABLE que tarda 2 segundos con 4 estaciones puede tardar 20 minutos con 2 millones de alquileres.
Consejo: usa restricciones e índices únicos parciales para las reglas de negocio. El uk_alquileres_usuario_en_curso garantiza en la base de datos algo que ninguna comprobación en Java puede asegurar frente a condiciones de carrera.
Ejercicios
Ejercicio 1: la migración de las incidencias
Escribe V3__crear_tabla_incidencias.sql para el modelo de herencia SINGLE_TABLE de 04-04: una tabla incidencias con id por secuencia, clave ajena a bicicletas con borrado en cascada, columna discriminadora tipo, descripción obligatoria, fecha de reporte, estado, columnas de auditoría, y los campos específicos nivel_detectado (batería) y denuncia_policial (vandalismo). Incluye restricciones CHECK e índices, y justifica por qué las columnas específicas admiten nulos.
Ejercicio 2: eliminar una columna sin parar el servicio
CicloUrbana tiene en usuarios una columna telefono VARCHAR(20) NOT NULL que ya no se usa. Hay 3 réplicas en producción con despliegue progresivo. Describe la secuencia completa de migraciones y despliegues para eliminarla sin cortar el servicio, y explica qué pasaría si se hiciera ALTER TABLE usuarios DROP COLUMN telefono directamente.
Ejercicio 3: diagnosticar tres incidentes
Diagnostica y resuelve cada situación.
- Al arrancar en preproducción:
Migration checksum mismatch for migration version 3. - Un desarrollador ejecuta
V4en local y funciona; en integración continua falla concolumn "activa" of relation "estaciones" already exists. - Tras desplegar, la aplicación arranca pero falla al crear estaciones:
duplicate key value violates unique constraint "pk_estaciones".
Soluciones
Solución 1.
-- V3__crear_tabla_incidencias.sql
CREATE SEQUENCE incidencias_id_seq START WITH 1 INCREMENT BY 50;
CREATE TABLE incidencias (
id BIGINT NOT NULL,
tipo VARCHAR(20) NOT NULL,
bicicleta_id BIGINT NOT NULL,
descripcion VARCHAR(500) NOT NULL,
estado VARCHAR(20) NOT NULL DEFAULT 'ABIERTA',
reportada_en TIMESTAMPTZ NOT NULL,
resuelta_en TIMESTAMPTZ,
-- Campos específicos de las subclases: NULABLES por necesidad
nivel_detectado INTEGER,
denuncia_policial VARCHAR(40),
version BIGINT NOT NULL DEFAULT 0,
creado_en TIMESTAMPTZ NOT NULL,
modificado_en TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_incidencias PRIMARY KEY (id),
CONSTRAINT fk_incidencias_bicicleta FOREIGN KEY (bicicleta_id)
REFERENCES bicicletas (id) ON DELETE CASCADE,
CONSTRAINT ck_incidencias_tipo CHECK (tipo IN ('BATERIA', 'VANDALISMO', 'AVERIA')),
CONSTRAINT ck_incidencias_estado CHECK (estado IN ('ABIERTA', 'EN_CURSO', 'RESUELTA')),
CONSTRAINT ck_incidencias_nivel CHECK (nivel_detectado IS NULL
OR nivel_detectado BETWEEN 0 AND 100),
CONSTRAINT ck_incidencias_resuelta CHECK (resuelta_en IS NULL
OR resuelta_en >= reportada_en),
-- Coherencia entre el discriminador y sus campos propios
CONSTRAINT ck_incidencias_bateria CHECK (
tipo <> 'BATERIA' OR nivel_detectado IS NOT NULL)
);
CREATE INDEX idx_incidencias_bicicleta ON incidencias (bicicleta_id);
CREATE INDEX idx_incidencias_estado ON incidencias (estado)
WHERE estado <> 'RESUELTA';Por qué las columnas específicas admiten nulos. Es la contrapartida inevitable de SINGLE_TABLE: todas las subclases comparten tabla, y una incidencia de vandalismo no tiene nivel_detectado. Declararlas NOT NULL haría imposible insertar cualquier tipo que no tuviera todos los campos.
La restricción ck_incidencias_bateria recupera parte de esa integridad perdida: si el tipo es BATERIA, el nivel es obligatorio. Es la técnica que compensa la principal debilidad de SINGLE_TABLE, y una razón más para haber elegido JOINED si los campos propios fueran muchos (ejercicio 3 de 04-04).
El índice parcial sobre estado es una optimización deliberada: solo indexa las incidencias no resueltas, que son las que se consultan a diario, manteniendo el índice pequeño aunque el histórico crezca sin límite.
Solución 2. La secuencia correcta tiene tres pasos:
Paso 1 — V10: relajar la restricción.
Es un cambio compatible hacia atrás: la versión antigua sigue enviando el teléfono y funciona; la nueva podrá omitirlo.
Paso 2 — Desplegar la versión que ya no usa telefono, eliminando el campo de la entidad Usuario, del DTO y del mapeador. Durante el despliegue progresivo conviven réplicas antiguas —que escriben el teléfono— y nuevas —que no—, y ambas funcionan porque la columna existe y admite nulos.
Paso 3 — V11: eliminar la columna, en un despliegue posterior, tras confirmar que ninguna réplica antigua sigue viva.
Qué pasaría con el DROP COLUMN directo. Durante la fase intermedia, las réplicas antiguas siguen ejecutando INSERT INTO usuarios (..., telefono, ...) y SELECT ... telefono ..., y cada una de esas consultas fallaría con column "telefono" does not exist. Peor aún, con ddl-auto: validate cualquier réplica antigua que se reiniciara no llegaría a arrancar: una caída parcial de CicloUrbana durante el despliegue, exactamente lo que el despliegue progresivo pretendía evitar. Y una consideración adicional: el paso 3 es irreversible, así que conviene archivar los datos antes de borrarlos, porque ninguna migración de deshacer recupera información que ya no existe.
Solución 3.
1. Migration checksum mismatch en la versión 3. Alguien editó V3__...sql después de que se hubiera aplicado en preproducción. El checksum del fichero ya no coincide con el registrado. Diagnóstico: SELECT version, checksum, installed_on FROM flyway_schema_history WHERE version = '3'; y comparar con el historial de Git del fichero. Solución: revertir V3 a su contenido original —el que se aplicó— y crear V6 con el cambio que se pretendía introducir. Solo si el cambio era puramente cosmético (un comentario, un espacio) y se ha verificado que el SQL efectivo es idéntico, flyway:repair recalcula los checksums. No es la opción por defecto: enmascara el problema.
2. column "activa" already exists solo en integración continua. La base de datos de CI no está limpia: conserva el esquema de una ejecución anterior en la que la columna ya se creó, probablemente por un ddl-auto: update que quedó activo o por una migración previa que ya la añadía; en local funcionaba porque la base se creaba de cero. Solución: hacer que CI parta de una base de datos efímera —Testcontainers (06-05) es exactamente eso—, comprobar que ninguna migración anterior crea ya esa columna y confirmar que ddl-auto está en validate en todos los entornos.
3. duplicate key value violates unique constraint "pk_estaciones". Falta el setval de V2. Se insertaron las cuatro estaciones con ids explícitos 1-4, pero la secuencia estaciones_id_seq sigue en su valor inicial, así que la primera estación creada desde la API pide el id 1 y choca con «Plaza Mayor». Solución: una migración nueva que ajuste la secuencia por encima del máximo real.
-- V12__ajustar_secuencia_estaciones.sql
SELECT setval('estaciones_id_seq',
GREATEST((SELECT COALESCE(MAX(id), 0) FROM estaciones) + 50, 100),
false);GREATEST con el máximo real la hace segura sea cual sea el estado del entorno, y el margen de 50 respeta el allocationSize. La lección general: siempre que una migración inserte ids explícitos en una tabla con secuencia, debe ajustar la secuencia en el mismo script.
Conclusión
El módulo 4 se cierra con CicloUrbana funcionando sobre persistencia real y con el esquema bajo control. Sabes por qué ddl-auto: update no puede llegar a producción —no modifica lo existente, no borra, no deja registro, no es reproducible y no migra datos— y, sobre todo, por qué su peor defecto es humano: convierte el cambio de esquema en un efecto secundario en lugar de una decisión revisada. Has comparado Flyway con Liquibase y entiendes por qué este proyecto elige SQL nativo sobre un único motor. Has integrado Flyway con sus dos dependencias, configurado spring.flyway.* con clean-disabled: true como línea roja, y hecho el cambio decisivo del módulo: ddl-auto pasa de update a validate, de modo que Hibernate ya no toca el esquema y se limita a comprobar que las entidades y las tablas coinciden, fallando al arrancar cuando no. Dominas la convención V/R/U, sabes para qué sirve una migración repetible y entiendes la tabla flyway_schema_history y su checksum, la huella que hace del arranque un guardián contra la edición de migraciones ya aplicadas.
Has escrito el esquema inicial completo de Ribalta en SQL de PostgreSQL: cuatro secuencias con INCREMENT BY 50 que casan con el allocationSize de 04-03, cuatro tablas con sus claves primarias, claves ajenas nombradas, restricciones CHECK que replican en la base de datos lo que los enumerados garantizan en Java, índices explícitos sobre las claves ajenas —porque PostgreSQL no los crea solo— y columnas de auditoría y version. Y con ellas el índice único parcial uk_alquileres_usuario_en_curso, que convierte la regla de negocio central de CicloUrbana en una garantía del motor, inmune a condiciones de carrera. La migración V2 cargó las cuatro estaciones y las tres tarifas, ajustó la secuencia con setval y jubiló definitivamente al CargadorEstacionesDemo del módulo 1. Conoces el patrón expand/contract para renombrar una columna en tres despliegues sin cortar el servicio, la tabla de cambios compatibles e incompatibles, los callbacks, las migraciones Java con BaseJavaMigration y la separación de datos de prueba por locations.
Mira dónde está CicloUrbana. Empezó siendo un main que imprimía un mensaje. Tiene una API REST de trece endpoints diseñada sobre las restricciones de REST, con verbos y códigos de estado correctos, validación declarativa, DTOs que separan dominio y contrato, errores uniformes en RFC 7807 y un contrato OpenAPI publicable. Y ahora, además, un modelo de datos persistente sobre PostgreSQL 16, con entidades bien mapeadas, relaciones perezosas, repositorios de Spring Data, consultas que no sufren el problema N+1, transacciones en la capa de servicio y un esquema versionado en Git que cualquiera puede reconstruir desde cero. Las cuatro estaciones de Ribalta sobreviven a un reinicio, y también los alquileres, las bicicletas y los usuarios.
Y precisamente por eso hay ahora un problema que antes no importaba: la API está completamente abierta. Cualquiera que conozca la URL puede crear estaciones, dar de baja bicicletas, consultar los datos personales de los ciudadanos de Ribalta o finalizar el alquiler de otra persona. Mientras todo vivía en memoria y se perdía al reiniciar, era una demo; ahora hay datos reales y persistentes de una red municipal, y no hay ni una sola comprobación de quién está al otro lado. El módulo 5, Seguridad en Spring Boot, lo resuelve: veremos qué es Spring Security y cómo su cadena de filtros se inserta delante del DispatcherServlet que conocimos en 03-01; la configuraremos con SecurityFilterChain sustituyendo la contraseña generada por defecto; distinguiremos autenticación de autorización y modelaremos los roles de CicloUrbana —ciudadano, operario, administrador— sobre la entidad Usuario que acabamos de crear; implementaremos autenticación sin estado con JWT, adecuada para una API consumida por una aplicación móvil; y bajaremos la seguridad al nivel de método con @PreAuthorize para que un ciudadano solo pueda finalizar sus alquileres. La red de Ribalta está a punto de tener puertas.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
