Spring Boot Course · Lesson 5

Persistence with Spring Data JPA

Your books live in a ConcurrentHashMap that vanishes the moment the app restarts. Time to give them a real home. In this lesson you'll add a database, turn Book into a persistent entity, and replace your hand-written store with a repository Spring Data writes for you — most of it without a single line of SQL.

The win The same CRUD endpoints from Lesson 4, now backed by a real PostgreSQL database that Spring Boot spins up for you in Docker, with its schema version-controlled by Liquibase migrations. You'll define BookRepository as an empty interface and get save, findById, findAll, and delete for free — plus a custom search query you create just by naming a method. The web and service layers barely change: the payoff of the layering you built earlier.

The cast: JPA, Hibernate, Spring Data

Three names show up the moment you persist data in Spring. They're often blurred together, so let's separate them once:

You'll mostly talk to the top layer (Spring Data) and let it drive the two below. One starter wires all three together.

Step 1 — Add the dependencies

Open pom.xml and add: the JPA starter, the PostgreSQL driver, Liquibase (for schema migrations), Spring Boot's Docker Compose support, Lombok (to keep the entity tidy), and a UUIDv7 generator.

pom.xml — inside <dependencies>

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

<!-- PostgreSQL JDBC driver (used at runtime to talk to the database) -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- Liquibase migrations. The autoconfiguration lives in this module (it brings
     liquibase-core transitively); the bare org.liquibase:liquibase-core won't activate it. -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-liquibase</artifactId>
</dependency>

<!-- Starts/stops the Postgres container from compose.yaml when the app runs -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-docker-compose</artifactId>
    <optional>true</optional>
</dependency>

<!-- Lombok: generates getters/constructors/equals at compile time -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<!-- uuid-creator: time-ordered UUIDv7 generation -->
<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>uuid-creator</artifactId>
    <version>6.0.0</version>  <!-- or the latest on Maven Central -->
</dependency>
Why PostgreSQL — and why Docker Compose? PostgreSQL is the database you'll actually meet on the job: a robust, open-source SQL server that companies run in production. It's a separate server process that writes to disk, so your data survives restarts — no more books vanishing when you stop the app, the way Lesson 4's in-memory map did. Rather than install and manage that server by hand, you'll describe it once in a Docker Compose file and let Spring Boot's spring-boot-docker-compose support start the container for you when the app boots and stop it when the app exits. The compose file is itself a portfolio asset — it's exactly how a real team pins the database their app needs. (This needs Docker Desktop running.)
A word on Lombok Lombok generates boilerplate — getters, constructors, equals/hashCode — from annotations at compile time, so your entity reads as just its fields. It's ubiquitous in Spring codebases, which is why we adopt it here. Two setup notes: keep the dependency <optional>true</optional> (it's a compile-time tool, not a runtime library), and install the Lombok plugin in your IDE so it understands the generated methods.

Step 2 — Turn Book into an entity

Here's the first real change to your model. Since Lesson 2, Book has been a record — a perfect immutable data carrier. But a JPA entity can't be a record: Hibernate needs a no-arg constructor, mutable fields it can populate, and a non-final class it can subclass with a proxy. So the entity becomes a class. (Records aren't gone — they return in Lesson 6 as DTOs, the immutable shape of your API.)

src/main/java/com/example/library/book/Book.java

package com.example.library.book;

import java.util.UUID;
import com.github.f4b6a3.uuid.UuidCreator;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)   // JPA needs it; protected discourages app code from using it
@EqualsAndHashCode(onlyExplicitlyIncluded = true)    // identity is the id, and ONLY the id
public class Book {

    @Id
    @EqualsAndHashCode.Include
    private UUID id = UuidCreator.getTimeOrderedEpoch();   // a fresh UUIDv7, set at construction

    private String title;
    private String author;
    private int year;

    // App-facing constructor: supply the data; the id defaults to a new UUIDv7 above.
    public Book(String title, String author, int year) {
        this.title = title;
        this.author = author;
        this.year = year;
    }
}

@Entity tells JPA "map this class to a database table." @Id marks the primary key. The field initializer assigns a time-ordered UUIDv7 the instant a Book is constructed — so every entity has a stable, non-null id from birth, before it ever touches the database.

Heads-up: the project won't compile until Step 4 — and that's expected You just changed Book's shape, which breaks the Lesson 4 code that relied on the old record API. Compile right now and you'll see a wall of errors in BookService and BookController — these two kinds:

Don't patch these by hand — the next steps replace the offending code. Step 3 adds the repository, Step 4 rewrites BookService around it (no more new Book(uuid, …) or fluent accessors), and there you'll also swap the controller's saved.id() for saved.getId(). Run ./mvnw compile after Step 4, not between steps.

