Spring Boot Course · Lesson 6

Relationships & DTOs

Right now a book's author is just a loose string — "Robert C. Martin" typed three different ways is three different authors, and a book with two authors has nowhere to put the second one. Time to make Author a real thing that books are linked to. Wiring that relationship changes what your JSON should look like, and fixing that properly is the second half of this lesson: DTOs — the deliberate shapes your API speaks, separate from the entities your database stores.

The win

A real many-to-many relationship — a book has one or more authors, an author can have written many books — with a join table in the database, a new /api/authors resource, and a filter that finds the books of one author by that author's id. And every endpoint now speaks through request and response records instead of leaking entities: your API gets a stable contract, lazy-loading accidents stay out of your JSON, and the "client smuggles an id" problem from Lesson 4 is gone for good.

Start from the shape of the data, not from the annotations. A foreign key is a column, and a column holds exactly one value — it can only point at one row. Put book.author_id in the book table and you have declared that a book has at most one author; The Pragmatic Programmer, by Andrew Hunt and David Thomas, has two, so that is already wrong. Flip it — author.book_id in the author table — and you have declared that an author wrote at most one book, which is worse. Neither table has room for the truth.

A many-to-many relationship doesn't fit in either table because it isn't a property of a book or of an author: it's a fact about a pair. So it gets a table of its own, whose rows are those pairs — book_author(book_id, author_id). One row per "this book was written by this author". Two authors on one book is two rows; an author with five books is five rows. Nothing has to bend:

book book_author author
────────────────── ─────────────────────── ─────────────
id title year book_id author_id id name
b1 Clean Code 2008 b1 a1 a1 Robert C. Martin
b3 The Pragmatic… 1999 b3 a3 a3 Andrew Hunt
b3 a4 a4 David Thomas
Which side owns it?

With a join table, neither entity holds a column pointing at the other — but there is still exactly one side that declares the mapping and therefore writes the rows: the side carrying @JoinTable. We put it on Book, because "which authors wrote this book?" is the question the API actually asks: you create a book with its authors, and you read a book with its authors. So book.getAuthors() is what Hibernate persists into book_author. Author doesn't need to know — it gets no collection at all. That's a unidirectional mapping, and it's the leaner default: one place to look, one place to change, no two collections to keep in sync by hand.

Step 1 — Create the Author entity

A new feature gets a new package: com.example.library.author. It mirrors Book — Lombok, an app-assigned UUIDv7 id, id-only equality (all the reasoning from Lesson 5 applies unchanged). Notice what it does not have: a list of books. An author's books are one query away (Step 5), and keeping the entity flat keeps it out of trouble.

src/main/java/com/example/library/author/Author.java
package com.example.library.author;
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)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Author {
@Id
@EqualsAndHashCode.Include
private UUID id = UuidCreator.getTimeOrderedEpoch();
private String name;
public Author(String name) {
this.name = name;
}
}

Step 2 — Point Book at its authors

On the Book side, the free-text String author becomes a collection of real references, and the annotation that declares the join table sits right on it. Swap the field and update the constructor — the author is no longer passed in as a string:

src/main/java/com/example/library/book/Book.java
package com.example.library.book;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import com.example.library.author.Author;
import com.github.f4b6a3.uuid.UuidCreator;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
17 collapsed lines
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 int year;
// The relationship gets its own table. This side declares it, so this side writes its rows.
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "book_author",
joinColumns = @JoinColumn(name = "book_id"),
inverseJoinColumns = @JoinColumn(name = "author_id"))
private Set<Author> authors = new HashSet<>();
// A book always comes with its authors: there is no way to build one without saying who wrote it.
public Book(String title, int year, Set<Author> authors) { // was Book(String title, String author, int year)
this.title = title;
this.year = year;
this.authors.addAll(authors); // copy in - never keep the caller's set
}
}

joinColumns is the side you're on (book_id points back at this entity); inverseJoinColumns is the side you're reaching (author_id points at the other one). The field initializer matters as much as the annotations: new HashSet<>() means book.getAuthors() is never null, and it gives the constructor a set of its own to addAll(...) into.

Why fetch = LAZY — and why we write it anyway

