Queda un cap solt que arrosseguem des de 04-02. spring.jpa.hibernate.ddl-auto: update continua creant i modificant taules pel seu compte cada vegada que arrenca l'aplicació. Ha estat còmode mentre dissenyàvem entitats i relacions, però ningú no sap exactament quin SQL s'ha executat sobre la base de dades, no hi ha registre dels canvis, no hi ha manera de reproduir l'esquema en un altre entorn i no hi ha manera de desfer res. Això no pot arribar a producció.

Aquesta lliçó tanca el mòdul substituint aquest automatisme per migracions versionades: l'esquema deixa de ser un efecte secundari de les classes Java i passa a ser codi font, escrit, revisat i versionat a Git com qualsevol altre. Escriurem l'esquema inicial complet de CicloUrbana en SQL de PostgreSQL, la migració que jubila el CarregadorEstacionsDemo del mòdul 1, i aprendrem a fer evolucionar un esquema en producció sense aturar el servei.

Contingut

  1. Per què ddl-auto no val en producció
  2. L'esquema com a codi versionat
  3. Flyway enfront de Liquibase
  4. Integració amb Spring Boot
  5. Convenció de noms dels scripts
  6. La taula flyway_schema_history
  7. V1: l'esquema inicial de CicloUrbana
  8. V2: les dades de Ribalta
  9. Migracions i desplegament continu
  10. Callbacks i migracions Java
  11. Migracions per entorn
  12. Validar la coherència amb les entitats
  13. Errors Comuns i Consells
  14. Exercicis

  1. Per què ddl-auto no val en producció

Repassem els cinc valors de 04-02 amb el criteri d'un entorn real:

Valor Risc en producció
create / create-drop Esborra tota la base de dades. Catastròfic
update Canvis no revisats, incomplets i sense registre
validate Cap: només comprova. El correcte
none Cap: no fa res

