usharik.dev

← All articles

Liquibase in a Spring Boot 4 application

Published · Alex Usharovski

First published on Habr (in Russian) on 22 July 2019; this is a rewritten and updated version.

In 2019 I wrote two articles on Habr about Liquibase in Spring Boot: the basics, then diffs and rollbacks with the Maven plugin. The ideas hold up; almost every version number and several configuration keys do not. This is both parts in one, rebuilt on Spring Boot 4.1, Liquibase 5.0 and Hibernate 7, and run end to end before writing. The project uses H2 in a file so it runs anywhere; the Liquibase side is the same for PostgreSQL or MySQL.

Why not ddl-auto=update

Most tutorials set spring.jpa.hibernate.ddl-auto=update and let Hibernate adjust the tables to the entities. It is convenient on day one and a liability later:

Liquibase turns schema changes into files in the repository. Each changeset runs once per database; Liquibase records what it ran in a DATABASECHANGELOG table. Hibernate's job shrinks to checking that the tables and columns the entities need exist with compatible types:

spring.jpa.hibernate.ddl-auto=validate

With validate, a missing column fails the application at startup instead of the first query at runtime. It is not a full comparison: constraints such as NOT NULL or indexes are not checked.

The project

Spring Boot 4 split auto-configuration into modules, and Liquibase now has its own starter:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-liquibase</artifactId>
  </dependency>
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
  </dependency>
</dependencies>

The 2019 article added liquibase-core by hand. With Boot 4, the starter is the way: it brings liquibase-core in the version Boot manages, plus the module that runs it at startup.

One entity:

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "username", nullable = false, unique = true, length = 64)
    private String username;

    @Column(name = "email", length = 255)
    private String email;

    // constructors and getters
}

The changelog

Spring Boot looks for db/changelog/db.changelog-master.yaml on the classpath by default. Keep the master file as a list of includes, one file per change:

databaseChangeLog:
  - include:
      file: changes/001-create-users.yaml
      relativeToChangelogFile: true
databaseChangeLog:
  - changeSet:
      id: 001-create-users
      author: usharik
      changes:
        - createTable:
            tableName: users
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: username
                  type: varchar(64)
                  constraints:
                    nullable: false
                    unique: true
              - column:
                  name: email
                  type: varchar(255)

Start the application and Liquibase runs before Hibernate validates:

Running Changeset: db/changelog/changes/001-create-users.yaml::001-create-users::usharik
ChangeSet db/changelog/changes/001-create-users.yaml::001-create-users::usharik ran successfully in 5ms

A changeset is identified by id, author and the file path. Once it has run, do not edit it: Liquibase stores a checksum and refuses to start if an applied changeset changed. Fix mistakes with a new changeset.

Generating a changeset from the entities

Now the entity changes: email becomes mandatory and a display_name appears.

@Column(name = "email", nullable = false, length = 255)
private String email;

@Column(name = "display_name", length = 128)
private String displayName;

You can write the changeset by hand. For bigger changes, let Liquibase compare the database with the entities. That is the job of the Maven plugin with the Hibernate extension. In 2019 it was liquibase-hibernate5; for Hibernate 7 it is liquibase-hibernate7:

<plugin>
  <groupId>org.liquibase</groupId>
  <artifactId>liquibase-maven-plugin</artifactId>
  <version>${liquibase.version}</version>
  <configuration>
    <propertyFile>liquibase.properties</propertyFile>
  </configuration>
  <dependencies>
    <dependency>
      <groupId>org.liquibase.ext</groupId>
      <artifactId>liquibase-hibernate7</artifactId>
      <version>${liquibase.version}</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
      <version>${project.parent.version}</version>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>${h2.version}</version>
    </dependency>
  </dependencies>
</plugin>

${liquibase.version} comes from the Spring Boot parent, so the plugin, the extension and the library Boot runs at startup stay on one version. The plugin's settings live in liquibase.properties next to pom.xml:

changeLogFile=db/changelog/db.changelog-master.yaml
searchPath=src/main/resources
url=jdbc:h2:file:./target/demo-db;AUTO_SERVER=TRUE
username=sa
password=
referenceUrl=hibernate:spring:demo.model?dialect=org.hibernate.dialect.H2Dialect
diffChangeLogFile=target/diff-changelog.yaml
changeSetAuthor=usharik

url is the database as it is; referenceUrl is what it should be, here read from the entities in the package demo.model. changeSetAuthor replaces the 2019 trick with the user.name system property; without it the author is your OS user name.

mvn compile liquibase:diff

compile matters: the plugin reads the compiled entity classes. The result:

databaseChangeLog:
- changeSet:
    id: 1791372710407-2
    author: usharik
    changes:
    - addColumn:
        columns:
        - column:
            name: display_name
            type: VARCHAR(128)
        tableName: users
- changeSet:
    id: 1791372710407-1
    author: usharik
    changes:
    - addNotNullConstraint:
        columnDataType: varchar(255)
        columnName: email
        tableName: users
        validate: true

Treat it as a draft. Read it, give the changesets meaningful ids (the listing above keeps the generated ones), and think about data: addNotNullConstraint on email fails if any row has no email, so in real life a changeset that fills the column goes first. Then save it as changes/002-user-display-name.yaml and add it to the master file. On the next start Liquibase applies both changesets and validate passes.

Rollback, and the trap

Most changes Liquibase can undo by itself: the reverse of addColumn is dropColumn. For changes it cannot invert, such as raw sql, write a rollback block in the changeset. Roll back the last two changesets:

mvn liquibase:rollback -Dliquibase.rollbackCount=2

The first time I ran this, Liquibase answered:

INFO: 0 changesets rolled back.
Rollback command completed successfully.

"Completed successfully", and nothing happened. The cause is the file path. Spring Boot reads the changelog from the classpath, so DATABASECHANGELOG stores paths like db/changelog/changes/002-user-display-name.yaml. My first liquibase.properties pointed the plugin at src/main/resources/db/changelog/..., and for Liquibase that is a different file whose changesets were never applied. Nothing to roll back.

The fix is the pair of settings shown above: changeLogFile relative to the resources folder, and searchPath=src/main/resources. Then the paths match:

Rolling Back Changeset: db/changelog/changes/002-user-display-name.yaml::1791372710407-1::usharik
Rolling Back Changeset: db/changelog/changes/002-user-display-name.yaml::1791372710407-2::usharik

mvn liquibase:update applies them again. Two more commands help before touching a real database: liquibase:updateSQL and liquibase:rollbackSQL write the SQL to target/liquibase/migrate.sql instead of running it, and liquibase:tag marks a point you can roll back to by name with -Dliquibase.rollbackTag=....

Takeaways

Source files