Spring Boot CRUD Application With DB Migration Using Flyway and PostgreSQL

Spring Boot CRUD Application With DB Migration Using Flyway and PostgreSQL

Flyway Overview

Flyway is an open-source version control framework that enables developers to continuously keep track of database changes incrementally. Changes made to the database by Flyway are known as Migrations.

Database/Schema migration is performed on a database (Flyway supports many databases) whenever it is necessary to update or revert that database's schema to some newer or older version.

In Flyway, Migrations have two types:

  1. Versioned Migrations: Versioned migrations have a version, description, and a checksum and are applied in order exactly once.

  2. Repeatable Migrations: Repeatable migrations have a description and a checksum and are reapplied every time their checksum changes.

In this article, We will see how to use Flyway in a Spring Boot application. We will create a simple CRUD that manages users' profiles.

To keep things simple, we won’t bother ourselves with validations, DTO (Data Transfer Object) classes and exception handlers, the goal is to demonstrate how to work with Flyway with Spring Boot and PostgreSQL.

Spring Boot provides easy integration with Flyway which is very good to start the data migration quickly.

Step 1: Create a Spring Boot project using spring initializr and add the following dependencies:

  • Spring Web

  • Spring Data JPA

  • PostgreSQL Database

  • Lombok

  • Flyway Migration

Spring Boot provides easy integration with Flyway by adding the Flyway Migration dependency which is very good to start the data migration quickly.

Then we need the Spring Data JPA and PostgreSQL dependency for Flyway to work with the database.

Lombok is used to reduce boilerplate code for model/data objects, e.g., it can generate getters and setters for those object automatically by using Lombok annotations.

Click here to download the above setup from spring initializr and open the project in IntelliJ or your favorite IDE.

Note: Spring Boot 3+ requires Java 17 and above to run, the same operation works for lower versions as well.

The pom.xml file looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.chrisimoni</groupId>
    <artifactId>spring-boot-flyway-jpa-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-flyway-jpa-demo</name>
    <description>Spring Boot With Flyway DB Migration Demo</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <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-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Step 2: Next, create 4 packages (controller, model, repository, service) as shown in the image below:

The db/migration folder in the resources folder is created automatically when we add flyway migration dependency.

Step 3: Update your application.yml with the following datasourse configuration:

server:
  port: 8080

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/demo_db
    username: postgres
    password: password
  jpa:
    show-sql: true
    driverClassName: org.hibernate.dialect.PostgreSQL81Dialect

  ## Flyway config
  flyway:
    baseline-version: 1
    baseline-on-migrate: true

For application.properties users:

server.port=8080

spring.datasource.url=jdbc:postgresql://localhost:5432/demo_db
spring.datasource.username=postgres
spring.datasource.password=password
spring.jpa.show-sql=true
spring.jpa.driverClassName=org.hibernate.dialect.PostgreSQL81Dialect

## Flyway config
spring.flyway.baseline-version=1
spring.flyway.baseline-on-migrate=true

Step 4: Create first migration

Flyway adheres to the following naming convention for migration scripts:

<Prefix><Version>__<Description>.sql

Where:

<Prefix> – The prefix determines the type of file, whether a versioned migration (V), an undo migration (U), or a repeatable migration (R). The default prefix is V.

<Version> – Next, you have a version number. This can be almost any format that you like, but it has to be unique for any given migration (although a versioned migration and an undo migration must share a common version number) and it has to be logically ordered. The migration version should always start with 1.

<Description> – Textual description of the migration. A double underscore separates the description from the version numbers.

Example: V1_1__my_first_migration.sql

So let's create our first migration file named V1_1__create_users_table.sql inside db/migration containing SQL instructions to create the user table:

CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  first_name VARCHAR(50) NOT NULL,
  last_name VARCHAR(50) NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  created_on TIMESTAMP NOT NULL
);

When we run the spring boot application, flyway creates users and flyway_schema_history tables as shown in the image below:

flyway_schema_history table is used by Flyway to keep track of the migration versions, script file name, checksum, etc. If we make any change to the V1_1__create_users_table.sql after the migration is completed, Flyway will check for the checksum and throw an error.

