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.
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.
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.
From this lesson on, the database runs in a Docker container that Spring Boot starts for you. That means the Docker daemon has to be installed and running on your machine before the app will boot. Check once:
docker --versiondocker compose versiondocker run --rm hello-world # proves the daemon is up and you're allowed to talk to itIf any of those fail, set Docker up for your OS:
macOS
Install Docker Desktop, launch it, and wait for the whale icon in the menu bar to report "Docker Desktop is running". Docker Desktop bundles the Compose plugin, so the three checks above should now pass in a fresh terminal.
Ubuntu / Debian
Install Docker Engine and the Compose plugin from Docker's own apt repository (Ubuntu's docker.io package lags behind and lacks the plugin): [docs]
# 1. Add Docker's apt repositorysudo apt-get updatesudo apt-get install -y ca-certificates curlsudo install -m 0755 -d /etc/apt/keyringssudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.ascsudo chmod a+r /etc/apt/keyrings/docker.ascecho "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ | sudo tee /etc/apt/sources.list.d/docker.list > /dev/nullsudo apt-get update
# 2. Install the engine, CLI and Compose pluginsudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# 3. Let your user run docker without sudo, then log out and back in for it to take effectsudo usermod -aG docker "$USER"The daemon starts automatically on a normal Ubuntu install (it's a systemd service). Until you log out and back in after step 3, docker commands will fail with permission denied on the Docker socket — that's the group change not being picked up yet, not a broken install.
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.
37 collapsed lines
<?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>4.1.1</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>library</artifactId> <version>0.0.1-SNAPSHOT</version> <name>library</name> <description/> <url/> <licenses> <license/> </licenses> <developers> <developer/> </developers> <scm> <connection/> <developerConnection/> <tag/> <url/> </scm> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency>
<!-- 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>17 collapsed lines
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc-test</artifactId> <scope>test</scope> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
</project>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.)
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.
Book into an entityHere'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.)
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 idpublic 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 // (a default, not a lock - @Setter gives you setId(...) for when a caller must pin it). 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. That generated id is a default, not a lock: because the class has @Setter, setId(...) exists for the cases where the caller must control the id — the service pins the path id on PUT in Step 4, and a test can give an entity a known id (book.setId(UUID.fromString("1111…"))) to assert against.
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:
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.
Entities have sharp edges that the convenient all-in-one Lombok annotations cut you on. The safe set:
@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 walks into the lazy authors collection and forces a load — or throws outside a session. If you want one, exclude relationship fields.equals/hashCode is dangerous hereHash-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 Ab.setYear(2009); // a fix; hash changes → "should" be bucket Bshelf.contains(b); // false! it's in bucket A; we looked in BThe 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.
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.
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.
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:
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:
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;
@Servicepublic 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:
37 collapsed lines
package com.example.library.book;
import java.net.URI;import java.util.List;import java.util.UUID;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.DeleteMapping;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.PutMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;
@RestController@RequestMapping("/api/books")public class BookController {
private final BookService books;
public BookController(BookService books) { this.books = books; }
@GetMapping // GET /api/books public List<Book> all() { return books.findAll(); }
@GetMapping("/{id}") // GET /api/books/{id} public ResponseEntity<Book> byId(@PathVariable UUID id) { return books.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); }
@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); }14 collapsed lines
@PutMapping("/{id}") // PUT /api/books/{id} public ResponseEntity<Book> replace(@PathVariable UUID id, @RequestBody Book incoming) { return books.replace(id, incoming) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); }
@DeleteMapping("/{id}") // DELETE /api/books/{id} public ResponseEntity<Void> delete(@PathVariable UUID id) { return books.delete(id) ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); }}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.)
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.
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.yamlCreate 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:
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: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.
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/libraryspring.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:
# Hibernate never alters the schema - Liquibase owns it. Just verify entities match the tables.spring.jpa.hibernate.ddl-auto=validatespring.jpa.show-sql you'll see elsewhereThere is a one-line setting, spring.jpa.show-sql=true, that 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:
# Hibernate never alters the schema - Liquibase owns it. Just verify entities match the tables.spring.jpa.hibernate.ddl-auto=validate
# Log SQL through SLF4J, pretty-printed, with bound parameter valueslogging.level.org.hibernate.SQL=DEBUGlogging.level.org.hibernate.orm.jdbc.bind=TRACEspring.jpa.properties.hibernate.format_sql=trueEither way, SQL logging is a development aid — keep it off in production, where it's pure noise and overhead.
uuid columnPostgres 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:
databaseChangeLog: - include: file: changes/001-create-book-table.yaml relativeToChangelogFile: trueNow the first migration — it creates the book table your entity maps to:
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: intEach 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 tapeBecause 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.)
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.
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.
package com.example.library.book;
import org.springframework.boot.CommandLineRunner;import org.springframework.stereotype.Component;
@Componentpublic 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("Domain-Driven Design", "Eric Evans", 2003)); }}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.
A CommandLineRunner runs on every start, including a production deploy — and the count() > 0 guard only protects a database that already has rows, not an empty one being brought up for real. The idiomatic fence is a Spring profile: annotate the seeder with @Profile("dev") and Spring creates the bean only when that profile is active. Locally you turn it on with spring.profiles.active=dev (in application.properties, or as an environment variable); in production you simply don't, and the seeder never exists. We keep the seeder unguarded in this lesson to stay focused on persistence — profiles get their proper treatment when we prepare the app for deployment.
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 as the very first thing main does, before Spring starts, so it holds no matter how the app is launched — ./mvnw spring-boot:run, the Run button in your IDE, or java -jar in production:
package com.example.library;
import java.util.TimeZone;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic class LibraryApplication {
public static void main(String[] args) { // Backend runs in UTC, whichever launcher started it (Maven, IDE, java -jar). TimeZone.setDefault(TimeZone.getTimeZone("UTC")); SpringApplication.run(LibraryApplication.class, args); }}The usual alternative is -Duser.timezone=UTC on the JVM command line — but a flag lives in whichever launcher you put it in. Set it in the Maven plugin and it applies to ./mvnw spring-boot:run only; press Run in IntelliJ and the app starts on your machine's zone, and the exact JDBC error the setting was meant to avoid is back. One line in main runs before anything else and covers every launcher. (Note main doesn't run during tests — when the testing lessons connect to a real database, they'll set the same zone for the test JVM.)
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 nowspring-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:
package com.example.library;
import org.junit.jupiter.api.Disabled; // newimport org.junit.jupiter.api.Test;import org.springframework.boot.test.context.SpringBootTest;
@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.
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.)
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:
21 collapsed lines
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;
@Servicepublic class BookService {
private final BookRepository books;
public BookService(BookRepository books) { // a repository bean, injected this.books = books; }
public List<Book> findAll() { return books.findAll(); }
// 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); }25 collapsed lines
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; }}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:
13 collapsed lines
package com.example.library.book;
import java.net.URI;import java.util.List;import java.util.UUID;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.DeleteMapping;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.PutMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;12 collapsed lines
import org.springframework.web.bind.annotation.RestController;
@RestController@RequestMapping("/api/books")public class BookController {
private final BookService books;
public BookController(BookService books) { this.books = books; }
@GetMapping // GET /api/books or /api/books?author=... public List<Book> all(@RequestParam(required = false) String author) { return books.findAll(author); // books = the BookService }28 collapsed lines
@GetMapping("/{id}") // GET /api/books/{id} public ResponseEntity<Book> byId(@PathVariable UUID id) { return books.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); }
@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); }
@PutMapping("/{id}") // PUT /api/books/{id} public ResponseEntity<Book> replace(@PathVariable UUID id, @RequestBody Book incoming) { return books.replace(id, incoming) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); }
@DeleteMapping("/{id}") // DELETE /api/books/{id} public ResponseEntity<Void> delete(@PathVariable UUID id) { return books.delete(id) ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); }}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 bookcurl -i http://localhost:8080/api/books
# Filtered — just this author, via your derived querycurl -i "http://localhost:8080/api/books?author=Joshua%20Bloch"@PathVariable vs @RequestParamBoth 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.
Glossary: the terms this lesson introduces are in the Glossary, under Lesson 5 — your quick-reference for every term we use.
Architecture decisions: the 16 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 5.
Code: the project at the end of this lesson · what changed in this lesson, exercises included.