Spring Boot by Building · Chapter 10
TaskFlow becomes a real multi-entity app: users own projects, projects contain tasks, and service methods protect the rule that work belongs to the right owner. This is where JPA stops being CRUD and starts becoming application design.
Study the same Relationships & Transactions explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.
The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.
A relationship is an object reference that becomes a foreign key. A transaction is a promise that a service method's database changes commit together or roll back together. Put together, they let you express business rules in Java while the database keeps the data honest.
@Entity
class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
}
@Entity
class Project {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@ManyToOne(optional = false)
private AppUser owner;
}
@Entity
class Task {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private boolean done;
@ManyToOne(optional = false)
private Project project;
}
Project has an AppUser owner. JPA turns that reference into owner_id.Task has a Project project. JPA turns that reference into project_id.Goal. A task is created inside a project, and listing tasks for a project returns only that project's tasks.
POST /projects creates a project with a name.POST /projects/{projectId}/tasks creates a task under that project.GET /projects/{projectId}/tasks lists only tasks for that project.Likely files: model/Project.java, ProjectRepository.java,
TaskRepository.java, ProjectService.java, TaskService.java,
ProjectController.java.
Commit checkpoint: feat: add project task relationships
Task has a field named project, Spring Data can derive
findByProjectId(Long projectId).public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByProjectId(Long projectId);
}
@Service
public class TaskService {
private final TaskRepository tasks;
private final ProjectRepository projects;
public TaskService(TaskRepository tasks, ProjectRepository projects) {
this.tasks = tasks;
this.projects = projects;
}
public Task createInProject(Long projectId, String title, boolean done) {
Project project = projects.findById(projectId).orElseThrow(() -> new ProjectNotFoundException(projectId));
Task task = new Task();
task.setTitle(title);
task.setDone(done);
task.setProject(project);
return tasks.save(task);
}
public List<Task> listForProject(Long projectId) {
if (!projects.existsById(projectId)) {
throw new ProjectNotFoundException(projectId);
}
return tasks.findByProjectId(projectId);
}
}
Use @Transactional when one service method changes multiple rows and those changes must succeed or
fail together. Do not scatter it on every class by habit; put it where the business operation needs one unit.
@Transactional
public Project createProjectWithFirstTask(Long ownerId, String projectName, String firstTaskTitle) {
AppUser owner = users.findById(ownerId).orElseThrow(() -> new UserNotFoundException(ownerId));
Project project = new Project();
project.setName(projectName);
project.setOwner(owner);
Project saved = projects.save(project);
Task task = new Task();
task.setTitle(firstTaskTitle);
task.setProject(saved);
tasks.save(task);
return saved;
}
If saving the task fails, the project insert should roll back too. That is the transaction boundary.
Goal. Projects belong to users now, even before login exists. Chapter 11 will
replace the temporary ownerId parameter with the authenticated user.
POST /users creates an AppUser with unique email.POST /users/{userId}/projects creates a project owned by that user.GET /users/{userId}/projects returns only that user's projects.findByOwnerId.Commit checkpoint: feat: scope projects by owner
userId only temporarily. After Basic Auth/JWT, the current user comes
from SecurityContext, but the service rule stays the same: query by owner.public interface ProjectRepository extends JpaRepository<Project, Long> {
List<Project> findByOwnerId(Long ownerId);
}
public List<Project> listForOwner(Long ownerId) {
if (!users.existsById(ownerId)) {
throw new UserNotFoundException(ownerId);
}
return projects.findByOwnerId(ownerId);
}
JPA can accidentally run one query for the parent rows and then one query per child row. That is the N+1 problem. You do not need to master performance tuning today; you only need to recognize the smell.
Keep response DTOs small. Do not automatically serialize whole entity graphs. When a screen needs a nested view, write a query or service method for that exact view instead of returning every relationship blindly.
@ManyToOne is the first relationship to reach for: many tasks belong to one project.@Transactional belongs on service operations that must commit or roll back as one unit.Chapter 11 — Security and Basic Auth. Now that projects have owners, the API can stop trusting
userId in the URL and start using the authenticated user.
Current state: tasks are loose records behind a safe boundary. Outcome: project ownership and an all-or-nothing service operation. Prerequisite: JPA associations. Java bridge: object references model relationships before JPA maps them. Estimate: 40 minutes.
Add project ownership, scope reads through a repository query, and make the create-project-plus-first-task operation transactional.
Likely files: model/Project.java, repository/ProjectRepository.java, service/ProjectService.java. Suggested commit: feat: add taskflow project ownership.
Before opening the solution: force the second write to fail and inspect what remains.
The next workshop proves the seams with fast service and controller tests before later changes add more cross-cutting behaviour.