Spring Boot Course · Lesson 5
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.
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.
Three names show up the moment you persist data in Spring. They're often blurred together, so let's separate them once:
@Entity, @Id, …) describing how Java objects map to database rows. It's a contract, not an implementation.You'll mostly talk to the top layer (Spring Data) and let it drive the two below. One starter wires all three together.
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> 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.)
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.
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.
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:
no suitable constructor found for Book(UUID, String, String, int) — the entity dropped the record's 4-arg constructor; the id is now generated, not passed in.cannot find symbol: method title() / author() / year() / id() — record accessors became JavaBean getters (getTitle(), getId(), …).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.
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.
@Getter / @Setter — fine. (JPA needs to read and write fields.)@NoArgsConstructor(access = AccessLevel.PROTECTED) — JPA requires a no-arg constructor; protected keeps your own code from calling it by accident while still letting Hibernate in.@EqualsAndHashCode(onlyExplicitlyIncluded = true) + @EqualsAndHashCode.Include on the id — identity based on the primary key alone (more below).Avoid on entities:
@Data — it bundles an all-fields equals/hashCode and an unguarded toString, both dangerous here.@EqualsAndHashCode (all fields) — mutable fields in hashCode break the HashSet contract, and touching a lazy relation can fire surprise queries.@ToString — once relationships arrive (Lesson 6), it recurses across both sides and forces lazy loads. If you want one, exclude relationship 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.
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.
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.
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.
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.
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.)
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.
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.
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: 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.
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 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.
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.
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 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.
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.)
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.
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.
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}' ./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.
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.)
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."
port 5432 already in use / bind failed? Another Postgres (a local install, or a leftover container) holds that port. Stop it, or change the published port in compose.yaml (e.g. '5433:5432').Not a managed type: Book? The @Entity annotation is missing, or the class sits outside com.example.library where entity scanning reaches.Schema-validation: missing table/column on startup? Hibernate's validate found an entity field with no matching column — your Book and your changelog disagree. Add a changeset for the new field (don't edit an applied one). That error is the safety net working.checksum / validation failure? You edited a changeset that had already run. Revert it and add a new changeset instead — applied changesets are immutable.docker compose down -v — the -v deletes the named volume. A plain stop (which is what Spring Boot does) keeps it.InvalidDataAccessApiUsageException / detached entity on PUT? Make sure replace sets the id from the path before save, so Hibernate updates the right row instead of trying to insert a new one.Retrieval beats re-reading. Answer from memory before clicking.
BookRepository as an empty interface extending JpaRepository. Where does the implementation come from?Book-the-entity base equals/hashCode on the id alone, and why is that safe here??)? 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.