How To Use Flyway for Database Migration in Spring Boot

In this article, we're going to look at how we can use Flyway to manage our SQL database schema in a Spring Boot application.

Flyway is a database migration tool that offers capabilities for migration history and rollback and allows us to separate the database schema-related layer from the database entity layer of an application.

Application Setup

The Spring Boot application we will be using can be generated using this Spring Initializr link. It contains all the necessary dependencies.

After downloading the application and resolving the dependencies, we will create a new Postgres database called spring-boot-flyway and configure the application to connect to it.

Listing 2.1 application.properties:

Properties files
 
spring.datasource.url=jdbc:postgresql://localhost:5432/spring-boot-flyway
spring.datasource.username=demo
spring.datasource.password=demo


By default, Flyway will search the db/migration/ directory in the classpath for migration files containing SQL statements to manage database tables and records.

For older versions of the library, we may need to create an empty text file called .keep in resources/db/migration/ to ensure the directory is compiled and available during application start-up to avoid errors.

Having done that, we can now start the application and it should run successfully.

Basic Usage

The way Flyway works is that we create a migration file in the resources/db/migration directory and Spring Boot will automatically execute the migration script because we've added the Flyway dependencies to the classpath in section 2.

Listing 3.1 V1__Users.sql:

SQL
 
CREATE TABLE IF NOT EXISTS users
(
    id    SERIAL,
    email VARCHAR(200) NOT NULL,
    name  VARCHAR(200) NOT NULL,
    PRIMARY KEY (id)
);


Let's take a minute to examine the snippet in Listing 3.1. The file name, V1__Users.sql, follows a certain convention:

At this point, restarting the application will create the users table in the database. Furthermore, we can see there's another table that we did not explicitly create - flyway_schema_history.

The flyway_schema_history is used by Flyway itself to track the migration that has been applied. If the table is missing, Flyway will assume we're initializing the database for the first time and run all the migrations in order of their version number.

When the flyway_schema_history table is present, Flyway will only apply newer migration files that have not been applied before. This means that in order for us to add new tables, we only need to create newer migration files with an updated version number and restart the application.

In addition to using SQL, we can also write our migration script in Java. In the Java migration style, our migration files are Java classes and must extend the abstract BaseJavaMigration class and implement the migrate method.

IDEs typically don't expect Java classes to be in the resources directory and so we're going to create a new package called db/migration in src/main/java. It is very important to know that this new package db/migration should be in the src/main/java directory.

Let's create a new Java migration to add a new table:

Listing 3.2 V2__Posts.java:

Java
 
public class V2__Posts extends BaseJavaMigration {
    @Override
    public void migrate(Context context) throws Exception {
        var sql = """
                CREATE TABLE posts (
                     id SERIAL,
                     author_id INT NOT NULL,
                     post TEXT NOT NULL,
                     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                     PRIMARY KEY (id)
                );
                """;
        try(var statement = context.getConnection().createStatement()) {
            statement.execute(sql);
        }
    }
}


The advantage of using Java migration over SQL files is that we can add custom logic, conditions, and validations that will not be possible with plain SQL. For example, we can check if another table exists or get certain values from the environment, etc.

As you're probably guessing now, yes, it is possible to mix both SQL and Java-style migration in the same codebase, as long as we ensure the Flyway location is the same in both cases.

Flyway Configuration and Customization

Hitherto, we've been working with the default Flyway behavior. We can further tweak Flyway to suit our needs. For example, we can change the default location for migration files, configure the database schema (a.k.a., tablespace), change the SQL migration prefix from "V" to whatever we want and so much more.

In the configuration below, we configure the path where the migration files are located and disable cleaning the database (i.e., dropping all tables) to prevent accidental use in a production environment.

Listing 4.1 application.properties:

Properties files
 
spring.flyway.locations=classpath:migrations
spring.flyway.clean-disabled=true


There are other configurable properties under the spring.flyway key that we can use to fine-tune the behavior of the library. Also, we can consult the Flyway documentation page for reference.

Flyway Callbacks

Flyway provides us with the ability to configure callbacks that can be invoked at different stages of the migration process. The callback mechanism is a handy way of performing certain actions at different phases of the migration life cycle. 

Let's say we have some default data that we want to seed when our application is starting up. We can simply create a callback that supports the AFTER_MIGRATE event.

Listing 5.1 FlywayDatabaseSeeder.java:

Java
 
public class FlywayDatabaseSeeder implements Callback {

    @Override
    public boolean supports(Event event, Context context) {
        return event.name().equals(Event.AFTER_MIGRATE.name());
    }
    
    @Override
    public void handle(Event event, Context context) {
        try(var statement = context.getConnection().createStatement()) {

            var ADMIN_EMAIL = "superadmin@example.com";
            
            var checkQuery = "SELECT id FROM users WHERE email = %s"
                    .formatted(ADMIN_EMAIL);
            
            statement.execute(checkQuery);
            ResultSet resultSet = statement.getResultSet();
            resultSet.last();

            //return if the seeder has already been executed
            if(resultSet.getRow() >= 0) return;

            var sql = """
                    INSERT INTO users (email, name) VALUES
                    ('%s', 'Super Admin')
                    """.formatted(ADMIN_EMAIL);
            
            statement.execute(sql);
            
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public boolean canHandleInTransaction(Event event, Context context) {
        return true;
    }

    @Override
    public String getCallbackName() {
        return FlywayDatabaseSeeder.class.getName();
    }
}


In the listing above, in the supports method, we declared that this callback should only be executed for the AFTER_MIGRATE event and in the handle method, we outlined the logic for inserting the default super admin user if it does not exist already.

Before this can work, we need to register the callback class with Flyway in SpringBoot. We do this by creating a FlywayMigrationStrategy bean.

Listing 5.2 SpringBootFlywayApplication.java:

Java
 
@Bean
public FlywayMigrationStrategy flywayMigrationStrategy() {
	return (flywayOld) -> {

		/*
		 Update the existing autoconfigured Flyway
		 bean to include our callback class
		*/
		Flyway flyway = Flyway.configure()
				.configuration(flywayOld.getConfiguration())
				.callbacks(new FlywayDatabaseSeeder())
				.load();

		flyway.migrate();

	};
}


There are other events in the org.flywaydb.core.api.callback.Event enum that we can configure our Callback classes to support. For example, you can have a callback that'll support the AFTER_MIGRATE_ERROR event and send a Slack notification to alert the engineers.

Tips and Tricks

While developing in your local environment, you can delete the entry for a migration from the flyway_schema_history table.

Next time you start your application, the migration for which you deleted its history will be executed again. This way, you can correct errors or update your schema while still developing on your local machine without dropping the entire database.

Additionally, in SpringBoot, you can control when Flyway should execute the migration script on application start-up. For example, let's say we don't want the migration to be automatically executed on our local environment. We can do the following:

Listing 6.1 SpringBootFlywayApplication.java:

Java
 
@Bean
public FlywayMigrationStrategy flywayMigrationStrategy(@Value("${spring.profiles.active}") String activeProfile) {
	return (flywayOld) -> {

		/*
		 Update the existing autoconfigured Flyway
		 bean to include our callback class
		*/
		Flyway flyway = Flyway.configure()
				.configuration(flywayOld.getConfiguration())
				.callbacks(new FlywayDatabaseSeeder())
				.load();

		if(!"local".equalsIgnoreCase(activeProfile)) {
			flyway.migrate();
		}

	};
}


Conclusion

One of the advantages of using a database migration tool is that it makes the database schema a part of the application codebase. This makes it easier to track changes to the database over time as there's a central reference point in the application.

The complete source code is available on GitHub, Happy coding.

 

 

 

 

Top