A to-many association is already lazy by default, so this line changes nothing. We write it because it makes a decision visible at the point it matters. Lazy means the set isn't loaded when the book is: Hibernate hands you a placeholder and fetches the rows only if something actually touches the collection. That turns "load a book's authors" into a deliberate act with a cost attached — and deliberately paying that cost well, in one query instead of one per book, is the whole subject of Lesson 9. Leaving the annotation bare would hide the property the deferred-performance callout near the end of this lesson is about.

Why a Set, not a List

Three reasons, and they stack:

The one thing a Set gives up is order — iterate it twice and the authors may come out differently. That's honest: the database doesn't store an order either. But a JSON array is ordered, so we fix it at the edge instead, where it belongs (Step 4).

No cascade — and why

@ManyToMany accepts a cascade = CascadeType.ALL attribute that makes saving a book also save its authors. We leave it off, on purpose: books and authors have independent lifecycles. An author exists whether or not you've entered any of their books, and survives all of them being deleted. Cascading would tie the two together in both directions, and the operation it quietly bundles in, CascadeType.REMOVE, is actively dangerous in a many-to-many: deleting one book would delete its authors — including a co-author who is still attached to other books, whose rows would then be gone too. The small price is one rule you follow by hand: save the authors before the book that references them, which is exactly what the seeder does in Step 7.

Step 3 — Migrate the schema

The schema is Liquibase's job, and applied changesets are immutable — so you add new ones rather than touching 001. Two changes: create the author table, then create the join table, carry the existing data into it, and retire the old text column. Each goes in its own file, included from the master changelog.

src/main/resources/db/changelog/changes/002-create-author-table.yaml
databaseChangeLog:
- changeSet:
id: 002-create-author-table
author: roman
changes:
- createTable:
tableName: author
columns:
- column:
name: id
type: uuid
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(255)
src/main/resources/db/changelog/changes/003-link-books-to-authors.yaml
databaseChangeLog:
- changeSet:
id: 003-link-books-to-authors
author: roman
changes:
# 1. Expand: a many-to-many can't live as a column in either table, so it gets its own.
- createTable:
tableName: book_author
columns:
- column:
name: book_id
type: uuid
constraints:
nullable: false
- column:
name: author_id
type: uuid
constraints:
nullable: false
# One row per (book, author) pair - the pair itself is the key, so no duplicates.
- addPrimaryKey:
tableName: book_author
columnNames: book_id, author_id
constraintName: pk_book_author
- addForeignKeyConstraint:
baseTableName: book_author
baseColumnNames: book_id
constraintName: fk_book_author_book
referencedTableName: book
referencedColumnNames: id
- addForeignKeyConstraint:
baseTableName: book_author
baseColumnNames: author_id
constraintName: fk_book_author_author
referencedTableName: author
referencedColumnNames: id
# 2. Migrate: carry the existing data across. Each distinct author text becomes
# an author row; each book gets a join row pointing at its author.
- sql:
sql: |
INSERT INTO author (id, name)
SELECT uuidv7(), author FROM book WHERE author IS NOT NULL GROUP BY author;
INSERT INTO book_author (book_id, author_id)
SELECT b.id, a.id FROM book b JOIN author a ON a.name = b.author;
# 3. Contract: only now, with nothing left in it that isn't also in the join table,
# is the free-text column safe to drop.
- dropColumn:
tableName: book
columnName: author

Read the two constraints as the two rules the table needs. The addPrimaryKey covers both columns, because the pair is the identity of a row — there is nothing else in the table to identify it by, and making the pair unique is precisely the "no duplicate link" rule the Set mirrors in Java. The two foreign keys then say that each half of the pair must name a real row: no link to a book that doesn't exist, no link to an author that doesn't exist. The database will enforce both whatever your Java does.

The sql block in the middle is what makes this changeset safe to run anywhere. Your database already has books, and each one has an author sitting in the column the last change drops. Drop it first and that data is gone — on your laptop an inconvenience, on a production database an incident. So the changeset does its work in the order every schema change with live data follows: expand (create the new structures), migrate (copy the data into them), contract (remove the old ones). Liquibase has no change type for "one row per distinct value", which is why the middle step is plain SQL: the first INSERT … SELECT turns every distinct author text into an author row — the GROUP BY is what makes two books by the same author share one row — and the second gives every book a join row pointing at its author. uuidv7() is a PostgreSQL 18 function, so the migrated authors get the same kind of id the Java side generates and are indistinguishable from authors created later through the API. The books keep their ids: a client holding /api/books/{id} never notices the migration. Plain SQL is also the one Postgres-specific thing in the file; the other changes are database-neutral. For a project with one database that's a fair price for a data migration that does exactly what it says.

Then register both files in the master changelog so Liquibase runs them in order:

src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
- include:
file: changes/001-create-book-table.yaml
relativeToChangelogFile: true
- include:
file: changes/002-create-author-table.yaml
relativeToChangelogFile: true
- include:
file: changes/003-link-books-to-authors.yaml
relativeToChangelogFile: true

The trap this sets: entities can't be your JSON

Pause before wiring controllers. Your Book entity now carries a collection that is lazy — and your endpoints have been returning that entity straight to Jackson since Lesson 2. Jackson doesn't know about laziness; it just walks the object's properties, finds authors, and serialises whatever is there. What's there is a Hibernate placeholder — a PersistentSet standing in for rows nobody has fetched yet. Two things can happen, and neither is good:

Either way, your wire format has become hostage to your persistence settings: change a fetch mode, or how long the session stays open, and the shape or the success of your API changes with it. And that's only the first of three problems with entities-as-JSON:

You'll find quick patches for each of these — @JsonIgnore on the collection, a hand-rolled guard in the service — but they all bake your JSON shape into your database class, so the entity ends up answering to two masters. There's a cleaner answer: it solves the second and third outright, and puts the first under your control instead of your serializer's — Lesson 9 finishes that job.

Step 4 — Introduce DTOs: the shapes your API speaks

A DTO (Data Transfer Object) is a plain carrier for data crossing a boundary — here, the HTTP boundary. You define one shape for what you accept and one for what you send, as immutable records, and you map between them and your entities at the edge. The entity goes back to being about storage, and the records become the contract you can actually promise clients.

The book side needs a request shape and a response shape. The request references authors by id:

src/main/java/com/example/library/book/CreateBookRequest.java
package com.example.library.book;
import java.util.Set;
import java.util.UUID;
// What we ACCEPT on POST/PUT. No id field -> nothing for a client to smuggle.
public record CreateBookRequest(String title, int year, Set<UUID> authorIds) {}

The response carries authors as small nested objects, so start with the author's response shape — just id and name. It lives in the author package, and it's the same record /api/authors will return in Step 6: one shape for an author, wherever it appears.

src/main/java/com/example/library/author/AuthorResponse.java
package com.example.library.author;
import java.util.UUID;
// What we SEND BACK for an author - on its own, or nested inside a book.
public record AuthorResponse(UUID id, String name) {
public static AuthorResponse from(Author author) {
return new AuthorResponse(author.getId(), author.getName());
}
}

Now the book's response maps the entity's Set into a List of those author responses — and sorts it:

src/main/java/com/example/library/book/BookResponse.java
package com.example.library.book;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import com.example.library.author.AuthorResponse;
// What we SEND BACK. Authors as a list, sorted by name so the JSON order is stable.
public record BookResponse(UUID id, String title, int year, List<AuthorResponse> authors) {
public static BookResponse from(Book book) {
List<AuthorResponse> authors = book.getAuthors().stream()
.map(AuthorResponse::from)
.sorted(Comparator.comparing(AuthorResponse::name))
.toList();
return new BookResponse(book.getId(), book.getTitle(), book.getYear(), authors);
}
}
Why sort at the edge?

Remember the one thing the Set gave up: order. The entity genuinely has none, and pretending otherwise — say, by making the field an ordered collection — would be modelling a fact the database doesn't store. But a JSON array is ordered, and a client that sees the same book's authors in a different order on every request has no way to tell "reordered" from "changed". So the edge decides: from(...) sorts by name, and the contract gains a stable, meaningful order without the entity having to fake one. That's the DTO earning its keep — the API's concerns handled in the API's own type.

Why a from(...) factory, and where mapping lives

Putting a static from(entity) on each response record keeps the mapping next to the shape it produces, and keeps controllers a one-liner: .map(BookResponse::from). Mapping happens at the edge — the controller turns entities into responses on the way out and pulls fields off requests on the way in — so the service and repository stay in the language of entities. (For big projects a dedicated mapper, or a library like MapStruct, takes over this job; hand-written factories are perfect at our size.)

The author side already has its response shape — AuthorResponse, above. All it still needs is a request:

src/main/java/com/example/library/author/CreateAuthorRequest.java
package com.example.library.author;
public record CreateAuthorRequest(String name) {}

