Hibernate will create an orders table from your entity on a laptop. That same application can then fail in CI because the column you added never existed as a migration. The local database was mutated by ddl-auto; the shared database was waiting for a SQL file nobody wrote.

This lab continues the orders service from REST API with Boot 3 + Records as DTOs and Spring Data JPA Footguns. Entity design, N+1, and transaction boundaries stay in that JPA post. Slice-test mechanics stay in Testing Slices + Testcontainers. The job here is one versioned SQL history as the schema owner.

Two tools cannot both own the schema

spring.jpa.hibernate.ddl-auto asks Hibernate to create, update, or drop tables from entity mappings. Flyway asks a directory of versioned SQL files to do the same job. When both write, the running database is a blend of “whatever Hibernate felt like” and “whatever Flyway already applied.”

Entity field added locally
        |
        +--> ddl-auto=update  --> laptop schema changes
        |
        +--> no V2__*.sql     --> production schema unchanged
        |
        v
    production insert/query fails

Two sources of schema truth drift. A Java field is not a schema change. A SQL file that was never applied is not a schema change either. The database other environments share is the one Flyway’s history table describes.

Hibernate remains useful: it maps rows to entities and can validate that the mapping matches the migrated schema. It should not be the writer of production DDL.

Stop the default ddl-auto roulette

Spring Boot’s default for ddl-auto depends on what it sees. An embedded database with no Flyway often gets create-drop. Detecting Flyway or Liquibase usually switches the default to none. Teams then override that in YAML, and the override is what actually ships.

This is the configuration that creates the roulette:

spring:
  jpa:
    hibernate:
      ddl-auto: update

update is convenient because adding a field appears to “just work.” It is also incomplete: it will not rename columns safely, will not drop unused columns in a reviewable way, and will not produce an auditable history. create and create-drop are worse outside a throwaway process—they destroy data.

Set a production-like default that refuses to mutate the schema from entities:

spring:
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true

none also prevents Hibernate from writing DDL. validate fails the context if the entity mapping does not match the migrated tables, which is the faster way to learn you forgot a script. Keep update and create-drop on a named local-experiment profile, not on the YAML every environment inherits.

Note: spring.flyway.enabled defaults to true once Flyway is on the classpath. Setting it explicitly documents the ownership rule: Flyway runs; Hibernate does not create tables.

Add Flyway next to JPA

This lab assumes Java 17+, Spring Boot 3.3+ (Flyway 10), Maven, and PostgreSQL. Keep spring-boot-starter-data-jpa and the PostgreSQL driver. Add Flyway’s core module plus the PostgreSQL database plugin that Flyway 10 requires:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
  </dependency>
  <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
  </dependency>
</dependencies>

Boot’s dependency management supplies compatible versions. On Spring Boot 3.2 / Flyway 9 the PostgreSQL module was not required. If startup fails because Flyway cannot resolve the database type, add flyway-database-postgresql.

Flyway looks on the classpath at db/migration by default. Versioned files use a capital V, a version, two underscores, a description, and .sql.

Create the orders table in V1

The REST lab’s order is an id, a customer, and a status. Put that shape in SQL first, then map the entity to the script. Create src/main/resources/db/migration/V1__create_orders.sql:

create table orders (
    id uuid primary key,
    customer_id varchar(64) not null,
    status varchar(32) not null
);

On startup Flyway creates flyway_schema_history if needed, applies every version that table does not already record, and then stops. Hibernate validate should now see orders with those three columns.

Do not edit V1__create_orders.sql after it has been applied anywhere shared. Flyway checksums the file. Changing an applied script fails startup on every database that already ran the old bytes. Add V2__...sql instead.

A later version is an additive script, not a rewrite of V1. Create src/main/resources/db/migration/V2__orders_add_notes.sql only when the column is a real change:

alter table orders
    add column notes varchar(255);

The JPA footguns lab used purchase_orders and order_lines. Same rule: those tables belong in versioned SQL, not in ddl-auto=update. This post keeps a single orders table so the ownership pattern stays visible.

Map the entity to the script

Keep the entity small. Persistence identity, relationships, and fetch plans are covered in the JPA footguns post. Here the entity exists so validate has something to check against V1.

package com.geekmonks.orders.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;

@Entity
@Table(name = "orders")
public class Order {

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

    @Column(name = "customer_id", nullable = false, length = 64)
    private String customerId;

    @Column(nullable = false, length = 32)
    private String status;

    protected Order() {
        // Required by JPA.
    }

    public Order(String customerId, String status) {
        this.customerId = customerId;
        this.status = status;
    }

    public UUID getId() {
        return id;
    }

    public String getCustomerId() {
        return customerId;
    }

    public String getStatus() {
        return status;
    }
}

Spring Boot’s physical naming strategy already maps customerId to customer_id. The explicit @Column names make the contract with V1 obvious when you read the class next to the SQL. Leave notes off the entity until V2 exists; adding a Java field first is how validate is supposed to fail.

A repository is enough for the test later:

package com.geekmonks.orders.persistence;

import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, UUID> {

    Optional<Order> findByCustomerId(String customerId);
}

If you add notes to the entity and forget V2, validate fails at context startup. If you add V2 and forget the entity field, the extra column usually survives validate—Hibernate does not require every database column to be mapped. The dangerous direction is the one ddl-auto=update hides: a Java field with no matching migration.

Keep experiments off the default profile

Local spikes still need a disposable schema sometimes. Give that behavior a profile name that cannot silently become production. Default YAML stays Flyway-owned:

# src/main/resources/application.yml
spring:
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true

The experiment profile is the only place Hibernate may create tables, and Flyway stays off so the two writers do not compete:

# src/main/resources/application-experiment.yml
spring:
  jpa:
    hibernate:
      ddl-auto: create-drop
  flyway:
    enabled: false