Why not call it BookEntity? A common instinct is to suffix the class — BookEntity — but resist it. The entity is your domain model, the central Book concept the whole app is about; Entity is a persistence detail, and baking it into the name leaks a technical concern into your vocabulary (you wouldn't call it BookTable either). The @Entity annotation already says it's persisted. Idiomatic Spring and domain-driven design name entities after the plain domain noun — Book, Author, Customer.

The real "is this the database object or the API object?" question gets a cleaner answer on the other side, in Lesson 6: the DTOs take role-based names that actually carry meaning — BookResponse (what you send back), CreateBookRequest (what you accept on POST, with no id to smuggle). Those suffixes tell you purpose and direction; BookEntity would only repeat what the annotation says. So: Book stays the entity, and the records arrive next lesson with descriptive names.

Lombok on a JPA entity: which annotations are safe? Entities have sharp edges that the convenient all-in-one Lombok annotations cut you on. The safe set:

Avoid on entities:

Why all-fields equals/hashCode is dangerous here Hash-based collections rely on one rule: an object's hashCode must not change while it sits in the set. A default, all-fields hashCode reads mutable fields — and JPA mutates entities constantly — so it's a moving target:
Book b = new Book("Clean Code", "Robert C. Martin", 2008);
Set<Book> shelf = new HashSet<>();
shelf.add(b);        // hash from title+author+year → bucket A
b.setYear(2009);     // a fix; hash changes → "should" be bucket B
shelf.contains(b);   // false!  it's in bucket A; we looked in B

The element is effectively lost — remove can't find it either, and logical duplicates can creep in. There's a second trap once relationships arrive (Lesson 6): an all-fields equals also reads lazy association fields, so merely comparing two books fires a surprise SELECT (an accidental N+1) — or throws LazyInitializationException if the Hibernate session has already closed. Basing identity on the immutable, always-present id (next callout) sidesteps both.

Why identity = the id (and why UUIDv7 makes this clean) Two Book objects represent the same book if they share a primary key — not if their titles happen to match. So equals/hashCode should use the id and nothing else. The classic JPA trap: if the database generates the id at insert time, a brand-new entity has a null id, two unsaved entities look "equal," and an entity's identity changes the moment it's saved — quietly corrupting any HashSet it's in. Because we assign a UUIDv7 in the constructor, the id is never null and never changes. Id-based equality just works, even for an entity that hasn't been saved yet. That's a concrete payoff of choosing app-generated UUIDs over a database auto-increment.
"Same id but different fields — won't they be wrongly 'equal'?" Yes they'll compare equal, and that's the correct answer. For an entity, equals asks "are these the same thing?", not "do these have identical contents?". Editing a book's title doesn't make it a different book — same id, same row, same entity; the differing fields just mean one reference is a fresher snapshot than the other. And because the id is a unique primary key, two objects with the same id are always the same book — never two distinct books that happened to collide. (Contrast a record/DTO, a value object with no identity, where equality rightly is all-fields.) If you ever need the other question — "do these two snapshots hold the same values?" — that's a separate, explicit content comparison, never something you'd fold into equals.

Step 3 — Declare the repository

This is the part that feels like magic the first time. You write an interface — no implementation — and Spring Data provides a working one at runtime:

src/main/java/com/example/library/book/BookRepository.java

package com.example.library.book;

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

public interface BookRepository extends JpaRepository<Book, UUID> {

    // A derived query: Spring Data reads the method NAME and writes the SQL.
    List<Book> findByAuthor(String author);
}

JpaRepository<Book, UUID> says "a repository of Book entities whose id type is UUID." That single line of inheritance hands you save, findById, findAll, deleteById, existsById, count, and more — all implemented. You only add method signatures for the queries unique to your domain.

Derived queries: a method name is the query findByAuthor(String author) isn't a method you implement — it's a method you name. Spring Data parses the name (findBy + Author) into SELECT * FROM book WHERE author = ?. The vocabulary is rich: findByTitleContainingIgnoreCase, findByYearGreaterThanEqual, findByAuthorOrderByYearDesc, countByAuthor. When a query gets too gnarly to express as a name, you drop to @Query with explicit JPQL or SQL — but you'll be surprised how far names alone carry you.

Step 4 — Point the service at the repository

Now retire the ConcurrentHashMap. The service stops being the storage and starts delegating to it — exactly the relationship the controller already has with the service. Notice it still injects its dependency through the constructor (Lesson 3), and its method signatures don't change at all:

src/main/java/com/example/library/book/BookService.java

package com.example.library.book;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import com.github.f4b6a3.uuid.UuidCreator;
import org.springframework.stereotype.Service;

@Service
public class BookService {

    private final BookRepository books;

    public BookService(BookRepository books) {   // a repository bean, injected
        this.books = books;
    }

    public List<Book> findAll() {
        return books.findAll();
    }

    public Optional<Book> findById(UUID id) {
        return books.findById(id);
    }

    public Book create(Book incoming) {
        incoming.setId(UuidCreator.getTimeOrderedEpoch());   // the server owns identity — never trust a client-sent id
        return books.save(incoming);
    }

    public Optional<Book> replace(UUID id, Book incoming) {
        if (!books.existsById(id)) {
            return Optional.empty();
        }
        incoming.setId(id);                  // pin the path id, then upsert
        return Optional.of(books.save(incoming));
    }

    public boolean delete(UUID id) {
        if (!books.existsById(id)) {
            return false;
        }
        books.deleteById(id);
        return true;
    }
}

That's the data layer done. One mechanical edit remains — and it's the only change the controller needs. Because Book is a class now, the create method reads the new id with the getter getId() instead of the record accessor id(). Update that one line:

src/main/java/com/example/library/book/BookController.java

@PostMapping                                // POST /api/books
public ResponseEntity<Book> create(@RequestBody Book incoming) {
    Book saved = books.create(incoming);
    URI location = URI.create("/api/books/" + saved.getId());   // was saved.id()
    return ResponseEntity.created(location).body(saved);
}

Miss this and the build stops with cannot find symbol: method id() — the record's id() is gone, replaced by the getter. (Same applies to any other .title()/.author()/.year() you might have: they're .getTitle()/etc. now.)

Look what didn't change: the layering paying off You just swapped your entire storage mechanism — from an in-memory map to a SQL database — and the controller from Lesson 4 barely moved: that single accessor was its only edit. The service's public methods kept their shapes (findAll, findById, create, replace, delete), so the web layer's logic never noticed the storage swap underneath it. That is why we separated the layers in Lesson 3 — a change in the data layer stops at the service's door.
Why force a new id in create? Because @RequestBody deserializes straight into a Book, a client could smuggle in an id and overwrite an existing row via save (an upsert). Resetting the id server-side slams that door. It's a stopgap: in Lesson 6 a dedicated create DTO won't even have an id field, so there's nothing to smuggle — the cleaner fix.

Step 5 — Describe the database in compose.yaml

Create a compose.yaml in the project root (next to pom.xml). It declares one service — a Postgres container — with a database name, a user, a password, and a named volume so the data survives between runs:

compose.yaml

services:
  postgres:
    image: 'postgres:18'
    environment:
      - 'POSTGRES_DB=library'
      - 'POSTGRES_USER=library'
      - 'POSTGRES_PASSWORD=library'
    ports:
      - '5432:5432'          # expose the DB on localhost so psql can reach it
    volumes:
      - 'library-data:/var/lib/postgresql'   # persist data across restarts

volumes:
  library-data:
The magic: Spring Boot wires the datasource from the container Notice what's not in your config: a JDBC URL, username, or password. Because spring-boot-docker-compose is on the classpath, at startup Spring Boot finds compose.yaml, starts the Postgres service, reads the POSTGRES_DB/USER/PASSWORD values straight off the running container, and configures the datasource for you. One source of truth (the compose file), zero connection strings to keep in sync. When the app stops, Spring Boot stops the container — but the named volume keeps your rows for next time.
This is for development only — not production The spring-boot-docker-compose support runs only during local development. Spring Boot marks it optional and excludes it from the production jar, so when you run the built jar with java -jar the module isn't even on the classpath — there is nothing to activate. You won't accidentally start a container in production.

In production you don't start Postgres from compose.yaml at all — you point the app at a managed database (RDS, Cloud SQL, a server your team runs) with ordinary settings, supplied as environment variables or an application-prod.properties:

spring.datasource.url=jdbc:postgresql://db.internal:5432/library
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}

So the same app talks to a throwaway container on your laptop and to real infrastructure in production — the difference is just configuration. The compose.yaml stays a dev-repo convenience and isn't shipped.

So application.properties stays tiny. Liquibase owns the schema (Step 6), so Hibernate must not touch it — validate tells Hibernate to only check that your entities match the tables Liquibase built, and change nothing:

src/main/resources/application.properties

