Week 5: Data Persistence with Spring Data JPA (Part 2)

Week 4 got you a single-entity Task API backed by Spring Data JPA. Real applications are rarely one table — this week you'll add a Project entity that a Task belongs to, learn how lazy loading actually behaves and where the notorious N+1 query problem comes from, wrap multi-step writes in proper transaction boundaries with @Transactional, and retire the ddl-auto shortcut in favor of versioned Flyway migrations — the way schema changes are actually managed on a real team.

Module 4 of 12 Week 5 of 15 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Model one-to-many and many-to-many relationships correctly with JPA
  • Understand lazy loading, the N+1 problem, and how to avoid it
  • Manage schema changes safely with Flyway instead of ddl-auto

1. Modeling Relationships

Week 4's Task stood alone. Most domains don't — a task belongs to a project, an order has line items, a user has roles. JPA models these with relationship annotations that map directly onto foreign keys, and getting the owning side right is the single most important decision you'll make when wiring two entities together.

@ManyToOne / @OneToMany

A Project can have many Tasks, and each Task belongs to exactly one Project — a classic one-to-many. The foreign key lives on the "many" side (the tasks table gets a project_id column), so Task is the owning side and declares the @ManyToOne. Project is the inverse side: it declares @OneToMany(mappedBy = "project"), pointing back at the field name on Task that owns the relationship rather than defining a second foreign key of its own.

Project.java
package com.codeverse.week05.project;

import com.codeverse.week05.task.Task;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "projects")
public class Project {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    // Inverse side -- Task owns the relationship via its "project" field.
    // mappedBy means "no foreign key here, look at Task.project instead."
    @OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Task> tasks = new ArrayList<>();

    protected Project() {}

    public Project(String name) {
        this.name = name;
    }

    public void addTask(Task task) {
        tasks.add(task);
        task.setProject(this);
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public List<Task> getTasks() { return tasks; }
}
Task.java (updated)
package com.codeverse.week05.task;

import com.codeverse.week05.project.Project;
import jakarta.persistence.*;

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    private boolean completed;

    // Owning side -- this field is what actually creates the
    // "project_id" foreign key column on the tasks table.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "project_id")
    private Project project;

    protected Task() {}

    public Task(String title, Project project) {
        this.title = title;
        this.project = project;
    }

    public void setProject(Project project) { this.project = project; }
    public Project getProject() { return project; }
    public Long getId() { return id; }
    public String getTitle() { return title; }
    public boolean isCompleted() { return completed; }
}

A quick look at @ManyToMany

If a Task can carry several free-form Tags, and a Tag like "urgent" can apply to many tasks, that's a many-to-many — JPA backs it with a hidden join table rather than a foreign key on either side:

Task.java — tags snippet
@ManyToMany
@JoinTable(
    name = "task_tags",
    joinColumns = @JoinColumn(name = "task_id"),
    inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();

We won't build out Tag fully this week, but keep the pattern in mind: @JoinTable on the owning side names the join table and both foreign key columns; the inverse side, if you need to navigate backward from Tag to its Tasks, uses @ManyToMany(mappedBy = "tags") the same way @OneToMany does above.

Building on Week 4's repository

The TaskRepository you wrote last week doesn't change at all here — JpaRepository<Task, Long> already knows how to save and query an entity with a @ManyToOne field. What's new this week is a second repository, ProjectRepository extends JpaRepository<Project, Long>, sitting right alongside it.

2. Lazy Loading & the N+1 Problem

Every JPA association has a fetch type that controls when the related data is actually loaded from the database. Get this wrong and you'll either crash with an exception outside a valid session, or silently fire hundreds of unnecessary queries — the N+1 problem is one of the most common performance bugs in any JPA-based application, and it's easy to introduce without noticing.

LAZY vs. EAGER

Collections (@OneToMany, @ManyToMany) default to FetchType.LAZY: the related rows aren't queried until you actually call a getter like project.getTasks(). Single-valued associations (@ManyToOne, @OneToOne) default to FetchType.EAGER, loading immediately with the owning entity — which is why, in the Task entity above, we explicitly override it to FetchType.LAZY rather than accept the default.

Default to LAZY everywhere

EAGER on a @ManyToOne feels convenient because the data is "just there," but it means every single query for a Task silently joins or re-queries Project too, whether you need it or not. Explicitly setting FetchType.LAZY on every association and fetching what you actually need, when you need it, keeps query cost predictable as the entity graph grows.

LazyInitializationException

A lazy association is loaded through a proxy that needs an open Hibernate session to resolve. If you return an entity from your service, close the session (as Spring does at the end of a @Transactional method), and only then touch task.getProject().getName() — say, while serializing a JSON response — you'll hit a LazyInitializationException. The fix isn't to make everything EAGER; it's to fetch what you need while the session is still open, inside the service method.

The N+1 query problem

Suppose you list every task for a project and print each task's project name:

the problem
List<Task> tasks = taskRepository.findAll();   // 1 query

for (Task task : tasks) {
    System.out.println(task.getProject().getName());  // N more queries!
}
// Total: 1 query to fetch tasks, plus one *additional* query per task
// to lazily resolve its Project -- N+1 queries for N tasks.

For 100 tasks, that's 101 round trips to the database instead of one or two — and it's invisible in code review because each individual line looks harmless. It shows up as a slow endpoint under load, and in the SQL log as a burst of nearly identical SELECT ... FROM projects WHERE id = ? statements.

Fixing it: JOIN FETCH and @EntityGraph

Both techniques tell Hibernate to pull the association in the same query instead of a follow-up one. JOIN FETCH in a JPQL query is explicit and easy to read:

TaskRepository.java
public interface TaskRepository extends JpaRepository<Task, Long> {

    @Query("SELECT t FROM Task t JOIN FETCH t.project WHERE t.project.id = :projectId")
    List<Task> findByProjectIdWithProject(@Param("projectId") Long projectId);
}

@EntityGraph achieves the same result declaratively, without hand-writing JPQL, and reads well on a derived query method:

TaskRepository.java — @EntityGraph alternative
public interface TaskRepository extends JpaRepository<Task, Long> {

    @EntityGraph(attributePaths = "project")
    List<Task> findByProjectId(Long projectId);
}

Either way, one query comes back with tasks and their projects already populated — touching task.getProject().getName() afterward costs nothing extra.

3. Transactions with @Transactional

A transaction is a unit of work that either commits completely or rolls back completely — no partial writes. JPA and Hibernate already wrap each individual repository call in a short transaction, but as soon as an operation spans more than one write, you need to draw that boundary yourself.

Where @Transactional belongs

Put @Transactional on service-layer methods, not on repository methods (Spring Data already manages transactions for individual repository calls) and not on controllers (an HTTP-layer concern has no business defining a database transaction boundary). The service method is where you can see the whole multi-step operation — moving a task between projects, say — and that's the natural place to say "all of this succeeds together, or none of it does."

TaskService.java
package com.codeverse.week05.task;

import com.codeverse.week05.project.Project;
import com.codeverse.week05.project.ProjectRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TaskService {

    private final TaskRepository taskRepository;
    private final ProjectRepository projectRepository;

    public TaskService(TaskRepository taskRepository, ProjectRepository projectRepository) {
        this.taskRepository = taskRepository;
        this.projectRepository = projectRepository;
    }

    @Transactional(readOnly = true)
    public List<Task> tasksForProject(Long projectId) {
        return taskRepository.findByProjectId(projectId);
    }

    @Transactional
    public Task moveTaskToProject(Long taskId, Long newProjectId) {
        Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new TaskNotFoundException(taskId));
        Project newProject = projectRepository.findById(newProjectId)
            .orElseThrow(() -> new ProjectNotFoundException(newProjectId));

        task.setProject(newProject);
        // No explicit save() call needed: within an open transaction,
        // Hibernate tracks this managed entity and flushes the UPDATE
        // automatically at commit ("dirty checking").
        return task;
    }
}

readOnly = true

@Transactional(readOnly = true) on a pure query method is a hint to Hibernate that it can skip dirty-checking overhead and, depending on the driver, route to a read replica — a small, free optimization for methods that never write.

Rollback behavior

By default, Spring rolls back a transaction automatically when the method throws an unchecked exception (any RuntimeException or Error) — which is why TaskNotFoundException above should be a RuntimeException, not a checked one. Checked exceptions do not trigger a rollback unless you explicitly declare @Transactional(rollbackFor = SomeCheckedException.class), a frequent source of "the error was handled, but the bad data still committed" bugs.

4. Schema Migrations with Flyway

Week 4 likely relied on spring.jpa.hibernate.ddl-auto=update to let Hibernate generate and adjust tables from your entities automatically. It's fast to start with — and genuinely dangerous the moment more than one person, or more than one environment, is involved.

Why ddl-auto=update doesn't scale to a real project

update inspects your entities and tries to reconcile the live schema to match, but it has no concept of history: it can't reliably rename a column (it'll add a new one and leave the old one behind), it can silently diverge between your machine and a teammate's, and it has no safe way to run a data backfill alongside a structural change. In production, the account with a schema-altering role is usually not one you want your running application to hold at all.

Versioned migration files

Flyway replaces "let Hibernate guess" with plain, ordered SQL files under src/main/resources/db/migration, each one a permanent, numbered record of a schema change:

project layout
src/main/resources/db/migration/
├── V1__create_tasks_table.sql
├── V2__create_projects_table.sql
└── V3__add_project_id_to_tasks.sql

The naming convention matters: V<version>__<description>.sql (two underscores). Flyway tracks which versions have already run in a flyway_schema_history table it creates for itself, and on every startup applies any migration files with a higher version number than the last one it recorded — in order, exactly once, on every environment.