Activate it only for a throwaway run:

./mvnw spring-boot:run -Dspring-boot.run.profiles=experiment

That profile is a sandbox. It must not be the YAML your tests, CI, or production inherit. Those environments need the same Flyway scripts the next developer will run.

Note: Mixing ddl-auto=update with Flyway enabled is the worst combination. Flyway records V1 as applied, Hibernate then alters extra columns, and the next migration can fail because the live schema no longer matches what V1 described.

Prove Flyway creates the table in the JPA slice

The testing post’s repository lab let Hibernate create the schema. Its drill 3 asked you to stop doing that: add a Flyway migration and run it against a container. That post used a posts table. The pattern is identical for orders.

@DataJpaTest loads entities, repositories, and Hibernate. It does not treat Flyway as part of the slice by default, and it replaces the DataSource with an embedded database unless you stop it. Both defaults would let Hibernate create tables again and skip your SQL.

Keep the Testcontainers test dependencies from that post. Keep the slice, attach PostgreSQL with @ServiceConnection, disable replacement, import Flyway, and validate:

package com.geekmonks.orders.persistence;

import static org.assertj.core.api.Assertions.assertThat;

import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.Autowired;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.ImportAutoConfiguration;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@DataJpaTest(properties = {
        "spring.jpa.hibernate.ddl-auto=validate",
        "spring.flyway.enabled=true"
})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ImportAutoConfiguration(FlywayAutoConfiguration.class)
@Testcontainers
class OrderRepositoryFlywayTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:17-alpine");

    @Autowired
    OrderRepository orders;

    @Autowired
    Flyway flyway;

    @Test
    void flywayCreatesOrdersAndHibernateValidates() {
        assertThat(flyway.info().applied()).isNotEmpty();
        assertThat(flyway.info().applied())
                .extracting(info -> info.getScript())
                .anyMatch(script -> script.contains("create_orders"));

        var saved = orders.saveAndFlush(
                new Order("customer-42", "OPEN"));

        assertThat(orders.findByCustomerId("customer-42"))
                .isPresent()
                .get()
                .extracting(Order::getId, Order::getStatus)
                .containsExactly(saved.getId(), "OPEN");
    }
}

Flyway runs once when the test DataSource starts, before the test method. @DataJpaTest still wraps the method in a rollback transaction, so inserted orders do not leak between tests. flyway_schema_history is created at startup, outside that rollback.

The Flyway bean proves V1__create_orders.sql ran. Saving an Order proves Hibernate’s mapping matches the migrated table. That is the assertion ddl-auto=create-drop never made.

The testing post already covered Testcontainers dependencies, @ServiceConnection, and why H2 is not PostgreSQL. Reuse that setup; do not switch this test back to an embedded database.

Run only this class:

./mvnw -Dtest=OrderRepositoryFlywayTest test

A first run pulls postgres:17-alpine. Later runs reuse the image. The schema still comes from Flyway, not from Hibernate.

If you add a notes field to Order and rerun without V2, startup should fail during schema validation:

Schema-validation: missing column [notes] in table [orders]

Comment out the migration file instead, and validate cannot find orders at all. Either failure is the point of the lab: the slice is no longer inventing a table to keep the test green.

Note: A green @DataJpaTest with the slice’s default ddl-auto does not prove V1__create_orders.sql. Import Flyway and set validate (or none) so a missing script cannot hide.

Close the posts homework with the same wiring

Drill 3 in the testing post was: add a Flyway migration for the posts table and configure a container-backed test to run it. You do not need a second mental model.

create table posts (
    id bigserial primary key,
    title varchar(255) not null
);

If orders and posts live in one application, they share one history. Use distinct versions (V1, V2, …) rather than two files both named V1. If they are separate apps, each app has its own db/migration and its own flyway_schema_history.

The test class is the orders test with Post / PostRepository and this wiring:

@DataJpaTest + Replace.NONE
  + @ImportAutoConfiguration(FlywayAutoConfiguration.class)
  + ddl-auto=validate
  + Testcontainers PostgreSQL @ServiceConnection

That is the whole homework. Hibernate no longer creates posts; the SQL file does.

Schema-ownership cheat sheet

Use this as the default for any Spring Boot service that persists with JPA:

Own the schema with Flyway:
  src/main/resources/db/migration/V1__create_orders.sql
  never edit an applied version; add V2
  spring.flyway.enabled=true

Hibernate maps, it does not invent tables:
  ddl-auto=validate  (or none) in default YAML
  ddl-auto=update / create-drop only on an experiment profile
  do not combine update with Flyway

Prove it in the JPA slice:
  @DataJpaTest
  @AutoConfigureTestDatabase(replace = NONE)
  @ImportAutoConfiguration(FlywayAutoConfiguration.class)
  Testcontainers + @ServiceConnection
  assert Flyway applied the script, then save an entity

Do / don’t:

DoDon’t
Write DDL as versioned SQLLet ddl-auto=update be the production schema
Fail startup with validate when mapping driftsTrust a green H2 test that never ran Flyway
Add V2 after a shared V1Edit checksummed files that already applied
Import Flyway into @DataJpaTestAssume the JPA slice runs migrations by default

Wrap-up

ddl-auto is a local experiment, not a release process. Flyway SQL is the reviewable history of the orders table: V1 creates it, later versions change it, and flyway_schema_history records what each database has applied. Hibernate’s job in that world is to map entities and to validate that the mapping still matches.

The testing-post homework closes the same way. @DataJpaTest stays a persistence slice; Testcontainers still supplies PostgreSQL; Flyway, not Hibernate, creates the table. When that test is green, the script you will run in production has already been executed.

Next optional step Stop querying for the same order twice with @Cacheable and Caffeine. Spring Cache