# Hibernate never alters the schema - Liquibase owns it. Just verify entities match the tables.
spring.jpa.hibernate.ddl-auto=validate
Aside: the spring.jpa.show-sql you'll see elsewhere Most tutorials add spring.jpa.show-sql=true here — it echoes the SQL Hibernate generates so you can see what your code triggers. We deliberately leave it off, and it's worth knowing why. It prints straight to System.out (bypassing your logger), unformatted, and without the bound parameter values — you see where id=?, never the actual id. When you do want to watch queries, route them through logging instead, which is formatted and shows the real values:
# Log SQL through SLF4J, pretty-printed, with bound parameter values
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
spring.jpa.properties.hibernate.format_sql=true
Either way, SQL logging is a development aid — keep it off in production, where it's pure noise and overhead.
A bonus from Postgres: a real uuid column Postgres has a native uuid type, so your @Id UUID maps to an actual uuid column — stored compactly as 16 bytes, not as text. Your UUIDv7 ids land in the database as first-class values, and because they're time-ordered, new rows insert near the end of the primary-key index instead of scattering across it.

Step 6 — Put the schema under version control with Liquibase

Building the schema is Liquibase's job, not Hibernate's. You describe each schema change as a changeset inside a changelog, and Liquibase applies them in order, exactly once each, recording what it has run — so your schema becomes versioned, reviewable code you can replay on any fresh database. (That's the point of the validate setting from Step 5: Hibernate verifies your entities match the schema Liquibase built, rather than creating or altering anything itself.)

Spring Boot runs Liquibase automatically on startup, looking for a master changelog at a conventional path. Create it — it's just an index that includes the real change files:

src/main/resources/db/changelog/db.changelog-master.yaml

databaseChangeLog:
  - include:
      file: changes/001-create-book-table.yaml
      relativeToChangelogFile: true

Now the first migration — it creates the book table your entity maps to:

src/main/resources/db/changelog/changes/001-create-book-table.yaml

databaseChangeLog:
  - changeSet:
      id: 001-create-book-table
      author: roman
      changes:
        - createTable:
            tableName: book
            columns:
              - column:
                  name: id
                  type: uuid
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: title
                  type: varchar(255)
              - column:
                  name: author
                  type: varchar(255)
              - column:
                  name: year
                  type: int
How a changeset works — and the one rule Each changeSet is identified by its id + author. On startup Liquibase reads a tracking table it keeps in your database (databasechangelog), sees which changesets have already run, and applies only the new ones. The cardinal rule: never edit a changeset that has already been applied. Once it has run anywhere, it's history — to change the schema you add a new changeset (say 002-add-isbn-column.yaml). That append-only discipline is what lets a teammate — or a fresh production database — replay your changelog from empty and arrive at exactly the right schema.
Why validate is a safety net, not red tape Because validate makes Hibernate check the schema against your entities, the two have to agree. Add a field to Book but forget the matching changeset, and the app refuses to start with a clear validation error — Hibernate caught the drift before a single request ran. Two sources that must stay in sync (Java entity, SQL schema), with a startup-time guard that they do. (Prefer no check at all? ddl-auto=none turns it off — but you lose the safety net.)
Why YAML — and the alternatives Liquibase changelogs come in YAML, XML, JSON, or formatted SQL. We use YAML for its readable, database-agnostic change types (createTable, addColumn, …), which Liquibase translates to the right SQL per database. If you'd rather write the exact SQL yourself — you know it well, and we've committed to Postgres anyway — a formatted SQL changelog lets you do that with --changeset comment markers. Same engine; pick portability or raw SQL.

Step 7 — (Optional) seed some books

Because the database is empty the first time you start it, GET /api/books returns [] until you add something. To put the familiar three classics in on startup, add a tiny seeder — a bean that runs when the app boots. The count() > 0 guard means it seeds only once: on later runs your persisted data is already there, so it does nothing.

src/main/java/com/example/library/book/BookSeeder.java

package com.example.library.book;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class BookSeeder implements CommandLineRunner {

    private final BookRepository books;

    public BookSeeder(BookRepository books) {
        this.books = books;
    }

    @Override
    public void run(String... args) {
        if (books.count() > 0) return;   // don't double-seed
        books.save(new Book("Clean Code", "Robert C. Martin", 2008));
        books.save(new Book("Effective Java", "Joshua Bloch", 2018));
        books.save(new Book("The Pragmatic Programmer", "Hunt & Thomas", 1999));
    }
}

CommandLineRunner is a Spring hook: any bean implementing it has its run method called once, after the context is ready. Each new Book(...) gets its own UUIDv7 from the constructor — so the ids are real, time-ordered, and assigned before save ever runs.

Step 8 — Run it and watch it boot

One bit of config first: run the app in UTC. Running a backend in UTC is standard practice — timestamps and logs mean the same thing no matter where the machine is — and it sidesteps host-timezone surprises (some OS zone names trip up the JDBC connection on newer Postgres). Set it once in the Spring Boot Maven plugin so every ./mvnw spring-boot:run uses it:

pom.xml — configure the existing spring-boot-maven-plugin

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <jvmArguments>-Duser.timezone=UTC</jvmArguments>
    </configuration>
</plugin>

Now make sure Docker Desktop is running, then start the app (./mvnw spring-boot:run). Watch the order in the console: Spring Boot brings up the Postgres container and connects, Liquibase applies 001-create-book-table (creating the table), Hibernate then validates that Book matches it, and finally the seeder inserts. Every later run, Liquibase sees the changeset already applied and skips it. Now exercise it:

# List the seeded books — now coming from a real SQL query.
curl -i http://localhost:8080/api/books

# Add one. Hibernate runs an INSERT; the response shows the server-assigned UUIDv7 id.
curl -i -X POST http://localhost:8080/api/books \
  -H "Content-Type: application/json" \
  -d '{"title":"Refactoring","author":"Martin Fowler","year":2018}'
Building with ./mvnw package? Park the default test for now spring-boot:run just runs the app, but ./mvnw package (or test) also runs the test phase — and the project's generated contextLoads test is a @SpringBootTest that boots the whole context, database and all. The Docker Compose support is deliberately skipped during tests, so the test has no database and the build fails with Failed to determine a suitable driver class. Giving tests their own database is a topic of its own (Testcontainers, in the testing lesson), so for now just disable that placeholder:
@SpringBootTest
@Disabled("Needs a test database; wired up with Testcontainers in the testing lesson.")
class LibraryApplicationTests {
    @Test void contextLoads() { }
}
You'll delete that annotation and write real tests against a throwaway Postgres later — this is a deferral, not a shortcut.
Peek inside the database The container is already running (Spring Boot started it), so connect with psql and look at your rows directly:
# Connect through the published port with the local psql client…
PGPASSWORD=library psql -h localhost -p 5432 -U library -d library -c 'SELECT * FROM book;'

# …or exec psql inside the container — no password needed (local socket connections are trusted):
docker compose exec postgres psql -U library -d library -c 'SELECT * FROM book;'
You're looking at your actual rows — UUIDv7 ids and all. Seeing the table makes "persistence" stop being abstract. (Prefer a GUI? Any Postgres client — DBeaver, TablePlus, the IntelliJ database tool — connects to localhost:5432 with user/password/db all library.)

Step 9 — Use the derived query: search by author

You added findByAuthor to the repository; let's expose it. Rather than a new endpoint, take an optional query parameter on the existing collection route. But where does the decision — "filter, or return everything?" — belong? Not the controller. Choosing which query to run based on the input is business logic, so it lives in the service; the controller just passes the parameter along. Add the method to the service:

src/main/java/com/example/library/book/BookService.java

// Optionally filter by author — the "which query?" decision lives here, not in the controller.
public List<Book> findAll(String author) {
    return (author == null) ? findAll() : books.findByAuthor(author);
}

Now the controller stays a thin translator: read the optional query parameter, hand it to the service, return the result. @RequestParam binds a ?author=... value from the URL's query string:

@GetMapping                                          // GET /api/books  or  /api/books?author=...
public List<Book> all(@RequestParam(required = false) String author) {
    return books.findAll(author);                    // books = the BookService
}

With required = false, the parameter is optional: no ?author= means "list everything," while ?author=Joshua%20Bloch runs your derived query. One endpoint, two behaviours, zero SQL. Restart the app and try both:

# No filter — every book
curl -i http://localhost:8080/api/books

# Filtered — just this author, via your derived query
curl -i "http://localhost:8080/api/books?author=Joshua%20Bloch"
@PathVariable vs @RequestParam Both pull values out of the URL, but from different places. @PathVariable reads a segment of the path that identifies a resource (/api/books/{id}). @RequestParam reads a key from the query string after the ? (?author=…), typically for filtering, sorting, or paging a collection. Rule of thumb: path for "which resource," query string for "how to filter the list."
If it doesn't work

Check yourself

Retrieval beats re-reading. Answer from memory before clicking.

What's the relationship between JPA, Hibernate, and Spring Data JPA?
You wrote BookRepository as an empty interface extending JpaRepository. Where does the implementation come from?
Why does Book-the-entity base equals/hashCode on the id alone, and why is that safe here?
Which retrieves a value from the query string (the part after ?)?

Read this next

Primary source: the Spring reference on Defining Query Methods — the official rules for how a repository method name becomes a query. Skim the keyword tables (Containing, GreaterThan, OrderBy, …); they're the toolbox you'll reach into constantly.

New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.