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

  1. Por qué ddl-auto no vale en producción
  2. El esquema como código versionado
  3. Flyway frente a Liquibase
  4. Integración con Spring Boot
  5. Convención de nombres de los scripts
  6. La tabla flyway_schema_history
  7. V1: el esquema inicial de CicloUrbana
  8. V2: los datos de Ribalta
  9. Migraciones y despliegue continuo
  10. Callbacks y migraciones Java
  11. Migraciones por entorno
  12. Validar la coherencia con las entidades
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. Por qué ddl-auto no vale en producción

Repasemos 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

  1. 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.sql

Flyway 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.

  1. 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.

  1. 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:

Unsupported Database: PostgreSQL 16.2

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.

  1. 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.

  1. La tabla flyway_schema_history

Flyway 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 |            12

El 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    : 987654321

Es 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.

  1. V1: el esquema inicial de CicloUrbana

Este 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.

  1. V2: los datos de Ribalta

Esta 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.

  1. 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.

  1. 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.

  1. 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.sql
# application-dev.yml
spring:
  flyway:
    locations: classpath:db/migration,classpath:db/datos-dev
# application-prod.yml
spring:
  flyway:
    locations: classpath:db/migration

Las 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.

  1. 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.

Schema-validation: missing column [nivel_bateria] in table [bicicletas]

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 fallidas

Requieren 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.

  1. Al arrancar en preproducción: Migration checksum mismatch for migration version 3.
  2. Un desarrollador ejecuta V4 en local y funciona; en integración continua falla con column "activa" of relation "estaciones" already exists.
  3. 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.

-- V10__telefono_usuarios_nulable.sql
ALTER TABLE usuarios ALTER COLUMN telefono DROP NOT NULL;

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.

-- V11__eliminar_telefono_usuarios.sql
ALTER TABLE usuarios DROP COLUMN telefono;

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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados