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.
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; }
}
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:
@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.
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.
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:
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:
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:
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."
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:
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.
CREATE TABLE tasks (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title VARCHAR(255) NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE projects (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
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:
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.
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.
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
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:
- Add a
Projectentity with a one-to-many relationship toTaskas shown in Section 1 (Taskowns the@ManyToOne;Projectdeclares the inverse@OneToMany(mappedBy = "project")), plus aProjectRepository. - Add Flyway to the project and write
V1__create_tasks_table.sql, retroactively describing thetaskstable exactly as Week 4 left it, followed byV2__create_projects_table.sqlandV3__add_project_id_to_tasks.sqlfor the new relationship. Switchspring.jpa.hibernate.ddl-autotovalidate. - Build a
GET /projects/{id}/tasksendpoint that intentionally starts out N+1: fetch the project's tasks with a plainfindByProjectId, then loop over them building a response that includes each task's project name. - Fix the N+1 query you just introduced using either a
JOIN FETCHquery 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. - Add a
POST /tasks/{id}/moveendpoint backed by a@Transactionalservice 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.
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?
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?
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?
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?
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.