El problema amb update és més profund que «podria esborrar dades» —de fet no esborra columnes—. Són cinc limitacions estructurals: no pot modificar el que ja existeix (canviar varchar(80) a varchar(40), afegir un NOT NULL a una taula amb files o alterar el tipus d'una columna); no esborra res, així que reanomenar capacitat a places_totals produeix les dues columnes, amb dades a la vella i nuls a la nova; no deixa registre de què es va aplicar, quan ni qui ho va revisar; no és reproduïble, perquè l'esquema depèn de l'ordre històric d'arrencades i no d'un estat declarat; i no sap migrar dades, com omplir una columna nova a partir d'una altra.

I hi ha un problema humà pitjor que els cinc tècnics: el canvi d'esquema deixa de ser una decisió i passa a ser un efecte secundari. Algú afegeix un camp a una entitat per a una funcionalitat i, en desplegar, la base de dades de producció canvia sense que ningú hagi revisat aquell ALTER TABLE.

La comparació resumeix el mòdul:

ddl-auto: update Flyway
Qui decideix el SQL Hibernate Tu
Revisable a Git No Sí
Registre del que s'ha aplicat No Taula d'historial
Reproduïble No Sí, determinista
Migració de dades No Sí
Reanomenar columnes No Sí
Reversible No Amb scripts de desfer

  1. L'esquema com a codi versionat

La idea central és simple: cada canvi de l'esquema és un fitxer SQL numerat que s'aplica una sola vegada i en ordre.

src/main/resources/db/migration/
├── V1__crear_esquema_inicial.sql        ├── V3__afegir_taula_incidencies.sql
├── V2__carregar_estacions_ribalta.sql   └── V4__index_lloguers_per_data.sql

Flyway manté a la mateixa base de dades una taula amb les migracions ja aplicades. En arrencar compara, executa només les noves en ordre de versió i registra el resultat.

graph TD
    A["L'aplicació arrenca"] --> B["Flyway llegeix flyway_schema_history"]
    B --> C["Escaneja db/migration"]
    C --> D{"Hi ha versions sense aplicar?"}
    D -->|No| E["Valida checksums i continua"]
    D -->|Sí| F["Executa en ordre: V1, V2, V3..."]
    F --> G["Registra cadascuna amb el seu checksum"]
    G --> E
    E --> H["Hibernate valida entitats contra l'esquema"]
    H --> I["Aplicació llesta"]

Els avantatges que això desbloqueja: qualsevol entorn es reconstrueix des de zero executant les migracions en ordre; el canvi d'esquema passa per revisió de codi, com qualsevol altre; l'historial de Git explica l'evolució del model de dades; i desenvolupament i producció convergeixen, perquè executen el mateix SQL.

  1. Flyway enfront de Liquibase

Són les dues eines de referència a l'ecosistema Java, i Spring Boot autoconfigura totes dues.

Aspecte Flyway Liquibase
Format SQL natiu (o Java) XML, YAML, JSON o SQL
Corba d'aprenentatge Baixa: és SQL Mitjana: cal aprendre el seu llenguatge
Abstracció del motor Cap: escrius SQL del motor Alta: genera SQL per motor
Desfer De pagament a l'edició Pro Gratuït
Refactoritzacions predefinides No Sí (renameColumn, etc.)
Filosofia Explícita i minimalista Declarativa i completa
Adequada quan Un sol motor i equip còmode amb SQL Diversos motors o necessitat de desfer

CicloUrbana fa servir Flyway per tres raons concretes: la base de dades és PostgreSQL i no canviarà, així que l'abstracció de Liquibase no hi aporta res; escriure SQL directe fa que el fitxer sigui exactament el que s'executa, sense traduccions intermèdies; i qualsevol que sàpiga SQL pot revisar una migració sense aprendre un format nou. Si el teu context és diferent —un producte que s'instal·la sobre Oracle, SQL Server o PostgreSQL segons el client—, Liquibase és probablement millor.

  1. Integració amb Spring Boot

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

La segona dependència és obligatòria des de Flyway 10, que va separar el suport de cada motor en mòduls propis. Oblidar-la produeix un error molt característic:

Unsupported Database: PostgreSQL 16.2

Amb les dependències al classpath, l'autoconfiguració fa la resta: crea un bean Flyway, l'apunta al DataSource i executa les migracions abans que Hibernate inicialitzi l'EntityManagerFactory. Aquest ordre és essencial: quan Hibernate validi les entitats, les taules ja existeixen.

La configuració 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      # ja no update!
Propietat Què fa Valor recomanat
enabled Activa Flyway true
locations On buscar migracions classpath:db/migration
baseline-on-migrate Marca una BD existent com a línia base false, llevat d'adopció en un projecte existent
baseline-version Versió d'aquesta línia base 1
validate-on-migrate Verifica els checksums en arrencar true
clean-disabled Impedeix flyway clean true. No el posis mai a false
out-of-order Permet aplicar versions anteriors tardanes false
table Nom de la taula d'historial flyway_schema_history

clean-disabled: true és innegociable. L'ordre clean esborra tots els objectes de l'esquema: taules, dades i índexs. Executar-lo per error contra producció és una d'aquelles històries que s'expliquen durant anys; des de Flyway 9 ve desactivat per defecte, i convé deixar-ho així explícitament.

El canvi a ddl-auto: validate és l'altre moment clau: a partir d'aquí Hibernate no toca l'esquema, només comprova que coincideix amb les entitats i falla en arrencar si no. És la xarxa de seguretat que impedeix que migracions i classes Java es desincronitzin.

  1. Convenció de noms dels scripts

V2__carregar_estacions_ribalta.sql
│ │ ││
│ │ │└── Descripció (els _ es converteixen en espais)
│ │ └─── Separador: DOS guions baixos, obligatori
│ └───── Versió
└─────── Prefix
Prefix Tipus Quan s'executa
V Versionada Una sola vegada, en ordre de versió
R Repetible Cada cop que canvia el seu checksum, després de les versionades
U De desfer Només amb flyway undo (edició de pagament)

Migracions versionades (V). Són el 95 % de la feina: crear taules, afegir columnes, inserir dades de referència. Les versions poden ser 1, 2, 2.1 o 20260901.1; en un equip gran, un esquema de data i hora evita que dues branques reclamin la mateixa versió.

Migracions repetibles (R). No porten versió (R__vista_ocupacio_estacions.sql) i es reexecuten quan el seu contingut canvia, cosa que les fa ideals per a objectes que es redefineixen sencers: vistes, funcions i procediments. En lloc de V5__crear_vista, V9__modificar_vista i V14__modificar_vista_un_altre_cop, hi ha un únic fitxer l'historial del qual a Git és l'historial de la vista.

-- R__vista_ocupacio_estacions.sql
CREATE OR REPLACE VIEW vista_ocupacio_estacions AS
SELECT e.id, e.nom, e.capacitat,
       COUNT(b.id) FILTER (WHERE b.estat = 'DISPONIBLE') AS bicicletes_disponibles
  FROM estacions e
  LEFT JOIN bicicletes b ON b.estacio_id = e.id
 WHERE e.activa = true
 GROUP BY e.id, e.nom, e.capacitat;

Anomena bé la descripció: apareix a la taula d'historial i als missatges d'error. V7__afegir_columna_nivell_bateria_bicicletes.sql és informatiu; V7__canvis.sql no diu res.

  1. La taula flyway_schema_history

Flyway crea aquesta taula a la seva primera execució: és el registre de tot el que s'ha aplicat.

Columna Contingut
installed_rank Ordre d'aplicació
version Versió (NULL a les repetibles)
description Descripció llegible
type SQL, JDBC, BASELINE
script Nom del fitxer
checksum Empremta del contingut de l'script
installed_by Usuari de base de dades
installed_on Moment d'aplicació
execution_time Mil·lisegons que va trigar
success Si va acabar correctament
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       | carregar estacions ribalta| t       | 2026-09-01 09:14:22 |            12

El checksum és el mecanisme central. En arrencar, Flyway recalcula l'empremta de cada script i la compara amb la registrada; si un script ja aplicat ha canviat, l'arrencada falla:

FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : 1554682513
-> Resolved locally    : 987654321

És una fallada deliberada i valuosa: significa que algú va editar una migració ja aplicada, trencant la premissa fonamental que les migracions són immutables. El fitxer diu una cosa i les bases de dades on es va aplicar en diuen una altra: ningú no sap ja quin és l'esquema real.

Què fer si et passa. Si l'script només es va aplicar al teu entorn local, esborra la base de dades i torna a migrar. Si va arribar a un entorn compartit, la resposta és una de sola: crea una migració nova amb el canvi addicional i reverteix el fitxer anterior al seu contingut original. flyway repair reescriu els checksums, però és un últim recurs: emmascara el problema en lloc de resoldre'l.

  1. V1: l'esquema inicial de CicloUrbana

Aquest és l'esquema complet, coherent amb les entitats de 04-03 i les relacions de 04-04.

-- V1__crear_esquema_inicial.sql
-- Esquema inicial de CicloUrbana: xarxa de bicicletes elèctriques de Ribalta.

-- ============================================================
-- Seqüències (allocationSize = 50 a les entitats, INCREMENT BY 50 aquí)
-- ============================================================
CREATE SEQUENCE estacions_id_seq  START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE bicicletes_id_seq START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE usuaris_id_seq    START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE lloguers_id_seq   START WITH 1 INCREMENT BY 50;

-- ============================================================
-- estacions
-- ============================================================
CREATE TABLE estacions (
    id             BIGINT       NOT NULL,
    nom            VARCHAR(80)  NOT NULL,
    adreca         VARCHAR(200) NOT NULL,
    capacitat      INTEGER      NOT NULL,
    latitud        NUMERIC(9,6) NOT NULL,
    longitud       NUMERIC(9,6) NOT NULL,
    activa         BOOLEAN      NOT NULL DEFAULT TRUE,
    versio         BIGINT       NOT NULL DEFAULT 0,
    creat_el       TIMESTAMPTZ  NOT NULL,
    modificat_el   TIMESTAMPTZ  NOT NULL,

    CONSTRAINT pk_estacions            PRIMARY KEY (id),
    CONSTRAINT uk_estacions_nom        UNIQUE (nom),
    CONSTRAINT ck_estacions_capacitat  CHECK (capacitat > 0 AND capacitat <= 200),
    CONSTRAINT ck_estacions_latitud    CHECK (latitud BETWEEN -90 AND 90),
    CONSTRAINT ck_estacions_longitud   CHECK (longitud BETWEEN -180 AND 180)
);

CREATE INDEX idx_estacions_activa   ON estacions (activa);
CREATE INDEX idx_estacions_ubicacio ON estacions (latitud, longitud);

-- ============================================================
-- bicicletes
-- ============================================================
CREATE TABLE bicicletes (
    id             BIGINT      NOT NULL,
    matricula      VARCHAR(10) NOT NULL,
    estat          VARCHAR(20) NOT NULL,
    nivell_bateria INTEGER     NOT NULL,
    estacio_id     BIGINT,
    versio         BIGINT      NOT NULL DEFAULT 0,
    creat_el       TIMESTAMPTZ NOT NULL,
    modificat_el   TIMESTAMPTZ NOT NULL,

    CONSTRAINT pk_bicicletes           PRIMARY KEY (id),
    CONSTRAINT uk_bicicletes_matricula UNIQUE (matricula),
    CONSTRAINT fk_bicicletes_estacio   FOREIGN KEY (estacio_id)
        REFERENCES estacions (id) ON DELETE SET NULL,
    CONSTRAINT ck_bicicletes_estat     CHECK (estat IN
        ('DISPONIBLE', 'EN_US', 'MANTENIMENT', 'RETIRADA')),
    CONSTRAINT ck_bicicletes_bateria   CHECK (nivell_bateria BETWEEN 0 AND 100),
    CONSTRAINT ck_bicicletes_matricula CHECK (matricula ~ '^RB-[0-9]{4}$')
);

CREATE INDEX idx_bicicletes_estat   ON bicicletes (estat);
CREATE INDEX idx_bicicletes_estacio ON bicicletes (estacio_id);

-- ============================================================
-- usuaris
-- ============================================================
CREATE TABLE usuaris (
    id             BIGINT       NOT NULL,
    correu         VARCHAR(120) NOT NULL,
    nom            VARCHAR(120) NOT NULL,
    tipus_tarifa   VARCHAR(20)  NOT NULL DEFAULT 'ESTANDARD',
    data_alta      DATE         NOT NULL,
    actiu          BOOLEAN      NOT NULL DEFAULT TRUE,
    versio         BIGINT       NOT NULL DEFAULT 0,
    creat_el       TIMESTAMPTZ  NOT NULL,
    modificat_el   TIMESTAMPTZ  NOT NULL,

    CONSTRAINT pk_usuaris        PRIMARY KEY (id),
    CONSTRAINT uk_usuaris_correu UNIQUE (correu),
    CONSTRAINT ck_usuaris_tarifa CHECK (tipus_tarifa IN
        ('ESTANDARD', 'ESTUDIANT', 'JUBILAT'))
);

-- ============================================================
-- lloguers
-- ============================================================
CREATE TABLE lloguers (
    id                BIGINT      NOT NULL,
    usuari_id         BIGINT      NOT NULL,
    bicicleta_id      BIGINT      NOT NULL,
    estacio_origen_id BIGINT      NOT NULL,
    estacio_desti_id  BIGINT,
    inici             TIMESTAMPTZ NOT NULL,
    fi                TIMESTAMPTZ,
    import_total      NUMERIC(8,2),
    estat             VARCHAR(20) NOT NULL DEFAULT 'EN_CURS',
    versio            BIGINT      NOT NULL DEFAULT 0,
    creat_el          TIMESTAMPTZ NOT NULL,
    modificat_el      TIMESTAMPTZ NOT NULL,

    CONSTRAINT pk_lloguers PRIMARY KEY (id),
    CONSTRAINT fk_lloguers_usuari FOREIGN KEY (usuari_id)
        REFERENCES usuaris (id),
    CONSTRAINT fk_lloguers_bicicleta FOREIGN KEY (bicicleta_id)
        REFERENCES bicicletes (id),
    CONSTRAINT fk_lloguers_estacio_origen FOREIGN KEY (estacio_origen_id)
        REFERENCES estacions (id),
    CONSTRAINT fk_lloguers_estacio_desti FOREIGN KEY (estacio_desti_id)
        REFERENCES estacions (id),
    CONSTRAINT ck_lloguers_estat CHECK (estat IN
        ('EN_CURS', 'FINALITZAT', 'CADUCAT', 'CANCELLAT')),
    CONSTRAINT ck_lloguers_fi     CHECK (fi IS NULL OR fi >= inici),
    CONSTRAINT ck_lloguers_import CHECK (import_total IS NULL OR import_total >= 0)
);

CREATE INDEX idx_lloguers_usuari    ON lloguers (usuari_id);
CREATE INDEX idx_lloguers_bicicleta ON lloguers (bicicleta_id);
CREATE INDEX idx_lloguers_inici     ON lloguers (inici DESC);

-- Un usuari no pot tenir dos lloguers en curs alhora
CREATE UNIQUE INDEX uk_lloguers_usuari_en_curs
    ON lloguers (usuari_id) WHERE fi IS NULL;

Cinc decisions de l'script mereixen comentari:

INCREMENT BY 50 a les seqüències, que ha de coincidir exactament amb l'allocationSize = 50 de les entitats (04-03); si no coincideixen, Hibernate genera ids que col·lideixen.

Restriccions CHECK sobre els enumerats. ck_bicicletes_estat garanteix a la base de dades el que @Enumerated(EnumType.STRING) garanteix en Java, i defensa davant d'escriptures que no passin per l'aplicació. La seva contrapartida: afegir un valor a l'enumerat exigeix una migració que actualitzi la restricció.

ON DELETE SET NULL a bicicletes.estacio_id, que reflecteix la decisió de domini de 04-04: esborrar una estació no esborra les seves bicicletes, les deixa sense estació assignada.

Índexs sobre les claus foranes, perquè PostgreSQL no els crea automàticament a diferència de MySQL: sense idx_lloguers_usuari, la consulta «els meus lloguers» recorreria seqüencialment tota la taula.

L'índex únic parcial uk_lloguers_usuari_en_curs. És la joia de l'script. WHERE fi IS NULL fa que la unicitat s'apliqui només als lloguers en curs: un usuari pot tenir centenars de lloguers finalitzats, però com a màxim un d'obert. És la regla de negoci central de CicloUrbana garantida per la base de dades, immune a condicions de cursa i a qualsevol fallada de la lògica d'aplicació. Cap comprovació en Java no ofereix aquesta garantia.

  1. V2: les dades de Ribalta

Aquesta migració jubila el CarregadorEstacionsDemo del mòdul 1, que portava des de 01-05 recreant les quatre estacions a cada arrencada.

-- V2__carregar_estacions_ribalta.sql
-- Estacions inicials de la xarxa municipal de Ribalta.

INSERT INTO estacions (id, nom, adreca, capacitat, latitud, longitud,
                       activa, versio, creat_el, modificat_el)
VALUES
    (1, 'Plaça Major',   'Plaça Major, 1',       24, 40.416775, -3.703790,
     TRUE, 0, NOW(), NOW()),
    (2, 'Estació Nord',  'Avinguda Estació 3',   30, 40.428900, -3.698120,
     TRUE, 0, NOW(), NOW()),
    (3, 'Parc del Riu',  'Passeig Fluvial 12',   18, 40.409330, -3.712450,
     TRUE, 0, NOW(), NOW()),
    (4, 'Universitat',   'Campus Sud, accés B',  36, 40.435210, -3.689870,
     TRUE, 0, NOW(), NOW());

-- La seqüència ha de quedar per damunt dels ids inserits a mà
SELECT setval('estacions_id_seq', 100, false);

-- Tarifes de l'ajuntament
INSERT INTO tarifes (codi, descripcio, preu_minut, import_minim)
VALUES ('ESTANDARD', 'Tarifa general',            0.15, 0.50),
       ('ESTUDIANT', 'Tarifa estudiant (-40%)',   0.09, 0.30),
       ('JUBILAT',   'Tarifa jubilat (-50%)',     0.075, 0.25);

Dos punts crítics:

setval sobre la seqüència. En inserir ids explícits, la seqüència no avança. Si no l'ajustes, la primera alta des de l'API demanarà l'id 1 i xocarà amb «Plaça Major». setval(..., 100, false) la deixa començant a 100, amb marge de sobres.

Idempotència. Una migració V s'executa una sola vegada, així que no cal que sigui idempotent; però escriure-la amb ON CONFLICT (id) DO NOTHING permet reutilitzar el mateix SQL en altres contextos sense risc.

Quines dades van en una migració i quines no. Les dades de referència —tarifes, tipus d'incidència, la xarxa inicial d'estacions— són part de l'esquema funcional i van en migracions. Les dades de prova —usuaris ficticis, lloguers d'exemple— no han d'aparèixer en producció: van en migracions separades per entorn (apartat 11).

Amb això, CarregadorEstacionsDemo s'elimina del projecte: les seves dades ja no depenen de l'arrencada de l'aplicació, viuen a la base de dades i sobreviuen als reinicis. Les quatre estacions de Ribalta han sobreviscut a un reinici.

  1. Migracions i desplegament continu

Aquí hi ha la part que separa un projecte petit d'un en producció real. Durant un desplegament sense aturada, conviuen dues versions de l'aplicació contra una sola base de dades:

graph TD
    A["v1.4 en 3 rèpliques"] --> B["Migració V8"]
    B --> C["v1.4 (2 rèpliques) + v1.5 (1 rèplica)"]
    C --> D["v1.5 en 3 rèpliques"]
    style C fill:#ffe6cc

Durant la fase intermèdia, la versió antiga continua executant consultes contra l'esquema ja migrat. D'aquí la regla d'or: tota migració ha de ser compatible cap enrere amb la versió anterior de l'aplicació.

Canvi És compatible? Per què
Afegir una taula Sí La versió antiga la ignora
Afegir una columna nul·lable Sí Els INSERT antics la deixen nul·la
Afegir un índex Sí Transparent (amb CONCURRENTLY)
Afegir una columna NOT NULL amb DEFAULT Sí Els INSERT antics prenen el valor per defecte
Afegir una columna NOT NULL sense DEFAULT No Els INSERT de la versió antiga fallen
Eliminar una columna No La versió antiga continua llegint-la
Reanomenar una columna No Equival a eliminar i afegir
Reduir la longitud d'un VARCHAR No Les dades existents poden no cabre-hi
Afegir una restricció NOT NULL No Trenca els INSERT que l'ometien

El patró expand/contract és la tècnica per fer un canvi incompatible en passos compatibles. Reanomenar capacitat a places_totals sense aturar CicloUrbana:

Fase 1 — Expand (V8): afegir la columna nova i copiar les dades.

-- V8__expand_places_totals.sql
ALTER TABLE estacions ADD COLUMN places_totals INTEGER;
UPDATE estacions SET places_totals = capacitat;

-- Un disparador manté totes dues columnes sincronitzades durant la transició
CREATE OR REPLACE FUNCTION sincronitzar_places() RETURNS TRIGGER AS $$
BEGIN
    IF NEW.places_totals IS DISTINCT FROM OLD.places_totals THEN
        NEW.capacitat := NEW.places_totals;
    ELSE
        NEW.places_totals := NEW.capacitat;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sincronitzar_places
    BEFORE INSERT OR UPDATE ON estacions
    FOR EACH ROW EXECUTE FUNCTION sincronitzar_places();

Fase 2 — Desplegar la versió que fa servir places_totals. Totes dues columnes existeixen i el disparador les manté coherents, així que la versió antiga i la nova conviuen sense problema.

Fase 3 — Contract (V9), després de confirmar que la versió antiga ja no està desplegada:

-- V9__contract_eliminar_capacitat.sql
DROP TRIGGER trg_sincronitzar_places ON estacions;
DROP FUNCTION sincronitzar_places();
ALTER TABLE estacions DROP COLUMN capacitat;
ALTER TABLE estacions ALTER COLUMN places_totals SET NOT NULL;

Són tres desplegaments per a un canvi de nom. Sembla excessiu, i és exactament el que separa un sistema desplegable a qualsevol hora d'un que necessita una finestra de manteniment.

La regla inviolable: mai no s'edita una migració ja aplicada. Si V5 té un error, es corregeix amb V6; editar-la trenca el checksum a tots els entorns i deixa l'esquema real en estat desconegut.

Dos consells operatius de PostgreSQL: fes servir CREATE INDEX CONCURRENTLY en taules grans, perquè un CREATE INDEX normal bloqueja les escriptures durant tota la construcció (necessita la seva pròpia migració, en no poder anar dins d'una transacció); i recorda que ADD COLUMN ... DEFAULT és instantani des de PostgreSQL 11, però ALTER COLUMN ... TYPE reescriu la taula sencera.

  1. Callbacks i migracions Java

Callbacks. Flyway executa fitxers SQL en moments concrets del cicle si segueixen la convenció de noms: beforeMigrate.sql (abans de totes), afterMigrate.sql (després de totes), beforeEachMigrate.sql (abans de cadascuna) i afterMigrateError.sql (si alguna falla). Un afterMigrate.sql amb ANALYZE estacions; ANALYZE bicicletes; ANALYZE lloguers; recalcula les estadístiques del planificador de PostgreSQL, especialment útil després d'una migració que hagi mogut moltes dades.

Migracions en Java. Quan la transformació no cap en SQL —cal desxifrar valors, cridar un servei o processar per lots amb lògica complexa—, s'escriu una classe que estengui BaseJavaMigration:

package db.migration;   // paquet obligatori

public class V7__normalitzar_matricules extends BaseJavaMigration {

    @Override
    public void migrate(Context context) throws Exception {
        try (Statement lectura = context.getConnection().createStatement();
             ResultSet files = lectura.executeQuery(
                     "SELECT id, matricula FROM bicicletes WHERE matricula !~ '^RB-[0-9]{4}$'")) {

            try (PreparedStatement escriptura = context.getConnection()
                    .prepareStatement("UPDATE bicicletes SET matricula = ? WHERE id = ?")) {
                while (files.next()) {
                    escriptura.setString(1, normalitzar(files.getString("matricula")));
                    escriptura.setLong(2, files.getLong("id"));
                    escriptura.addBatch();
                }
                escriptura.executeBatch();
            }
        }
    }

    private String normalitzar(String original) {   // "rb142" -> "RB-0142"
        return "RB-" + String.format("%04d",
                Integer.parseInt(original.replaceAll("\\D", "")));
    }
}

Quatre requisits: la classe ha d'estar al paquet db.migration; el seu nom segueix la mateixa convenció (V7__descripcio); fa servir JDBC directe, mai els repositoris de l'aplicació, que encara no estan inicialitzats; i no ha de gestionar la transacció, de la qual ja se n'encarrega Flyway. Fes-les servir amb moderació: el SQL és més transparent i revisable, i una migració Java només es justifica quan la lògica és inexpressable en SQL.

  1. Migracions per entorn

Les dades de prova no poden arribar a producció. spring.flyway.locations accepta diverses rutes:

src/main/resources/db/
├── migration/                    # esquema i dades de referència: TOTS els entorns
│   ├── V1__crear_esquema_inicial.sql
│   └── V2__carregar_estacions_ribalta.sql
└── dades-dev/                    # dades de prova: NOMÉS desenvolupament
    └── V900__usuaris_i_lloguers_prova.sql
# application-dev.yml
spring:
  flyway:
    locations: classpath:db/migration,classpath:db/dades-dev
# application-prod.yml
spring:
  flyway:
    locations: classpath:db/migration

Les versions altes (V900) mantenen les dades de prova sempre al final i eviten col·lisions amb les migracions reals de l'esquema.

Advertiment important: cada entorn té la seva pròpia taula d'historial, així que no hi ha conflicte entre ells. El que sí que passa és que si un desenvolupador executa el perfil prod sobre la seva base de dades local, Flyway detectarà que V900 està aplicada però ja no apareix a locations i avisarà amb Detected applied migration not resolved locally: és un avís, no un error. Els perfils s'estudien a fons a 07-02.

  1. Validar la coherència amb les entitats

Amb Flyway gestionant l'esquema, ddl-auto: validate cobra tot el seu sentit: Hibernate compara cada entitat amb les taules reals i falla en arrencar si alguna cosa no quadra.

Schema-validation: missing column [nivell_bateria] in table [bicicletes]

Aquest missatge significa que algú va afegir un camp a l'entitat i va oblidar la migració. És exactament la fallada que vols, i arriba abans que cap petició la pateixi.

validate comprova l'existència de taules i columnes, els seus tipus i nul·labilitat, i les seqüències. No comprova índexs, restriccions CHECK ni claus foranes, així que no substitueix la revisió del SQL.

El flux de treball complet en afegir un camp a CicloUrbana: afegir el camp a l'entitat amb les seves anotacions; escriure la migració V<n>__...sql corresponent; arrencar, amb Flyway aplicant i Hibernate validant; i si validate falla, corregir la discrepància amb una altra migració nova, mai editant l'anterior.

Les ordres de Flyway des de Maven, útils fora de l'arrencada de l'aplicació:

./mvnw flyway:info      # estat de cada migració: aplicada, pendent, fallida
./mvnw flyway:validate  # comprova els checksums sense aplicar res
./mvnw flyway:migrate   # aplica les pendents
./mvnw flyway:baseline  # marca una BD existent com a línia base
./mvnw flyway:repair    # repara checksums i neteja entrades fallides

Requereixen configurar el plugin al pom.xml amb la URL, l'usuari i la contrasenya presos de variables d'entorn, com a 04-02. flyway:info és especialment útil en integració contínua, per comprovar l'estat d'un entorn abans de desplegar (mòdul 8).

Errors Comuns i Consells

Editar una migració ja aplicada. Trenca el checksum i l'arrencada falla a tots els entorns on es va aplicar. Corregeix sempre amb una migració nova.

Oblidar flyway-database-postgresql. Des de Flyway 10 és obligatòria: Unsupported Database: PostgreSQL 16.

Deixar ddl-auto: update amb Flyway actiu. Tots dos competeixen per l'esquema i el resultat és impredictible. Amb Flyway, sempre validate o none.

Oblidar setval després d'inserir ids explícits. La primera alta des de l'API col·lideix amb les dades precarregades.

Posar dades de prova a db/migration. Acabaran en producció. Separa-les per locations.

Afegir una columna NOT NULL sense DEFAULT en desplegament continu. Els INSERT de la versió antiga fallen durant la transició.

Habilitar flyway clean. Esborra l'esquema complet. clean-disabled: true, sempre.

No indexar les claus foranes. PostgreSQL no ho fa sol, i les consultes per relació acaben en recorreguts seqüencials.

Consell: una migració, un canvi lògic. És més fàcil de revisar i, si falla, més fàcil de diagnosticar. I prova cada migració contra una còpia de producció abans de desplegar: un ALTER TABLE que triga 2 segons amb 4 estacions pot trigar 20 minuts amb 2 milions de lloguers.

Consell: fes servir restriccions i índexs únics parcials per a les regles de negoci. L'uk_lloguers_usuari_en_curs garanteix a la base de dades una cosa que cap comprovació en Java no pot assegurar davant de condicions de cursa.

Exercicis

Exercici 1: la migració de les incidències

Escriu V3__crear_taula_incidencies.sql per al model d'herència SINGLE_TABLE de 04-04: una taula incidencies amb id per seqüència, clau forana a bicicletes amb esborrat en cascada, columna discriminadora tipus, descripció obligatòria, data de report, estat, columnes d'auditoria, i els camps específics nivell_detectat (bateria) i denuncia_policial (vandalisme). Inclou-hi restriccions CHECK i índexs, i justifica per què les columnes específiques admeten nuls.

Exercici 2: eliminar una columna sense aturar el servei

CicloUrbana té a usuaris una columna telefon VARCHAR(20) NOT NULL que ja no es fa servir. Hi ha 3 rèpliques en producció amb desplegament progressiu. Descriu la seqüència completa de migracions i desplegaments per eliminar-la sense tallar el servei, i explica què passaria si es fes ALTER TABLE usuaris DROP COLUMN telefon directament.

Exercici 3: diagnosticar tres incidents

Diagnostica i resol cada situació.

  1. En arrencar en preproducció: Migration checksum mismatch for migration version 3.
  2. Un desenvolupador executa V4 en local i funciona; en integració contínua falla amb column "activa" of relation "estacions" already exists.
  3. Després de desplegar, l'aplicació arrenca però falla en crear estacions: duplicate key value violates unique constraint "pk_estacions".

Solucions

Solució 1.

-- V3__crear_taula_incidencies.sql
CREATE SEQUENCE incidencies_id_seq START WITH 1 INCREMENT BY 50;

CREATE TABLE incidencies (
    id                BIGINT       NOT NULL,
    tipus             VARCHAR(20)  NOT NULL,
    bicicleta_id      BIGINT       NOT NULL,
    descripcio        VARCHAR(500) NOT NULL,
    estat             VARCHAR(20)  NOT NULL DEFAULT 'OBERTA',
    reportada_el      TIMESTAMPTZ  NOT NULL,
    resolta_el        TIMESTAMPTZ,
    -- Camps específics de les subclasses: NUL·LABLES per necessitat
    nivell_detectat   INTEGER,
    denuncia_policial VARCHAR(40),
    versio            BIGINT       NOT NULL DEFAULT 0,
    creat_el          TIMESTAMPTZ  NOT NULL,
    modificat_el      TIMESTAMPTZ  NOT NULL,

    CONSTRAINT pk_incidencies PRIMARY KEY (id),
    CONSTRAINT fk_incidencies_bicicleta FOREIGN KEY (bicicleta_id)
        REFERENCES bicicletes (id) ON DELETE CASCADE,
    CONSTRAINT ck_incidencies_tipus  CHECK (tipus IN ('BATERIA', 'VANDALISME', 'AVARIA')),
    CONSTRAINT ck_incidencies_estat  CHECK (estat IN ('OBERTA', 'EN_CURS', 'RESOLTA')),
    CONSTRAINT ck_incidencies_nivell CHECK (nivell_detectat IS NULL
                                            OR nivell_detectat BETWEEN 0 AND 100),
    CONSTRAINT ck_incidencies_resolta CHECK (resolta_el IS NULL
                                             OR resolta_el >= reportada_el),
    -- Coherència entre el discriminador i els seus camps propis
    CONSTRAINT ck_incidencies_bateria CHECK (
        tipus <> 'BATERIA' OR nivell_detectat IS NOT NULL)
);

CREATE INDEX idx_incidencies_bicicleta ON incidencies (bicicleta_id);
CREATE INDEX idx_incidencies_estat     ON incidencies (estat)
    WHERE estat <> 'RESOLTA';

Per què les columnes específiques admeten nuls. És la contrapartida inevitable de SINGLE_TABLE: totes les subclasses comparteixen taula, i una incidència de vandalisme no té nivell_detectat. Declarar-les NOT NULL faria impossible inserir qualsevol tipus que no tingués tots els camps.

La restricció ck_incidencies_bateria recupera part d'aquesta integritat perduda: si el tipus és BATERIA, el nivell és obligatori. És la tècnica que compensa la principal debilitat de SINGLE_TABLE, i una raó més per haver triat JOINED si els camps propis fossin molts (exercici 3 de 04-04).

L'índex parcial sobre estat és una optimització deliberada: només indexa les incidències no resoltes, que són les que es consulten a diari, mantenint l'índex petit encara que l'històric creixi sense límit.

Solució 2. La seqüència correcta té tres passos:

Pas 1 — V10: relaxar la restricció.

-- V10__telefon_usuaris_nullable.sql
ALTER TABLE usuaris ALTER COLUMN telefon DROP NOT NULL;

És un canvi compatible cap enrere: la versió antiga continua enviant el telèfon i funciona; la nova podrà ometre'l.

Pas 2 — Desplegar la versió que ja no fa servir telefon, eliminant el camp de l'entitat Usuari, del DTO i del mapejador. Durant el desplegament progressiu conviuen rèpliques antigues —que escriuen el telèfon— i noves —que no—, i totes dues funcionen perquè la columna existeix i admet nuls.

Pas 3 — V11: eliminar la columna, en un desplegament posterior, després de confirmar que cap rèplica antiga continua viva.

-- V11__eliminar_telefon_usuaris.sql
ALTER TABLE usuaris DROP COLUMN telefon;

Què passaria amb el DROP COLUMN directe. Durant la fase intermèdia, les rèpliques antigues continuen executant INSERT INTO usuaris (..., telefon, ...) i SELECT ... telefon ..., i cadascuna d'aquestes consultes fallaria amb column "telefon" does not exist. Pitjor encara, amb ddl-auto: validate qualsevol rèplica antiga que es reiniciés no arribaria a arrencar: una caiguda parcial de CicloUrbana durant el desplegament, exactament el que el desplegament progressiu pretenia evitar. I una consideració addicional: el pas 3 és irreversible, així que convé arxivar les dades abans d'esborrar-les, perquè cap migració de desfer no recupera informació que ja no existeix.

Solució 3.

1. Migration checksum mismatch a la versió 3. Algú va editar V3__...sql després que s'hagués aplicat a preproducció. El checksum del fitxer ja no coincideix amb el registrat. Diagnòstic: SELECT version, checksum, installed_on FROM flyway_schema_history WHERE version = '3'; i comparar amb l'historial de Git del fitxer. Solució: revertir V3 al seu contingut original —el que es va aplicar— i crear V6 amb el canvi que es pretenia introduir. Només si el canvi era purament cosmètic (un comentari, un espai) i s'ha verificat que el SQL efectiu és idèntic, flyway:repair recalcula els checksums. No és l'opció per defecte: emmascara el problema.

2. column "activa" already exists només en integració contínua. La base de dades de CI no està neta: conserva l'esquema d'una execució anterior en què la columna ja es va crear, probablement per un ddl-auto: update que va quedar actiu o per una migració prèvia que ja l'afegia; en local funcionava perquè la base es creava de zero. Solució: fer que CI parteixi d'una base de dades efímera —Testcontainers (06-05) és exactament això—, comprovar que cap migració anterior no crea ja aquesta columna i confirmar que ddl-auto està en validate a tots els entorns.

3. duplicate key value violates unique constraint "pk_estacions". Falta el setval de V2. Es van inserir les quatre estacions amb ids explícits 1-4, però la seqüència estacions_id_seq continua al seu valor inicial, així que la primera estació creada des de l'API demana l'id 1 i xoca amb «Plaça Major». Solució: una migració nova que ajusti la seqüència per damunt del màxim real.

-- V12__ajustar_sequencia_estacions.sql
SELECT setval('estacions_id_seq',
              GREATEST((SELECT COALESCE(MAX(id), 0) FROM estacions) + 50, 100),
              false);

GREATEST amb el màxim real la fa segura sigui quin sigui l'estat de l'entorn, i el marge de 50 respecta l'allocationSize. La lliçó general: sempre que una migració insereixi ids explícits en una taula amb seqüència, ha d'ajustar la seqüència al mateix script.

Conclusió

El mòdul 4 es tanca amb CicloUrbana funcionant sobre persistència real i amb l'esquema sota control. Saps per què ddl-auto: update no pot arribar a producció —no modifica el que existeix, no esborra, no deixa registre, no és reproduïble i no migra dades— i, sobretot, per què el seu pitjor defecte és humà: converteix el canvi d'esquema en un efecte secundari en lloc d'una decisió revisada. Has comparat Flyway amb Liquibase i entens per què aquest projecte tria SQL natiu sobre un únic motor. Has integrat Flyway amb les seves dues dependències, configurat spring.flyway.* amb clean-disabled: true com a línia vermella, i fet el canvi decisiu del mòdul: ddl-auto passa d'update a validate, de manera que Hibernate ja no toca l'esquema i es limita a comprovar que les entitats i les taules coincideixen, fallant en arrencar quan no. Domines la convenció V/R/U, saps per a què serveix una migració repetible i entens la taula flyway_schema_history i el seu checksum, l'empremta que fa de l'arrencada un guardià contra l'edició de migracions ja aplicades.

Has escrit l'esquema inicial complet de Ribalta en SQL de PostgreSQL: quatre seqüències amb INCREMENT BY 50 que casen amb l'allocationSize de 04-03, quatre taules amb les seves claus primàries, claus foranes anomenades, restriccions CHECK que repliquen a la base de dades el que els enumerats garanteixen en Java, índexs explícits sobre les claus foranes —perquè PostgreSQL no els crea sol— i columnes d'auditoria i versio. I amb elles l'índex únic parcial uk_lloguers_usuari_en_curs, que converteix la regla de negoci central de CicloUrbana en una garantia del motor, immune a condicions de cursa. La migració V2 va carregar les quatre estacions i les tres tarifes, va ajustar la seqüència amb setval i va jubilar definitivament el CarregadorEstacionsDemo del mòdul 1. Coneixes el patró expand/contract per reanomenar una columna en tres desplegaments sense tallar el servei, la taula de canvis compatibles i incompatibles, els callbacks, les migracions Java amb BaseJavaMigration i la separació de dades de prova per locations.

Mira on és CicloUrbana. Va començar sent un main que imprimia un missatge. Té una API REST de tretze endpoints dissenyada sobre les restriccions de REST, amb verbs i codis d'estat correctes, validació declarativa, DTOs que separen domini i contracte, errors uniformes en RFC 7807 i un contracte OpenAPI publicable. I ara, a més, un model de dades persistent sobre PostgreSQL 16, amb entitats ben mapejades, relacions mandroses, repositoris de Spring Data, consultes que no pateixen el problema N+1, transaccions a la capa de servei i un esquema versionat a Git que qualsevol pot reconstruir des de zero. Les quatre estacions de Ribalta sobreviuen a un reinici, i també els lloguers, les bicicletes i els usuaris.

I precisament per això hi ha ara un problema que abans no importava: l'API està completament oberta. Qualsevol que conegui la URL pot crear estacions, donar de baixa bicicletes, consultar les dades personals dels ciutadans de Ribalta o finalitzar el lloguer d'una altra persona. Mentre tot vivia en memòria i es perdia en reiniciar, era una demo; ara hi ha dades reals i persistents d'una xarxa municipal, i no hi ha ni una sola comprovació de qui hi ha a l'altra banda. El mòdul 5, Seguretat a Spring Boot, ho resol: veurem què és Spring Security i com la seva cadena de filtres s'insereix davant del DispatcherServlet que vam conèixer a 03-01; la configurarem amb SecurityFilterChain substituint la contrasenya generada per defecte; distingirem autenticació d'autorització i modelarem els rols de CicloUrbana —ciutadà, operari, administrador— sobre l'entitat Usuari que acabem de crear; implementarem autenticació sense estat amb JWT, adequada per a una API consumida per una aplicació mòbil; i baixarem la seguretat al nivell de mètode amb @PreAuthorize perquè un ciutadà només pugui finalitzar els seus lloguers. La xarxa de Ribalta està a punt de tenir portes.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats