Spring Boot Course · Lesson 6
Right now a book's author is just a loose string — "Robert C. Martin" typed three different ways is three different authors. Time to make Author a real thing that books belong to. Wiring that relationship will quietly break your JSON, and fixing it properly is the second half of this lesson: DTOs — the deliberate shapes your API speaks, separate from the entities your database stores.
Author has many Books — with a foreign key in the database, a new /api/authors resource, and a search that filters books by their author's name. And every endpoint now speaks through request and response records instead of leaking entities: your API gets a stable contract, the infinite-recursion trap disappears, and the "client smuggles an id" problem from Lesson 4 is gone for good.
A book has one author; an author has many books. In the database that's expressed with a single foreign key: the book table gets an author_id column pointing at a row in author. A foreign key can only live in one table — and that fact decides everything about how JPA models the two sides.
Book, mapped with @ManyToOne (many books → one author). It's "owning" because Hibernate looks at this side to decide what to write to author_id.Author with a @OneToMany collection of books. It owns no column — it's just a convenient way to navigate from an author to their books. Its mappedBy says "the link is already mapped, by the author field over on Book."
The practical consequence you must remember: to persist the link, set the owning side. Adding a book to author.getBooks() alone changes nothing in the database; you have to set book.setAuthor(author). You'll do exactly that, by hand, when you seed data in Step 7 — and the order it forces is the whole point.
Author entity
A new feature gets a new package: com.example.library.author. The entity mirrors Book — Lombok, an app-assigned UUIDv7 id, id-only equality (all the reasoning from Lesson 5 applies unchanged) — plus the @OneToMany back-reference and the sync helper.
src/main/java/com/example/library/author/Author.java
package com.example.library.author;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.example.library.book.Book;
import com.github.f4b6a3.uuid.UuidCreator;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
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;
// Inverse side: "author" names the field that owns the FK over in Book.
// No cascade: Author and Book have independent lifecycles, linked only by the FK.
@OneToMany(mappedBy = "author")
private List<Book> books = new ArrayList<>();
public Author(String name) {
this.name = name;
}
} books list
The foreign key lives entirely on the Book side, so the relationship works without Author.books at all. This collection is the inverse side: navigation-only — it adds no column and writes nothing, it just lets you walk from an author to their books in Java (author.getBooks()), which is what lets the author's response nest its books. The leaner alternative is a unidirectional mapping: drop this field and fetch books with a query when you need them — bookRepository.findByAuthorId(id). That's the modern default; the inverse collection is where most JPA friction lives (the JSON cycle, cascade surprises, the N+1). We keep the bidirectional version here for two reasons: it's how you learn @OneToMany and owning-vs-inverse (prime interview ground), and it's the very link that creates the serialization cycle the DTOs exist to cut. Rule of thumb: add the inverse side only when you genuinely navigate parent → children often.
cascade here — and why that's the honest default
Many tutorials add cascade = CascadeType.ALL so that saving an author also saves its books. We leave it off, on purpose. An inverse @OneToMany doesn't write the foreign key anyway — the owning side does — so cascade's only job here would be to propagate lifecycle operations, and the one it bundles in, CascadeType.REMOVE, would make deleting an author silently delete all of their books. We don't want that, and the database's foreign key is a useful backstop against accidental author deletes (it blocks them). The small price: one rule you'll follow by hand in the seeder — save the author before the book that points at it, because a book's author_id can't reference a row that doesn't exist yet. (When you later add a real "delete an author" feature, that is the moment to decide deliberately between blocking, reassigning, or cascading — not before.)
Book at its author
On the Book side, the free-text String author becomes a real reference: @ManyToOne with a @JoinColumn naming the foreign-key column. Swap the field (and update the constructor — author is no longer passed in as a string):
src/main/java/com/example/library/book/Book.java — the changed parts
import com.example.library.author.Author;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
// ...inside class Book, replacing `private String author;`
private String title;
private int year;
// Owning side: the foreign key (author_id) lives in THIS table.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
public Book(String title, int year) { // was Book(String title, String author, int year)
this.title = title;
this.year = year;
} fetch = LAZY here @ManyToOne defaults to EAGER — every time you load a book, Hibernate also loads its author, whether you need it or not. On a single entity that's harmless; across joins and bigger graphs it quietly fetches half your database. The seasoned default is to make associations LAZY and load what you actually need on purpose. We follow that here. (When you do need the author — as our response mapping will — Hibernate fetches it then. The cost of that, and how to batch it, is a tuning topic noted at the end.)
Note the asymmetry with Author: you only write fetch to override a default, and the defaults differ by side — to-one (@ManyToOne, @OneToOne) defaults to EAGER, to-many (@OneToMany, @ManyToMany) defaults to LAZY. So this @ManyToOne needs the override, but Author's @OneToMany is already lazy and we leave it bare. The only place in the whole app that needs a fetch is right here.
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 rework book (drop the old text column, add the author_id foreign key). 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-book-to-author.yaml
databaseChangeLog:
- changeSet:
id: 003-link-book-to-author
author: roman
changes:
# The author is a real table now, so drop the old free-text column...
- dropColumn:
tableName: book
columnName: author
# ...and replace it with a foreign key to author.id.
- addColumn:
tableName: book
columns:
- column:
name: author_id
type: uuid
- addForeignKeyConstraint:
baseTableName: book
baseColumnNames: author_id
constraintName: fk_book_author
referencedTableName: author
referencedColumnNames: id Then register both 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-book-to-author.yaml
relativeToChangelogFile: true
Pause before wiring controllers. You now have a cycle in your object graph: a Book points to its Author, and that Author points back to a list of Books, each of which points to the Author… If a controller returned an Author entity directly — exactly what your Book endpoints have done since Lesson 2 — Jackson would walk that loop with no exit:
Author → books[0] → author → books[0] → author → books[0] → ...
(no base case — Jackson recurses until the stack overflows)
The request dies with an infinite-recursion error (a StackOverflowError). You'll find quick patches for it — annotate the entity with @JsonIgnore or @JsonManagedReference/@JsonBackReference — but they all share the same flaw: they bake your JSON shape into your database class. Now the entity answers to two masters (the schema and the wire format), and every API tweak risks your persistence and vice versa. There's a cleaner answer that solves three problems at once.
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. Three problems dissolve:
AuthorSummary, not your entity's lazy proxies, version columns, or whatever you add later. The API stops changing every time the table does.id — a create request simply has no id field, so a client can't set one. (Remember the server-side setId guard we needed in Lesson 5? Gone.)The book side needs a request shape and a response shape. The request references the author by id; the response carries the author as a small nested object — an AuthorSummary of just id and name — which is what breaks the cycle, since that summary has no books to recurse into:
src/main/java/com/example/library/book/CreateBookRequest.java
package com.example.library.book;
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, UUID authorId) {} First, a compact view of an author to embed. It lives in the author package, since it's an author projection — just id and name, and crucially no books, so it can never form a cycle:
src/main/java/com/example/library/author/AuthorSummary.java
package com.example.library.author;
import java.util.UUID;
// A compact, embeddable view of an author: id + name, no books (so it can't recurse).
public record AuthorSummary(UUID id, String name) {
public static AuthorSummary from(Author author) {
return author == null ? null : new AuthorSummary(author.getId(), author.getName());
}
} Now the book's response nests that summary in place of the full author entity:
src/main/java/com/example/library/book/BookResponse.java
package com.example.library.book;
import java.util.UUID;
import com.example.library.author.AuthorSummary;
// What we SEND BACK. The author is a nested AuthorSummary (id + name, no books) -> no cycle.
public record BookResponse(UUID id, String title, int year, AuthorSummary author) {
public static BookResponse from(Book book) {
return new BookResponse(
book.getId(),
book.getTitle(),
book.getYear(),
AuthorSummary.from(book.getAuthor()));
}
} The author side mirrors it. Its response includes the author's books as BookResponses — and since each of those nests only an AuthorSummary (which has no books of its own), the loop is still cut at the boundary:
src/main/java/com/example/library/author/CreateAuthorRequest.java
package com.example.library.author;
public record CreateAuthorRequest(String name) {} src/main/java/com/example/library/author/AuthorResponse.java
package com.example.library.author;
import java.util.List;
import java.util.UUID;
import com.example.library.book.BookResponse;
public record AuthorResponse(UUID id, String name, List<BookResponse> books) {
public static AuthorResponse from(Author author) {
List<BookResponse> books = author.getBooks().stream()
.map(BookResponse::from)
.toList();
return new AuthorResponse(author.getId(), author.getName(), books);
}
} 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 service now needs the AuthorRepository too — to turn an incoming authorId into a real, managed Author before saving a book. The old findByAuthor(String) derived query becomes findByAuthorName, which traverses the relationship (Spring Data reads AuthorName as "the name of the related author"):
src/main/java/com/example/library/book/BookRepository.java
public interface BookRepository extends JpaRepository<Book, UUID> {
// Traverses the relationship: WHERE author.name = ?
List<Book> findByAuthorName(String name);
} src/main/java/com/example/library/book/BookService.java
@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(String author) {
return (author == null) ? books.findAll() : books.findByAuthorName(author);
}
public Optional<Book> findById(UUID id) {
return books.findById(id);
}
public Book create(String title, int year, UUID authorId) {
Book book = new Book(title, year);
book.setAuthor(requireAuthor(authorId));
return books.save(book);
}
public Optional<Book> replace(UUID id, String title, int year, UUID authorId) {
if (!books.existsById(id)) return Optional.empty();
Book book = new Book(title, year);
book.setId(id); // pin the path id, then upsert
book.setAuthor(requireAuthor(authorId));
return Optional.of(books.save(book));
}
public boolean delete(UUID id) {
if (!books.existsById(id)) return false;
books.deleteById(id);
return true;
}
private Author requireAuthor(UUID authorId) {
return authors.findById(authorId)
.orElseThrow(() -> new NoSuchElementException("No author with id " + authorId));
}
} title, year, authorId — 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.authorId()).
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.
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
@GetMapping // GET /api/books or ?author=...
public List<BookResponse> all(@RequestParam(required = false) String author) {
return books.findAll(author).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.authorId());
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.authorId())
.map(BookResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
} authorId
Post a book whose authorId matches no author and requireAuthor 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.
A repository, a thin service, and a controller — the same three-layer shape you built for books in Lesson 3, now for authors.
src/main/java/com/example/library/author/AuthorRepository.java
public interface AuthorRepository extends JpaRepository<Author, UUID> {} src/main/java/com/example/library/author/AuthorService.java
@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)); }
} src/main/java/com/example/library/author/AuthorController.java
@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));
}
}
The Lesson 5 BookSeeder built loose books with string authors — it won't even compile now. Replace it with a seeder that creates an author, then a book that points at it. With no cascade, the seeder saves each side itself, and the line order is the lesson: parent first, then set the owning side on the child, then save the child. (Delete BookSeeder.java.)
src/main/java/com/example/library/DataSeeder.java
package com.example.library;
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("Robert C. Martin", "Clean Code", 2008);
add("Joshua Bloch", "Effective Java", 2018);
add("Andrew Hunt", "The Pragmatic Programmer", 1999);
}
private void add(String authorName, String title, int year) {
Author author = authors.save(new Author(authorName)); // 1. parent must exist...
Book book = new Book(title, year); // 2. ...before...
book.setAuthor(author); // 3. ...the child points at it...
books.save(book); // 4. ...and only then is saved
}
}
Save the book before the author and Hibernate stops you cold:
object references an unsaved transient instance. That error is the foreign
key talking — a book can't reference an author row that isn't there yet. The four lines aren't
ceremony; they're the relationship's direction made executable.
Changeset 003 drops the old author column, so any books already in your database would lose their author. The cleanest way to see the new shape is to start the database fresh — this wipes the dev volume, so it's a development-only move:
# Stop the app, then wipe the volume so migrations + seed run from scratch.
docker compose down -v
# Boot again: Liquibase runs 001, 002, 003; the seeder inserts authors + books.
./mvnw spring-boot:run Now exercise the relationship from both directions:
# Authors, each with their books nested (the @OneToMany, mapped to DTOs)
curl -s http://localhost:8080/api/authors
# Books, each carrying a flat authorId + authorName (the @ManyToOne)
curl -s http://localhost:8080/api/books
# Filter books by author name — the derived query that traverses the relationship
curl -s "http://localhost:8080/api/books?author=Joshua%20Bloch"
# Create an author, then a book that belongs to it (paste the id from the first response)
curl -i -X POST http://localhost:8080/api/authors \
-H "Content-Type: application/json" -d '{"name":"Martin Fowler"}'
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Refactoring","year":2018,"authorId":"PASTE-AUTHOR-ID"}' A book response now looks like this — the author as a small nested object:
{
"id": "019ee620-98d7-7c09-b44a-2501a2ef4a3d",
"title": "Refactoring",
"year": 2018,
"author": {
"id": "019ee620-9892-7f3a-8287-266e5da2e20e",
"name": "Martin Fowler"
}
} The N+1 query problem. GET /api/books runs one query for the books, then — because author is lazy — fires one more query per book to load its author while mapping. A thousand books becomes ~1001 queries in a single request. (GET /api/authors is the mirror image: one query per author to load their books.) Those extra loads happen during JSON rendering 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 (Page<Book> findAll(Pageable pageable)), which caps the payload and shrinks N+1 to a single page. Then a deliberate fetch kills the rest: @EntityGraph(attributePaths = "author") or a JOIN FETCH query loads books with their authors in one query.
These are standard, well-trodden fixes — pagination, fetch joins / entity graphs, @BatchSize, turning OSIV off, DTO projections — and they get their own lesson. They're deferred here only so this one can stay about modeling and DTOs, not because they're minor.
missing column [author_id] or missing table [author]? Hibernate's validate ran before your new changesets — i.e. they didn't apply. Check both files are listed in db.changelog-master.yaml and the file paths match.save a book but author_id stays null? You set only the inverse side (added to author.getBooks()). The FK is written from the owning side — make sure book.setAuthor(author) runs before you save the book.POST /api/books returns 500? The authorId matches no author. For now that's the un-handled NoSuchElementException — create the author first; clean 4xx handling arrives in Lesson 7."authorName": null? They predate the relationship (changeset 003 left their author_id empty). Run docker compose down -v once to reseed from scratch.Answer from memory before revealing.
Author⇄Book relationship, which side holds the foreign key and is therefore the "owning" side?Author entity directly as JSON now fail?CreateBookRequest no id field accomplish?findByAuthorName(String name) generate? Primary source: Vlad Mihalcea's
The best way to map a @OneToMany relationship with JPA and Hibernate.
Mihalcea is the most authoritative voice on Hibernate; this article is the canonical explanation of owning vs. inverse sides and why the bidirectional sync helper matters. For the DTO half, Baeldung's
The DTO Pattern is a clean, focused companion.
New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.