Background
After joining a new company, there are always many things to learn from scratch. Today, I want to walk your through how to build your first Java web application using Sprint Boot. In this post, most of the code will be generated with the help of AI, and I will focus on assembling a complete and practical foundation for web application development - including environment configuration with .env, unit testing, ORM integration and more.
This article will be the first in a series documenting how I collaborate with AI to build a fully functional to-do web application. In this initial post, we will focus specifically on managing database migrations in a Sprint Boot application using Liquibase.
Initiation
At the beginning, we will use the Spring Initializr to help us initiate the basic content of the project. Go to 👉 https://start.spring.io.
Project Metadata Explained:
- Group: A unique identifier for your organization or project
- Common pattern: com.companyname.projectname
- Artifact: The name of your project’s build artifact (the JAR or WAR file name).
- If artifact = demo, your build will produce a file like:
demo-0.0.1-SNAPSHOT.jar
- If artifact = demo, your build will produce a file like:
- Name: The display name of the project (for humans).
- Description: A short explanation of your project’s purpose. It appears in your generated pom.xml file for documentation.

Click Generate, unzip it, and open it in your IDE. Your project structure will look like:
➜ tree
.
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── todolist
│ │ │ └── TodolistApplication.java
│ │ └── resources
│ │ └── application.properties
│ └── test
│ └── java
│ └── com
│ └── example
│ └── todolist
│ └── TodolistApplicationTests.java
└── target
├── classes
│ ├── application.properties
│ └── com
│ └── example
│ └── todolist
│ └── TodolistApplication.class
└── test-classes
└── com
└── example
└── todolist
└── TodolistApplicationTests.classFile / Folder Explanation
-
.mvn — Maven Wrapper Folder
- This folder supports the Maven Wrapper, which lets you build the project without installing Maven globally.
-
src — Source Code Directory
- This is where all your code and resources live.
src/main/java— your actual application source code. That’s where controllers, services, repositories, etc.
- This is where all your code and resources live.
-
target — Build Output Directory
- This folder is automatically created by Maven when you build or run your project. It contains all the compiled code, packaged JAR/WAR files, and temporary build files.
-
pom.xml
- pom.xml stands for Project Object Model file. It’s the central configuration file that defines:
- 📦 Project structure and metadata
- 🔗 Dependencies (libraries your app uses)
- ⚙️ Build configuration (how your app compiles, packages, and runs)
- 🔁 Plugins (extra tools like testing, packaging, or deployment automation)
- pom.xml stands for Project Object Model file. It’s the central configuration file that defines:
Implementation
Let's jump into the implementation. Before we write any code, we need to prepare two things: the project structure and the database dependencies. Below is what a clean Sprint Boot folder layout looks like. Most of this is generated automatically by Sprint Initializr, but we'll add our entity and config files manually...
src/
├── main/
│ ├── java/
│ │ └── com/example/todolist/
│ │ ├── TodolistApplication.java ← main entry point
│ │ ├── model/ ← your JPA entity
│ │ │ └── Todo.java
│ └── resources/
│ ├── application.properties ← DB + JPA configAdd the some dependencies
Before implementing the to-do list application, we need to set up the required dependencies and database connection. In my location environment, I use a .env file to manage config values such as database connection info. Below is an example configuration:
.env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=todolist
DB_USER=paul
DB_PASSWORD=devapplication.properties
spring.application.name=todolist
spring.datasource.url=jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME:demo}
spring.datasource.username=${DB_USER:default_user}
spring.datasource.password=${DB_PASSWORD:default_pass}
# Hibernate Settings
spring.jpa.hibernate.ddl-auto=None
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=trueI want to take a little of time to explain what's the Hibernate Setting, Hibernate is an ORM (Object-Relational Mapping) Framework, you can think it as the bridge between your java objects to relation database.
- spring.jpa.hibernate.ddl-auto=None
This controls how Hibernate handles DDL (Data Definition Language) → meaning tables, columns, schema generation. The following are different values for this setting.
- none: Do nothing. Hibernate will not create, update, validate, or drop your database schema.
- update: Automatically updates schema → adds columns, changes types (not recommended in production).
- create: Drops all tables and recreates them every time the app starts.
- create-drop: Like create, but drops the schema when the app stops.
- validate: Checks entity vs database schema—fails if mismatch but does not modify schema. - spring.jpa.show-sql=true
This tells Hibernate to print SQL statements in the console/logs. Not recommended in production, because it can print sensitive data + cause log flooding. - spring.jpa.properties.hibernate.format_sql=true
Formats SQL logs to be pretty and readable. Works only when show-sql=true
Next, edit your pom.xml → inside <dependencies> section, add:
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testing (already included but keep it) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>Then reload Maven: (Remember to export your .env to system environmental variable, because the Spring Boot doesn’t read .env automatically)
Create the Model
For the creating the model in the Sprint Boot, I will introduce the Lombok which can help us quickly build the model object without implement the Getter/Setter methods.
In pom.xml, inside <dependencies>, add:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>src/main/java/com/example/todolist/model/Todo.java
package com.example.todolist.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
@Entity
@Table(name = "todolist")
@Getter
@Setter
@NoArgsConstructor
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
@Column(nullable = false)
private boolean completed = false;
// UTC-safe creation timestamp
@Column(nullable = false, updatable = false,
columnDefinition = "TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP")
private Instant createdAt = Instant.now();
// Custom constructor for creating new todos
public Todo(String title, String description) {
this.title = title;
this.description = description;
}
}We have defined the model schema for the todolist table. For the createdAt field, we use the Instant type rather than LocalDateTime to store the timestamp in UTC. This ensures that the creation time is recorded consistently and is automatically set to the current instant.
Migration Manager
In modern SaaS application, database migration management is a critical part of the development workflow. Each release typically involves schema changes, and keeping track of these changes in version control is essential for maintainability and team collaboration.
Liquibase is an open-source database schema change management tool that helps development and DevOps teams track, version, and automate database migrations. In this post, we will walk through how to install Liquibase and use it to manage database changes in your Spring Boot application.
To get started, add the following sections to your pom.xml to include the Liquibase dependencies and Maven plugin:
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.27.0</version>
</dependency><plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>4.27.0</version>
<configuration>
<changeLogFile>
src/main/resources/db/changelog/db.changelog-master.yaml
</changeLogFile>
<url>${env.DB_URL}</url>
<username>${env.DB_USER}</username>
<password>${env.DB_PASSWORD}</password>
<driver>org.postgresql.Driver</driver>
<referenceUrl>
hibernate:spring:com.example.todolist.model?dialect=org.hibernate.dialect.PostgreSQLDialect
</referenceUrl>
<referenceDriver>
liquibase.ext.hibernate.database.connection.HibernateDriver
</referenceDriver>
</configuration>
<dependencies>
<!-- Hibernate integration for Liquibase -->
<dependency>
<groupId>org.liquibase.ext</groupId>
<artifactId>liquibase-hibernate6</artifactId>
<version>4.27.0</version>
</dependency>
<!-- Spring ORM support -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>6.1.3</version>
</dependency>
<!-- Spring context (required for Hibernate integration) -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.1.3</version>
</dependency>
<!-- PostgreSQL JDBC driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
</dependencies>
</plugin>you can use the following command to verify the installation.
./mvnw liquibase:helpNext we will setup how to generate the ChangeLog for our new model todolist. Create the db.changelog-master.yaml to /src/main/resources/db/changelog and also create the changes folder in the changelog.
todolist/src/main/resources on main [⇡] is 📦 1 via ☕ v21.0.9
➜ tree
.
├── application.properties
└── db
└── changelog
├── changes
│ └── 0001-changelog-init.yaml
└── db.changelog-master.yamldb.changelog-master.yaml
databaseChangeLog:
- includeAll:
path: db/changelog/changes/
relativeToChangelogFile: false
Based on the settings in the db.changelog-master.yaml, we will store all the changelog files in the db/changelog/changes/. New we will use the following commands to generate our first init migration file and apply the update.
export $(cat .env | xargs)
./mvnw liquibase:diff -Dliquibase.diffChangeLogFile=src/main/resources/db/changelog/changes/0001-init.yamlYou will get the following change log:
databaseChangeLog:
- changeSet:
id: 1763910259559-1
author: taiker (generated)
changes:
- createTable:
columns:
- column:
autoIncrement: true
constraints:
nullable: false
primaryKey: true
primaryKeyName: todolistPK
name: id
type: BIGINT
- column:
constraints:
nullable: false
name: completed
type: BOOLEAN
- column:
constraints:
nullable: false
defaultValueComputed: CURRENT_TIMESTAMP
name: createdAt
type: TIMESTAMP WITH TIME ZONE
- column:
name: description
type: VARCHAR(255)
- column:
constraints:
nullable: false
name: title
type: VARCHAR(255)
tableName: todolist
- changeSet:
id: 1763910259559-2
author: taiker (generated)
changes:
- dropTable:
tableName: todos
Next we will use the update command to apply our database change log.
export $(cat .env | xargs)
./mvnw liquibase:update
You can log in to the Postgres to see the result
todolist=# \dt public.*
List of relations
Schema | Name | Type | Owner
--------+-----------------------+-------+--------
public | databasechangelog | table | taiker
public | databasechangeloglock | table | taiker
public | todolist | table | taiker
(3 rows)todolist=# \d public.todolist
Table "public.todolist"
Column | Type | Collation | Nullable | Default
-------------+--------------------------+-----------+----------+----------------------------------
id | bigint | | not null | generated by default as identity
completed | boolean | | not null |
createdAt | timestamp with time zone | | not null | now()
description | character varying(255) | | |
title | character varying(255) | | not null |
Indexes:
"todolistPK" PRIMARY KEY, btree (id)Now you can see that we have successfully created a new table called todolist in PostgreSQL by applying the changelog generated by Liquibase. This is the simplest example of using Liquibase during development. Next, we will add additional fields to the table through multiple changelogs to explore what else Liquibase can do.
The first change we want to make is renaming the createdAt column in the todolist table. According to common database conventions, column names should follow snake_case, so the column should be named created_at instead of createdAt. This is a perfect opportunity to create another Liquibase changelog to apply this update.
To avoid running into this naming mismatch again in the future, we will also update our Hibernate configuration. By enabling the CamelCaseToUnderscoresNamingStrategy, we can continue using maps them to snake_case columns in the database. This keeps our Java model clean and idiomatic while ensuring consistent database naming conventions.
application.properties
# Use snake_case for database column names
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategyGenerate the following change log for renaming createdAt field, and apply it.
databaseChangeLog:
- changeSet:
id: 0002-rename-createdAt-to-created_at
author: taiker
changes:
- renameColumn:
tableName: todolist
oldColumnName: createdAt
newColumnName: created_at
columnDataType: TIMESTAMP WITH TIME ZONE
Now that we have updated the table schema, we can try using the Liquibase rollback command. After running the rollback, you will see that the database returns to the previous schema exactly as expected.
export $(cat .env | xargs)
# option 1
mvn liquibase:rollback -Dliquibase.rollbackCount=1todolist=# \d public.todolist
Table "public.todolist"
Column | Type | Collation | Nullable | Default
-------------+--------------------------+-----------+----------+----------------------------------
id | bigint | | not null | generated by default as identity
completed | boolean | | not null |
createdAt | timestamp with time zone | | not null | now()
description | character varying(255) | | |
title | character varying(255) | | not null |
Indexes:
"todolistPK" PRIMARY KEY, btree (id)Bonus
I use a Makefile to wrap the Maven Liquibase commands, making them much easier to run during development. Below is the Makefile for reference. You can run make help to view all supported commands.
➜ make help
=== Liquibase Migration Commands ===
Migration Generation:
make makemigration - Generate new migration (auto-numbered)
make makemigration NAME=example - Generate migration with custom name
Migration Execution:
make migrate - Apply all pending migrations
make migrate-one - Apply only the next pending migration
make migrate-to NUM=0008 - Migrate to specific version
make showmigrations - Show migration status
Rollback:
make rollback COUNT=1 - Rollback N changesets (default: 1)
make rollback-preview COUNT=1 - Preview rollback SQL
Fake Migrations:
make fake-migrate - Mark all pending as executed
make fake-migrate-to NUM=0008 - Mark up to version as executed
make fake-migrate-preview - Preview what would be marked# Makefile for Liquibase Migration Management
# Provides Django-like commands for database migrations
.PHONY: help makemigration migrate migrate-one migrate-to showmigrations rollback rollback-preview fake-migrate fake-migrate-to fake-migrate-preview
# Default target
help:
@echo "=== Liquibase Migration Commands ==="
@echo ""
@echo "Migration Generation:"
@echo " make makemigration - Generate new migration (auto-numbered)"
@echo " make makemigration NAME=example - Generate migration with custom name"
@echo ""
@echo "Migration Execution:"
@echo " make migrate - Apply all pending migrations"
@echo " make migrate-one - Apply only the next pending migration"
@echo " make migrate-to NUM=0008 - Migrate to specific version"
@echo " make showmigrations - Show migration status"
@echo ""
@echo "Rollback:"
@echo " make rollback COUNT=1 - Rollback N changesets (default: 1)"
@echo " make rollback-preview COUNT=1 - Preview rollback SQL"
@echo ""
@echo "Fake Migrations:"
@echo " make fake-migrate - Mark all pending as executed"
@echo " make fake-migrate-to NUM=0008 - Mark up to version as executed"
@echo " make fake-migrate-preview - Preview what would be marked"
@echo ""
# Variables
CHANGES_DIR := src/main/resources/db/changelog/changes
MVN := mvn
NAME ?= auto_generated
COUNT ?= 1
# Auto-detect next migration number
LATEST_NUM := $(shell ls $(CHANGES_DIR) 2>/dev/null | grep -E '^[0-9]+' | sed 's/^0*//' | sed 's/[^0-9].*//' | sort -n | tail -1)
ifeq ($(LATEST_NUM),)
NEXT_NUM := 1
else
NEXT_NUM := $(shell echo $$(($(LATEST_NUM) + 1)))
endif
NEXT_FORMATTED := $(shell printf "%04d" $(NEXT_NUM))
# Migration Generation
makemigration:
@echo "Generating migration $(NEXT_FORMATTED)_$(NAME).yaml..."
@if [ ! -d "$(CHANGES_DIR)" ]; then \
echo "Error: Directory $(CHANGES_DIR) does not exist"; \
exit 1; \
fi
@FILEPATH="$(CHANGES_DIR)/$(NEXT_FORMATTED)_$(NAME).yaml"; \
$(MVN) liquibase:diff -Dliquibase.diffChangeLogFile=$$FILEPATH; \
if [ -f $$FILEPATH ]; then \
echo "" >> $$FILEPATH; \
echo "- changeSet:" >> $$FILEPATH; \
echo " id: tag-$(NEXT_FORMATTED)" >> $$FILEPATH; \
echo " author: taiker" >> $$FILEPATH; \
echo " changes:" >> $$FILEPATH; \
echo " - tagDatabase:" >> $$FILEPATH; \
echo " tag: \"$(NEXT_FORMATTED)\"" >> $$FILEPATH; \
echo "✓ Migration created: $$FILEPATH"; \
echo "✓ Tag $(NEXT_FORMATTED) added"; \
else \
echo "Error: Failed to generate migration file"; \
exit 1; \
fi
# Migration Execution
migrate:
@echo "Applying all pending migrations..."
@$(MVN) liquibase:update
migrate-one:
@echo "Applying next pending migration..."
@$(MVN) liquibase:updateCount -Dliquibase.count=1
migrate-to:
@if [ -z "$(NUM)" ]; then \
echo "Error: NUM parameter required. Usage: make migrate-to NUM=0008"; \
exit 1; \
fi
@echo "Migrating to version $(NUM)..."
@$(MVN) liquibase:updateToTag -Dliquibase.toTag=$(NUM)
showmigrations:
@echo "Checking migration status..."
@$(MVN) liquibase:status
# Rollback
rollback:
@echo "Rolling back $(COUNT) changeset(s)..."
@$(MVN) liquibase:rollback -Dliquibase.rollbackCount=$(COUNT)
rollback-preview:
@echo "Previewing rollback of $(COUNT) changeset(s)..."
@$(MVN) liquibase:rollbackSQL -Dliquibase.rollbackCount=$(COUNT)
@echo ""
@echo "Preview saved to: target/liquibase/migrate.sql"
# Fake Migrations
fake-migrate:
@echo "Marking all pending migrations as executed (without running them)..."
@$(MVN) liquibase:changeLogSync
fake-migrate-to:
@if [ -z "$(NUM)" ]; then \
echo "Error: NUM parameter required. Usage: make fake-migrate-to NUM=0008"; \
exit 1; \
fi
@echo "Marking migrations up to $(NUM) as executed (without running them)..."
@$(MVN) liquibase:changeLogSyncToTag -Dliquibase.toTag=$(NUM)
fake-migrate-preview:
@echo "Previewing what would be marked as executed..."
@$(MVN) liquibase:changeLogSyncSQL
@echo ""
@echo "Preview saved to: target/liquibase/migrate.sql"
Takeaway
In this post, I walked you through how to start a new Java project using the online Spring Initializr. Unlike man tutorials that build an entire to-do list application in one go, we spent most of our time focusing on how to use Liquibase to manage database schema changes through practical, real-world examples. Understanding database migrations early helps ensure your application remains maintainable as it grows.
In the next post, we will continue building the application and complete the remaining parts of the basic to-do list system using Spring Boot. If you’re interested in building production-ready SaaS applications with Java and Spring Boot, stay tuned for the next article.