Step 5 — Rework the book service and controller

The service now needs the AuthorRepository too — to turn the incoming authorIds into real, managed Author instances before saving a book. And the Lesson 5 derived query findByAuthor(String), which searched a text column that no longer exists, is replaced by one that filters on the author's id — findByAuthorsId. Now that an author is a row with an identity, the id is the right key: two authors can share a name, an exact string match breaks on "Robert Martin" vs. "Robert C. Martin", and a client that got an author from /api/authors is already holding the id.

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> {
// Traverses the collection: WHERE any of the book's authors has this id
List<Book> findByAuthorsId(UUID authorId);
}

Read the name the way Spring Data does: findBy + Authors + Id. Authors is the collection field on Book, and Id is a property of its elements — so the whole phrase means "the id of any element of authors". To answer that, Spring Data joins book to book_author and filters WHERE book_author.author_id = ? — it doesn't even need the author table, because the id it's looking for is already sitting in the join row. A book with two authors is returned if either of them matches. Still no method body, still no SQL: a method name turned a column comparison into a query across the relationship.

The service is about to look authors up by id, so it needs a repository for them. Create it now — like BookRepository in Lesson 5, an empty interface is enough, and the inherited findAllById is exactly the method the service will call:

src/main/java/com/example/library/author/AuthorRepository.java
package com.example.library.author;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorRepository extends JpaRepository<Author, UUID> {}

Now the service can take both repositories through its constructor:

src/main/java/com/example/library/book/BookService.java
package com.example.library.book;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import com.example.library.author.Author;
import com.example.library.author.AuthorRepository;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository books;
private final AuthorRepository authors; // new: to resolve author ids
public BookService(BookRepository books, AuthorRepository authors) {
this.books = books;
this.authors = authors;
}
public List<Book> findAll(UUID authorId) {
return (authorId == null) ? books.findAll() : books.findByAuthorsId(authorId);
}
public Optional<Book> findById(UUID id) {
return books.findById(id);
}
public Book create(String title, int year, Set<UUID> authorIds) {
Book book = new Book(title, year, requireAuthors(authorIds));
return books.save(book);
}
public Optional<Book> replace(UUID id, String title, int year, Set<UUID> authorIds) {
if (!books.existsById(id)) {
return Optional.empty();
}
Book book = new Book(title, year, requireAuthors(authorIds));
book.setId(id); // pin the path id, then upsert
return Optional.of(books.save(book));
}
public boolean delete(UUID id) {
if (!books.existsById(id)) {
return false;
}
books.deleteById(id);
return true;
}
// Every id must resolve to a real author, or the book isn't created at all.
private Set<Author> requireAuthors(Set<UUID> authorIds) {
List<Author> found = authors.findAllById(authorIds);
if (found.size() != authorIds.size()) {
Set<UUID> missing = new HashSet<>(authorIds);
found.forEach(a -> missing.remove(a.getId()));
throw new NoSuchElementException("No author with id " + missing);
}
return new HashSet<>(found);
}
}
Why does the service take title, year, authorIds — not the DTO?

On purpose. CreateBookRequest is a web type (it models a JSON request), and the service should speak the domain, not HTTP. Taking plain values keeps the service callable by anything — a future bulk importer, a scheduled job, a message consumer — without it having to fabricate an HTTP-request object. The controller does the unpacking: books.create(req.title(), req.year(), req.authorIds()).

The cost is a longer parameter list, which stops scaling once a book gains more fields (positional arguments get easy to mis-order). At that point you'd pass a single cohesive object instead — either the request DTO directly (simplest, at the price of mild web-coupling in the service) or a small service-owned command record. At three fields, plain values stay clearer than introducing another type; past four or five, reach for the object.

requireAuthors: all or nothing

One call to findAllById resolves the whole set in a single query — but note what it does with an id it can't find: nothing. It returns only the authors that exist, silently. Hand that result straight to the book and a request for three authors would quietly create a book with two. So we compare sizes: fewer rows back than ids sent means at least one id was bogus, and the book isn't created at all. The set difference then tells the caller which ones — "No author with id [00000000-…]" is a message someone can act on, unlike a bare "not found". A partial write is worse than a rejected one.

The controller keeps its Lesson 4 shape — same routes, same status codes — but now takes request DTOs in and maps entities to response DTOs out:

