Setting Up Spring Boot with Spring Data JPA and MySQL

Introduction

This article builds the project foundation used throughout the entire series. By the end, you will have a running Spring Boot 3.3 application connected to MySQL 8.x with Hibernate 6, a proper connection pool, schema management via Flyway, and SQL logging configured so you can see exactly what Hibernate sends to the database.


Project Setup

Maven pom.xml

<?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.3.4</version>
    </parent>

    <groupId>com.devopsmonk</groupId>
    <artifactId>spring-data-jpa-demo</artifactId>
    <version>1.0.0</version>
    <name>spring-data-jpa-demo</name>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <!-- Spring Data JPA — includes Hibernate 6 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Spring Web (for REST controllers in later articles) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- MySQL JDBC driver -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Flyway for schema migrations -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-mysql</artifactId>
        </dependency>

        <!-- Lombok — reduces boilerplate on entities and services -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Testing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>mysql</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>

The key dependency is spring-boot-starter-data-jpa, which pulls in:

  • Spring Data JPA
  • Hibernate 6.4+
  • Spring ORM
  • The JPA API (jakarta.persistence)

Main Application Class

package com.devopsmonk.jpademo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JpaDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(JpaDemoApplication.class, args);
    }
}

No extra annotations are needed. Spring Boot auto-configures JPA, Hibernate, and the datasource from application.properties.


Application Configuration

# ── DataSource ───────────────────────────────────────────────────────────────
spring.datasource.url=jdbc:mysql://localhost:3306/jpa_demo?createDatabaseIfNotExist=true\
  &useSSL=false\
  &serverTimezone=UTC\
  &allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# ── HikariCP Connection Pool ─────────────────────────────────────────────────
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.connection-test-query=SELECT 1

# ── JPA / Hibernate ──────────────────────────────────────────────────────────
# Never let Hibernate manage schema in production — use Flyway instead
spring.jpa.hibernate.ddl-auto=validate

# Show SQL in logs (disable in production)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Hibernate statistics — useful during development for detecting N+1
spring.jpa.properties.hibernate.generate_statistics=false

# ── Flyway ───────────────────────────────────────────────────────────────────
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true

Why ddl-auto=validate?

Hibernate’s DDL auto options:

ValueBehaviourUse when
noneNo schema changesProduction with managed migrations
validateValidates schema matches entities — fails fast on mismatchProduction (recommended)
updateAdds missing columns — never dropsPrototyping only — dangerous in prod
createDrops and recreates all tables on startupTests with in-memory DB
create-dropCreates on start, drops on stopTests only

In production, use validate with Flyway managing the schema. In development, update is convenient but can hide errors. This series uses validate so you see Flyway doing its job.


Flyway Migration Scripts

Flyway applies SQL migrations from src/main/resources/db/migration/ in alphabetical order.

V1__create_schema.sql

-- Initial e-commerce schema

CREATE TABLE categories (
    id         BIGINT       NOT NULL AUTO_INCREMENT,
    name       VARCHAR(100) NOT NULL,
    slug       VARCHAR(100) NOT NULL UNIQUE,
    created_at DATETIME(6)  NOT NULL,
    updated_at DATETIME(6)  NOT NULL,
    PRIMARY KEY (id)
) ENGINE=InnoDB;

CREATE TABLE tags (
    id   BIGINT       NOT NULL AUTO_INCREMENT,
    name VARCHAR(50)  NOT NULL UNIQUE,
    PRIMARY KEY (id)
) ENGINE=InnoDB;

CREATE TABLE products (
    id          BIGINT         NOT NULL AUTO_INCREMENT,
    name        VARCHAR(255)   NOT NULL,
    description TEXT,
    price       DECIMAL(10, 2) NOT NULL,
    stock       INT            NOT NULL DEFAULT 0,
    status      VARCHAR(20)    NOT NULL DEFAULT 'ACTIVE',
    category_id BIGINT,
    created_at  DATETIME(6)    NOT NULL,
    updated_at  DATETIME(6)    NOT NULL,
    PRIMARY KEY (id),
    CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES categories (id)
) ENGINE=InnoDB;

CREATE TABLE product_tags (
    product_id BIGINT NOT NULL,
    tag_id     BIGINT NOT NULL,
    PRIMARY KEY (product_id, tag_id),
    CONSTRAINT fk_product_tag_product FOREIGN KEY (product_id) REFERENCES products (id),
    CONSTRAINT fk_product_tag_tag     FOREIGN KEY (tag_id)     REFERENCES tags (id)
) ENGINE=InnoDB;

CREATE TABLE customers (
    id           BIGINT       NOT NULL AUTO_INCREMENT,
    name         VARCHAR(100) NOT NULL,
    email        VARCHAR(150) NOT NULL UNIQUE,
    phone        VARCHAR(20),
    -- Address embedded columns
    street       VARCHAR(200),
    city         VARCHAR(100),
    state        VARCHAR(100),
    postal_code  VARCHAR(20),
    country      VARCHAR(50),
    created_at   DATETIME(6)  NOT NULL,
    updated_at   DATETIME(6)  NOT NULL,
    PRIMARY KEY (id)
) ENGINE=InnoDB;

