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. 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.

The win A real one-to-many relationship — one 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.

One author, many books — and which side "owns" it

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.

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.

Step 1 — Create the 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;
    }
}
You don't strictly need this 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.
No 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.)

Step 2 — Point 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;
    }
Why 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.

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 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

The trap this just set: entities can't be your JSON

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.

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. Three problems dissolve:

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);
    }
}
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.)

Step 5 — Rework the book service and controller

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));
    }
}
Why does the service take 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());
}
One rough edge, on purpose: a bad 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.

Step 6 — Add the author resource

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));
    }
}

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 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.

Step 8 — Run it

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"
  }
}
Deferred — but serious at scale: N+1 and pagination Two performance problems live in the list endpoints. They don't bite at three 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 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.

If it doesn't behave

Check yourself

Answer from memory before revealing.

In the AuthorBook relationship, which side holds the foreign key and is therefore the "owning" side?
Why does returning the Author entity directly as JSON now fail?
What does giving CreateBookRequest no id field accomplish?
What query does the repository method findByAuthorName(String name) generate?

Read this next

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.