src/main/java/com/example/library/book/BookController.java
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;
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 ?authorId=...
public List<BookResponse> all(@RequestParam(required = false) UUID authorId) {
return books.findAll(authorId).stream().map(BookResponse::from).toList();
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> byId(@PathVariable UUID id) {
return books.findById(id)
.map(BookResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@PutMapping("/{id}") // PUT /api/books/{id}
public ResponseEntity<BookResponse> replace(@PathVariable UUID id, @RequestBody CreateBookRequest req) {
return books.replace(id, req.title(), req.year(), req.authorIds())
.map(BookResponse::from)
.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();
}
}
One rough edge, on purpose: a bad id in authorIds

Post a book whose authorIds contains an id matching no author and requireAuthors throws — which, with no handler yet, surfaces as a blunt 500 Internal Server Error. That's the wrong answer: a bad id the client sent is a client error (a 4xx), not a server fault. Turning exceptions like this into clean, consistent error responses is exactly Lesson 7 (validation & @ControllerAdvice). We leave the sharp edge visible so you feel why that lesson exists.

Step 6 — Add the author resource

The AuthorRepository already exists from Step 5. Add a thin service and a controller on top of it — the same three-layer shape you built for books in Lesson 3, now for authors.

src/main/java/com/example/library/author/AuthorService.java
package com.example.library.author;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service;
@Service
public class AuthorService {
private final AuthorRepository authors;
public AuthorService(AuthorRepository authors) {
this.authors = authors;
}
public List<Author> findAll() {
return authors.findAll();
}
public Optional<Author> findById(UUID id) {
return authors.findById(id);
}
public Author create(String name) {
return authors.save(new Author(name));
}
public Optional<Author> replace(UUID id, String name) {
if (!authors.existsById(id)) {
return Optional.empty();
}
Author author = new Author(name);
author.setId(id); // pin the path id, then upsert
return Optional.of(authors.save(author));
}
public boolean delete(UUID id) {
if (!authors.existsById(id)) {
return false;
}
authors.deleteById(id);
return true;
}
}
src/main/java/com/example/library/author/AuthorController.java
package com.example.library.author;
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/authors")
public class AuthorController {
private final AuthorService authors;
public AuthorController(AuthorService authors) {
this.authors = authors;
}
@GetMapping
public List<AuthorResponse> all() {
return authors.findAll().stream().map(AuthorResponse::from).toList();
}
@GetMapping("/{id}")
public ResponseEntity<AuthorResponse> byId(@PathVariable UUID id) {
return authors.findById(id)
.map(AuthorResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<AuthorResponse> create(@RequestBody CreateAuthorRequest req) {
Author saved = authors.create(req.name());
URI location = URI.create("/api/authors/" + saved.getId());
return ResponseEntity.created(location).body(AuthorResponse.from(saved));
}
@PutMapping("/{id}")
public ResponseEntity<AuthorResponse> replace(@PathVariable UUID id, @RequestBody CreateAuthorRequest req) {
return authors.replace(id, req.name())
.map(AuthorResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id) {
return authors.delete(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}

That's the full set for authors — list, read one, create, replace, delete — with replace and delete written exactly like the book's in Step 5. One of them behaves differently from its twin, though: a book can always be deleted, but an author might still be referenced by a book. Hold that thought until Step 8.

The author endpoints return {id, name} and nothing more — the entity has no books to nest, and that's deliberate: to see an author's books you take the author's id and filter the books endpoint with it, GET /api/books?authorId={id}, which is the resource that owns that question.

Step 7 — Seed authors with their books

The Lesson 5 BookSeeder built loose books with string authors — it won't even compile now. Replace it with a seeder that creates the authors, then a book that points at them. With no cascade, the seeder saves each side itself, and the line order is the lesson. (Delete BookSeeder.java.)

On a database that came through Lesson 5 the migration has already created the authors, so the authors.count() > 0 guard fires and this seeder does nothing — it is for an empty database, where it produces the same three books and authors the migration does.

src/main/java/com/example/library/DataSeeder.java
package com.example.library;
import java.util.HashSet;
import java.util.Set;
import com.example.library.author.Author;
import com.example.library.author.AuthorRepository;
import com.example.library.book.Book;
import com.example.library.book.BookRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class DataSeeder implements CommandLineRunner {
private final AuthorRepository authors;
private final BookRepository books; // now need both repositories
public DataSeeder(AuthorRepository authors, BookRepository books) {
this.authors = authors;
this.books = books;
}
@Override
public void run(String... args) {
if (authors.count() > 0) {
return; // seed once
}
add("Clean Code", 2008, "Robert C. Martin");
add("Effective Java", 2018, "Joshua Bloch");
add("Domain-Driven Design", 2003, "Eric Evans");
}
private void add(String title, int year, String... authorNames) {
Set<Author> bookAuthors = new HashSet<>();
for (String name : authorNames) {
bookAuthors.add(authors.save(new Author(name))); // 1. the authors must exist...
}
Book book = new Book(title, year, bookAuthors); // 2. ...before the book points at them...
books.save(book); // 3. ...and only then is saved
}
}

Save the book before the authors and Hibernate stops you cold with a TransientObjectException: object references an unsaved transient instance. That error is Hibernate refusing to write a join row that would point at an author that isn't in the database yet. The three steps aren't ceremony; they're the relationship's direction made executable. The String... authorNames parameter already allows several authors per book; the first such book arrives through the API in Step 8.

Step 8 — Run it

Start the app — and do nothing to the database first. It still holds the three books Lesson 5 seeded, each with its author in the column changeset 003 is about to drop, and that is the point: the migration has to take the data you already have to the new shape, because that is exactly what it will have to do on a real deployment, where wiping the database is not an option.

Terminal window
./mvnw spring-boot:run

Watch Liquibase in the console (timestamps and logger names trimmed):

… Running Changeset: db/changelog/changes/002-create-author-table.yaml::002-create-author-table::roman
… Table author created
… Running Changeset: db/changelog/changes/003-link-books-to-authors.yaml::003-link-books-to-authors::roman
… Table book_author created
… Primary key added to book_author (book_id, author_id)
… Foreign key constraint added to book_author (book_id)
… Foreign key constraint added to book_author (author_id)
… Custom SQL executed
… Column book.author dropped
…
… Liquibase: Update has been successful. Rows affected: 6

Six rows: three in author and three in book_author, written by the two INSERT … SELECT statements. After that Hibernate validated the entities against the new schema, and the seeder found authors already there and did nothing. Look at the two collections. Authors are flat {id, name} objects; books nest their authors:

Terminal window
curl -s http://localhost:8080/api/authors
[
{"id":"01a0c863-1491-7286-ae6c-8c03cdc90cac","name":"Eric Evans"},
{"id":"01a0c863-1491-747b-b7c3-d6e6932f76e9","name":"Joshua Bloch"},
{"id":"01a0c863-1491-74a3-8fe5-bfa5535c1441","name":"Robert C. Martin"}
]
Terminal window
curl -s http://localhost:8080/api/books
[
{"id":"01a0c862-0d43-7431-8433-4cccc8b71f24","title":"Clean Code","year":2008,
"authors":[{"id":"01a0c863-1491-74a3-8fe5-bfa5535c1441","name":"Robert C. Martin"}]},
{"id":"01a0c862-0d76-7267-bc84-7029cdf043af","title":"Effective Java","year":2018,
"authors":[{"id":"01a0c863-1491-747b-b7c3-d6e6932f76e9","name":"Joshua Bloch"}]},
{"id":"01a0c862-0d79-78ec-909b-eac4e9d9f831","title":"Domain-Driven Design","year":2003,
"authors":[{"id":"01a0c863-1491-7286-ae6c-8c03cdc90cac","name":"Eric Evans"}]}
]
Use your own ids

Your ids differ from the ones printed here. From this point on, every request below uses an id from an earlier response — always copy it from your response, never from the page.

Started this lesson from an empty database instead? Then there was nothing to migrate, the seeder ran, and you have the same three books and authors.

Notice the book ids: they are the ones Lesson 5 handed out. Only the authors are new, and the UUIDv7 prefixes show it (01a0c862-… against 01a0c863-…). What isn't in there yet is the book this lesson opened with — the one with two authors. The authors come first, because the service refuses a book whose authorIds it can't resolve:

Terminal window
curl -i -X POST http://localhost:8080/api/authors \
-H "Content-Type: application/json" -d '{"name":"Andrew Hunt"}'
HTTP/1.1 201
Location: /api/authors/01a0c863-2b7a-7364-8500-fbc1d5c00f2e
Content-Type: application/json
{"id":"01a0c863-2b7a-7364-8500-fbc1d5c00f2e","name":"Andrew Hunt"}
Terminal window
curl -i -X POST http://localhost:8080/api/authors \
-H "Content-Type: application/json" -d '{"name":"David Thomas"}'
HTTP/1.1 201
Location: /api/authors/01a0c863-2be1-77cd-8cba-b8492f994901
Content-Type: application/json
{"id":"01a0c863-2be1-77cd-8cba-b8492f994901","name":"David Thomas"}

And the case the whole lesson exists for — one book, two authors, in a single request. Watch the order of the response: you sent Thomas first, but BookResponse.from sorts by name, so Hunt comes back first:

Terminal window
# The two ids are the ones just returned, David Thomas's first
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"The Pragmatic Programmer","year":1999,
"authorIds":["01a0c863-2be1-77cd-8cba-b8492f994901","01a0c863-2b7a-7364-8500-fbc1d5c00f2e"]}'
HTTP/1.1 201
Location: /api/books/01a0c863-2c42-7a14-bd3b-a78af4dcb08e
Content-Type: application/json
{"id":"01a0c863-2c42-7a14-bd3b-a78af4dcb08e","title":"The Pragmatic Programmer","year":1999,
"authors":[{"id":"01a0c863-2b7a-7364-8500-fbc1d5c00f2e","name":"Andrew Hunt"},
{"id":"01a0c863-2be1-77cd-8cba-b8492f994901","name":"David Thomas"}]}

Now the derived query. Take David Thomas's id and filter the books by it. He is only the second of two authors on that book — the old text column had no room for him at all — and the join finds the book without you writing a line of SQL. Note that the book comes back with both its authors, not just the one you filtered by:

Terminal window
curl -s "http://localhost:8080/api/books?authorId=01a0c863-2be1-77cd-8cba-b8492f994901"
[
{"id":"01a0c863-2c42-7a14-bd3b-a78af4dcb08e","title":"The Pragmatic Programmer","year":1999,
"authors":[{"id":"01a0c863-2b7a-7364-8500-fbc1d5c00f2e","name":"Andrew Hunt"},
{"id":"01a0c863-2be1-77cd-8cba-b8492f994901","name":"David Thomas"}]}
]

The other direction works too — an author with several books. Give Robert C. Martin a second one; authorIds is an array even when it holds a single id:

Terminal window
# Robert C. Martin's id, from GET /api/authors
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Clean Architecture","year":2017,"authorIds":["01a0c863-1491-74a3-8fe5-bfa5535c1441"]}'
HTTP/1.1 201
Location: /api/books/01a0c863-2c69-7df2-bdf4-f8168903b3cc
Content-Type: application/json
{"id":"01a0c863-2c69-7df2-bdf4-f8168903b3cc","title":"Clean Architecture","year":2017,
"authors":[{"id":"01a0c863-1491-74a3-8fe5-bfa5535c1441","name":"Robert C. Martin"}]}

Finally, the sharp edge from Step 5 — an id that matches no author. It is a client mistake reported as a server failure, and Lesson 7 fixes it:

Terminal window
curl -s -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Ghost","year":2000,"authorIds":["00000000-0000-0000-0000-000000000000"]}' | head -1
HTTP/1.1 500

Now look at what those requests actually wrote. The join table is where the relationship really lives, and it's worth seeing as rows — this is the table you designed at the top of the lesson, filled in:

Terminal window
docker compose exec postgres psql -U library -d library -c \
"SELECT b.title, a.name FROM book_author ba
JOIN book b ON b.id = ba.book_id
JOIN author a ON a.id = ba.author_id
ORDER BY b.title, a.name;"
title | name
--------------------------+------------------
Clean Architecture | Robert C. Martin
Clean Code | Robert C. Martin
Domain-Driven Design | Eric Evans
Effective Java | Joshua Bloch
The Pragmatic Programmer | Andrew Hunt
The Pragmatic Programmer | David Thomas
(6 rows)

Six rows for five books, because one of those books has two authors — and Robert C. Martin appears twice, on different books. Neither fact could have been stored in a column on either table.

Last, DELETE on an author. Try David Thomas, whose id is sitting in one of those six join rows:

Terminal window
curl -s -i -X DELETE http://localhost:8080/api/authors/01a0c863-2be1-77cd-8cba-b8492f994901 | head -1
HTTP/1.1 500

And in the application log:

ERROR: update or delete on table "author" violates foreign key constraint "fk_book_author_author" on table "book_author"
The foreign key just saved a book — and a second rough edge, on purpose

That failure is the constraint you wrote in changeset 003 doing its job. Without fk_book_author_author, the delete would have succeeded and left The Pragmatic Programmer with a join row pointing at an author who no longer exists. The database refused, the transaction rolled back, and David Thomas is still there. This is what "no cascade" buys you: nothing is deleted by surprise.

But a 500 is the wrong way to say it. The request was well-formed and the server is healthy; the client asked for something the current state of the data doesn't allow. HTTP has a status for exactly that — 409 Conflict — and the service should check for it and say so, instead of letting a database error leak out as a server fault. Like the bad authorIds above, we leave this edge sharp so you feel it; Lesson 7 fixes both.

Deferred — but serious at scale: N+1 and pagination

Two performance problems live in the list endpoint. They don't bite at five rows, but they're first-order concerns in production, so know they're waiting.

The N+1 query problem. GET /api/books runs one query for the books, then — because authors is lazy — one more query per book to load its authors set while BookResponse.from maps it. A thousand books becomes ~1001 queries in a single request. Those extra loads happen while the controller maps entities to BookResponse DTOs, after the service's transaction has already ended — and they succeed anyway only because Spring Boot keeps the persistence session open for the whole request (open-session-in-view — that startup warning you saw), which is exactly what lets N+1 hide.

The one underneath it: unbounded results. At a thousand rows you'd never return them all in one response anyway — so the first fix isn't even N+1, it's pagination, which caps the payload and shrinks the problem to a single page. That's Lesson 8. Then a deliberate fetch kills the rest: an entity graph loads the books with their authors in one query, which is Lesson 9.

They're deferred here only so this lesson can stay about modeling and DTOs, not because they're minor.

If it doesn't behave
  • App won't start: missing table [book_author] or missing table [author]? Hibernate's validate ran before your new changesets — i.e. they didn't apply. Check that both new files are listed in db.changelog-master.yaml and that the paths match the filenames exactly.
  • POST /api/books returns 500? Either one of the authorIds matches no author (create the author first, or copy the id from GET /api/authors), or the request has no authorIds field at all — then it deserializes as null and requireAuthors fails on it. Both are the un-handled exception showing through; Lesson 7 turns each into a clean 4xx.
  • TransientObjectException — object references an unsaved transient instance — from the seeder? You saved the book before its authors. Save every Author first, then build the book with them, then save the book (Step 7's three numbered steps).
  • DELETE /api/authors/{id} returns 500? That author is still linked to at least one book, and the foreign key on book_author refuses to orphan the link. Delete or re-author those books first. The 500 itself is this lesson's second deliberate rough edge; Lesson 7 turns it into a 409 Conflict.
  • App won't start, and Liquibase reports ERROR: column "author" does not exist? The sql step is below dropColumn in your 003 — within a changeset the changes run in the order you wrote them. Nothing is lost: Postgres runs the whole changeset in one transaction, so the failed one rolled back, column included, and Liquibase didn't record it as applied. Fix the order and start again; 003 runs from the top.
  • Old books come back with an empty "authors": []? The migrate step of changeset 003 didn't produce join rows for them. Check the second INSERT … SELECT: it matches author.name against book.author, so the two must be the exact same text. A changeset that has run is recorded and never re-run, and the column is already gone, so fix the SQL and then start over locally: docker compose down -v, run the app, and let the seeder fill the empty database. Keep the corrected 003 — it's what runs on every environment that still has data to migrate.

Check yourself

Answer from memory before revealing.

Why can't the book–author link be a foreign-key column on either table?
Which side of the mapping writes rows into book_author?
Why is Book.authors a Set rather than a List?
What does the repository method findByAuthorsId(UUID authorId) generate?

Read this next

Primary source: Vlad Mihalcea's The best way to use the @ManyToMany annotation with JPA and Hibernate. Mihalcea is the most authoritative voice on Hibernate; this article is the canonical treatment of join-table mappings — why Set beats List, and what cascading really does to a shared association. For the DTO half, Baeldung's The DTO Pattern is a clean, focused companion.

Glossary: the terms this lesson introduces are in the Glossary, under Lesson 6 — your quick-reference for every term we use.

Architecture decisions: the 11 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 6.

Code: the project at the end of this lesson · what changed in this lesson, exercises included.

💬 Discuss