Spring Boot by Building · Chapter 10

Relationships and transactions

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.

Chapter 10 · You build with reference patches · 2 missions · Security prep


Choose your route

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.

Learning goal

Shared core

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.

Learn deeply: build evidence

Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.

Prepare for interviews: explain the trade-off

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.

// the concept

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.

Section 1The new TaskFlow model

AppUser1 → many Project1 → many Task
relationship shape
@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;
}
read it as plain Java first
  • Project has an AppUser owner. JPA turns that reference into owner_id.
  • Task has a Project project. JPA turns that reference into project_id.
  • You do not need bidirectional lists yet. Start with the direction your code actually queries.

Section 2Mission: add projects and task ownership

Mission 10.1 you build

Move tasks under projects

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.
  • Creating a task for a missing project returns 404.
  • No controller directly calls a repository.

Likely files: model/Project.java, ProjectRepository.java, TaskRepository.java, ProjectService.java, TaskService.java, ProjectController.java.

Commit checkpoint: feat: add project task relationships

Hint 1 — repository method name
If Task has a field named project, Spring Data can derive findByProjectId(Long projectId).
Hint 2 — service flow
Create flow: find the project first, throw if missing, construct task, set project, save task.
Reference patch — service and repository shape
TaskRepository.java + TaskService.java
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);
    }
}

Section 3Transactions: one service method, one unit of work

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.

ProjectService.java
@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.


Section 4Mission: prepare TaskFlow for security

Mission 10.2 you build

Add users and owner-scoped project queries

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.
  • Project listing uses a repository query such as findByOwnerId.

Commit checkpoint: feat: scope projects by owner

Hint — future security connection
The URL has userId only temporarily. After Basic Auth/JWT, the current user comes from SecurityContext, but the service rule stays the same: query by owner.
Reference patch — owner query
ProjectRepository.java + ProjectService.java
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);
}

Section 5N+1 awareness without panic

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.

// practical rule

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.


Notebook Summary

Recall prompts

// next

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.

TaskFlow checkpoint

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.

Mission: create a project with its first task safely

Add project ownership, scope reads through a repository query, and make the create-project-plus-first-task operation transactional.

  • A project response contains only its own tasks.
  • A failure creating the first task leaves no partial project.
  • Curl can show owner-scoped project data.

Likely files: model/Project.java, repository/ProjectRepository.java, service/ProjectService.java. Suggested commit: feat: add taskflow project ownership.

Hint 1
Start from the owning side of the relationship.
Hint 2
Place the transaction on the service operation, not the controller.
Hint 3
Use a repository method that expresses the owner constraint.

Before opening the solution: force the second write to fail and inspect what remains.

Reference patch — project transaction
The service coordinates project and first-task persistence inside one transaction; controller code stays transport-only.

Recall before continuing

  1. Which layer owns a transaction boundary?
  2. What should a rollback protect?
  3. Why scope projects by owner?

The next workshop proves the seams with fast service and controller tests before later changes add more cross-cutting behaviour.