CREATE TABLE orders (
    id          BIGINT         NOT NULL AUTO_INCREMENT,
    customer_id BIGINT         NOT NULL,
    status      VARCHAR(20)    NOT NULL DEFAULT 'PENDING',
    total       DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
    ordered_at  DATETIME(6)    NOT NULL,
    created_at  DATETIME(6)    NOT NULL,
    updated_at  DATETIME(6)    NOT NULL,
    PRIMARY KEY (id),
    CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES customers (id)
) ENGINE=InnoDB;

CREATE TABLE order_items (
    id         BIGINT         NOT NULL AUTO_INCREMENT,
    order_id   BIGINT         NOT NULL,
    product_id BIGINT         NOT NULL,
    quantity   INT            NOT NULL DEFAULT 1,
    unit_price DECIMAL(10, 2) NOT NULL,
    PRIMARY KEY (id),
    CONSTRAINT fk_item_order   FOREIGN KEY (order_id)   REFERENCES orders (id),
    CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES products (id)
) ENGINE=InnoDB;

CREATE TABLE reviews (
    id          BIGINT    NOT NULL AUTO_INCREMENT,
    product_id  BIGINT    NOT NULL,
    customer_id BIGINT    NOT NULL,
    rating      TINYINT   NOT NULL,
    comment     TEXT,
    created_at  DATETIME(6) NOT NULL,
    PRIMARY KEY (id),
    CONSTRAINT fk_review_product  FOREIGN KEY (product_id)  REFERENCES products (id),
    CONSTRAINT fk_review_customer FOREIGN KEY (customer_id) REFERENCES customers (id)
) ENGINE=InnoDB;

Each subsequent migration (V2, V3, …) adds columns, tables, or indexes as the series progresses.


SQL Logging in Development

Seeing Hibernate’s generated SQL is essential for learning and debugging. The show-sql setting logs raw SQL; format_sql=true pretty-prints it.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Output looks like:

Hibernate:
    select
        c1_0.id,
        c1_0.name,
        c1_0.email
    from
        customers c1_0
    where
        c1_0.email=?

For even more detail (including bind parameters), add:

logging.level.org.hibernate.orm.jdbc.bind=TRACE

This logs the actual values substituted for ? — invaluable during debugging:

binding parameter (1:VARCHAR) <- [alice@example.com]

Turn both off in production — SQL logging is a significant overhead in high-throughput applications.


Verifying the Setup

Create a minimal entity and repository to confirm everything works:

// src/main/java/com/devopsmonk/jpademo/domain/Category.java
package com.devopsmonk.jpademo.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;

@Entity
@Table(name = "categories")
@Getter @Setter @NoArgsConstructor
public class Category {

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

    private String name;
    private String slug;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
// src/main/java/com/devopsmonk/jpademo/repository/CategoryRepository.java
package com.devopsmonk.jpademo.repository;

import com.devopsmonk.jpademo.domain.Category;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CategoryRepository extends JpaRepository<Category, Long> {
}
// src/main/java/com/devopsmonk/jpademo/DemoRunner.java
package com.devopsmonk.jpademo;

import com.devopsmonk.jpademo.domain.Category;
import com.devopsmonk.jpademo.repository.CategoryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;

@Component
@RequiredArgsConstructor
@Slf4j
public class DemoRunner implements CommandLineRunner {

    private final CategoryRepository categoryRepository;

    @Override
    public void run(String... args) {
        Category category = new Category();
        category.setName("Electronics");
        category.setSlug("electronics");
        category.setCreatedAt(LocalDateTime.now());
        category.setUpdatedAt(LocalDateTime.now());

        Category saved = categoryRepository.save(category);
        log.info("Saved category with id={}", saved.getId());

        long count = categoryRepository.count();
        log.info("Total categories in database: {}", count);
    }
}

Run the application and you will see:

Hibernate:
    insert
    into
        categories
        (created_at, name, slug, updated_at)
    values
        (?, ?, ?, ?)
INFO  DemoRunner - Saved category with id=1
INFO  DemoRunner - Total categories in database: 1

Flyway creates the schema, Hibernate validates it matches the entity, and the repository saves the record — everything is wired up.


Docker Compose for Local MySQL

If you do not have MySQL installed locally, use Docker Compose:

# docker-compose.yml
version: "3.8"
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: jpa_demo
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

Start with docker-compose up -d before running the application.


Key Takeaways

  • spring-boot-starter-data-jpa pulls in everything: Spring Data JPA, Hibernate 6, the JPA API, and Spring ORM
  • Never use ddl-auto=update or ddl-auto=create in production — use validate with Flyway for schema management
  • HikariCP is the default connection pool; configure maximum-pool-size to match your thread model
  • show-sql=true and format_sql=true make Hibernate’s SQL visible during development — essential for learning and debugging
  • The Flyway db/migration directory is where all schema changes live — version-controlled SQL scripts

What’s Next

Article 3 explains the persistence context — the first-level cache that is the foundation of all JPA behaviour. Understanding it is required to understand everything else: dirty checking, lazy loading, transaction scope, and detached entity issues.