V1__create_tasks_table.sql — retroactively describing Week 4's schema
CREATE TABLE tasks (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE
);
V2__create_projects_table.sql
CREATE TABLE projects (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(255) NOT NULL
);
V3__add_project_id_to_tasks.sql
ALTER TABLE tasks
    ADD COLUMN project_id BIGINT;

ALTER TABLE tasks
    ADD CONSTRAINT fk_tasks_project
    FOREIGN KEY (project_id) REFERENCES projects (id);

With Flyway on the classpath (spring-boot-starter-jdbc plus the flyway-core dependency), switch ddl-auto to a mode that only validates rather than mutates the schema, and let Flyway own every structural change from here on:

application.properties
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=true

validate still checks that your entity mappings match the real schema at startup and fails fast if they've drifted — you get Hibernate's safety net without handing it write access to your table definitions.

Looking ahead to Week 8

Right now there's no automated way to prove these migrations actually apply cleanly to a fresh database. Week 8 introduces Testcontainers, which spins up a real disposable Postgres instance for your tests to run every Flyway migration against — catching a broken migration file long before it reaches a shared environment.

Looking ahead to Week 11

JOIN FETCH and @EntityGraph solve N+1 by collapsing queries at the database level, but for data that's read far more often than it changes -- like a project's name -- Week 11's caching module covers a complementary fix: avoiding the repeated round trip to the database entirely.

5. Hands-on Exercise

Hands-on

Add projects, migrate the schema, and fix an N+1 bug

Extend Week 4's Task API with a real relationship, replace ddl-auto with Flyway, and put the fixes from this week's sections to work.

Requirements:

  1. Add a Project entity with a one-to-many relationship to Task as shown in Section 1 (Task owns the @ManyToOne; Project declares the inverse @OneToMany(mappedBy = "project")), plus a ProjectRepository.
  2. Add Flyway to the project and write V1__create_tasks_table.sql, retroactively describing the tasks table exactly as Week 4 left it, followed by V2__create_projects_table.sql and V3__add_project_id_to_tasks.sql for the new relationship. Switch spring.jpa.hibernate.ddl-auto to validate.
  3. Build a GET /projects/{id}/tasks endpoint that intentionally starts out N+1: fetch the project's tasks with a plain findByProjectId, then loop over them building a response that includes each task's project name.
  4. Fix the N+1 query you just introduced using either a JOIN FETCH query or an @EntityGraph-annotated repository method, and confirm in the SQL log (spring.jpa.show-sql=true) that the endpoint now issues one query instead of one-per-task.
  5. Add a POST /tasks/{id}/move endpoint backed by a @Transactional service method, moveTaskToProject(Long taskId, Long newProjectId), that looks up both the task and the target project and reassigns the task — with the whole operation rolling back together if either lookup fails.
Hint

Write and run V1 against a fresh database before adding V2 and V3 — if V1 doesn't exactly match what Hibernate would have generated for Week 4's Task entity, ddl-auto=validate will fail loudly at startup, which is the whole point of switching to it.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is FetchType.LAZY the safer default for collection associations?

Loading a collection eagerly means every query for the owning entity also loads every related row, whether or not you need them — a Project with a thousand tasks would drag all thousand into memory just to display the project's name. Lazy loading defers that cost until you actually call the collection's getter, so the price is only paid by code paths that genuinely need the data, and you can opt into an eager, single-query fetch explicitly (with JOIN FETCH or @EntityGraph) exactly where it's needed instead of paying it everywhere by default.

Q2

What causes the N+1 query problem, and how does JOIN FETCH fix it?

It happens when you run one query to fetch a list of N entities, then access a lazy association on each one inside a loop — each access triggers its own separate query to resolve that entity's association, so a list of 100 tasks results in 1 query for the tasks plus 100 more to resolve each task's project, 101 round trips total instead of one. JOIN FETCH rewrites the original query to pull the association in via a SQL join in the same round trip, so the related data arrives already populated and touching it afterward costs nothing extra — collapsing N+1 queries down to one.

Q3

Why does @Transactional belong on the service layer rather than the repository or controller?

Spring Data repositories already wrap each individual method call in its own short transaction, so adding @Transactional there is redundant; putting it on a controller mixes an HTTP-layer concern with a database concern it has no business owning. The service layer is where a multi-step business operation -- like looking up a task, looking up a project, and reassigning one to the other -- is visible as a single unit, which is exactly the boundary that needs to succeed or fail as a whole; that's also the layer where readOnly = true can be applied precisely to pure-query methods.

Q4

Why are versioned Flyway migrations safer than ddl-auto=update for a team project?

ddl-auto=update infers schema changes from your current entities with no memory of history, so it can't safely rename a column, can't run a data backfill alongside a structural change, and can quietly drift between a teammate's database and yours since nothing records what changed or when. Flyway migrations are explicit, ordered SQL files checked into source control and tracked in a flyway_schema_history table, so every environment -- your machine, a teammate's, staging, production -- applies the exact same sequence of changes in the exact same order, and the full history of how the schema got to its current shape is permanently visible and reviewable in a pull request.