So any changes to the users table must be done in a new migration script.

Step 5: Create the class User inside the model package.

package com.chrisimoni.springbootflywayjpademo.model;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;
    private String firstName;
    private String lastName;
    private String email;
    @CreationTimestamp
    private LocalDateTime createdOn;
}

Step 6: Create a simple interface and name the interface as UserRepository. This interface is going to extend the JpaRepository interface as shown below:

package com.chrisimoni.springbootflywayjpademo.repository;

import com.chrisimoni.springbootflywayjpademo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}

Step 7: create an interface named UserService and add the following codes:

package com.chrisimoni.springbootflywayjpademo.service;

import com.chrisimoni.springbootflywayjpademo.model.User;

import java.util.List;

public interface UserService {
    User saveUser(User user);

    List<User> getAllUsers();

    User getUserById(long userId);

    User UpdateUser(long userId, User user);

    void deleteUser(long userId);
}

Step 8: Next, create a new package inside the service package called Impl, then create the class UserServiceImpl that would implement the userService interface as shown in the image below:

Add the following implementation to UserServiceImpl class:

package com.chrisimoni.springbootflywayjpademo.service.impl;

import com.chrisimoni.springbootflywayjpademo.model.User;
import com.chrisimoni.springbootflywayjpademo.repository.UserRepository;
import com.chrisimoni.springbootflywayjpademo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserRepository userRepository;

    @Override
    public User saveUser(User user) {
        return userRepository.save(user);
    }

    @Override
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }

    @Override
    public User getUserById(long userId) {
        return userRepository.findById(userId).get();
    }

    @Override
    public User UpdateUser(long userId, User user) {
        //Allow user to update their firstname and lastname
        User userObj = userRepository.findById(userId).get();
        if (Objects.nonNull(user.getFirstName())
                && !"".equalsIgnoreCase(
                user.getFirstName())) {
            userObj.setFirstName(user.getFirstName());
        }

        if (Objects.nonNull(user.getLastName())
                && !"".equalsIgnoreCase(
                user.getLastName())) {
            userObj.setLastName(user.getLastName());
        }

        return userRepository.save(userObj);
    }

    @Override
    public void deleteUser(long userId) {
        userRepository.deleteById(userId);
    }
}

Step 9: Create UserController class inside the controller package, then add the following implementation:

package com.chrisimoni.springbootflywayjpademo.controller;

import com.chrisimoni.springbootflywayjpademo.model.User;
import com.chrisimoni.springbootflywayjpademo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/users")
public class UserController {
    @Autowired
    private UserService userService;

    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody User userRequest) {
        User user = userService.saveUser(userRequest);
        return new ResponseEntity<>(user, HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity<List<User>> getAllUsers() {
        List<User> users = userService.getAllUsers();
        return new ResponseEntity<>(users, HttpStatus.OK);
    }

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable("id") long userId) {
        User user = userService.getUserById(userId);
        return new ResponseEntity<>(user, HttpStatus.OK);
    }

    @PatchMapping("/{id}")
    public ResponseEntity<User> updateUser(@PathVariable("id") long userId,
                                               @RequestBody User userRequest) {
        User user = userService.updateUser(userId, userRequest);
        return new ResponseEntity<>(user, HttpStatus.OK);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<User> deleteUser(@PathVariable("id") long userId) {
        userService.deleteUser(userId);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
}

Step 10: Testing the endpoints in Postman

Create user: POST - localhost:8080/users

Get all users: GET - localhost:8080/users

Get user by id: GET - localhost:8080/users/1

Update user: PATCH - localhost:8080/users/1

Delete user: DELETE - localhost:8080/users/1

Modify existing table

Whenever a database needs to be upgraded, whether it be the schema (DDL) or reference data (DML), we can just make a new migration script with a higher version number. Flyway will discover the updated script when it launches and update the database accordingly.

In this example, I will create a new script named V1_2__add_sex_to_users_table.sql containing SQL instructions to alter the users table and add a new column called sex.

When you run the application, you would see the users table now contain the sex column, the next thing to do is to update your User class to contain the sex property.

Source code: https://github.com/chrisimoni/spring-boot-flyway-demo