# JPA Relationships and @Transactional — ManyToOne, Lazy Loading, N+1 and Business Rules

Source: https://skills101.in/learn/spring-boot/jpa-relationships-transactional — Skills101 free tutorial (Advanced Java & Spring Boot). Updated 2026-11-09.

Real data is connected: a student enrols in many courses, a course has many students. Let's add **enrolments** to the Course API — with a seat limit that must never be broken.

## 1. The data model

![Tables student, enrollment and course: enrollment has student_id and course_id foreign keys, so one student has many enrolments and one course has many enrolments](https://skills101.in/learn-img/spring-boot/jpa-mapping.svg)

Student ↔ Course is **many-to-many**. Instead of `@ManyToMany`, we use a **join entity** `Enrollment` with two `@ManyToOne`s — then we can add fields later (date, fee paid, grade), which real systems always need.

```java
// File: course-api/src/main/java/com/skills101/courses/student/Student.java
package com.skills101.courses.student;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Student {

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

    private String name;

    @Column(unique = true, nullable = false)
    private String email;

    protected Student() {
    }

    public Student(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
}
```

```java
// File: course-api/src/main/java/com/skills101/courses/enrollment/Enrollment.java
package com.skills101.courses.enrollment;

import com.skills101.courses.course.Course;
import com.skills101.courses.student.Student;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

@Entity
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"student_id", "course_id"}))
public class Enrollment {

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)   // many enrollments → one student
    private Student student;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)   // many enrollments → one course
    private Course course;

    protected Enrollment() {
    }

    public Enrollment(Student student, Course course) {
        this.student = student;
        this.course = course;
    }

    public Long getId() { return id; }
    public Student getStudent() { return student; }
    public Course getCourse() { return course; }
}
```

| Relationship | Annotation | Example |
|---|---|---|
| many-to-one | `@ManyToOne` | enrolment → its course (foreign key column) |
| one-to-many | `@OneToMany(mappedBy = "course")` | course → its enrolments (the other side) |
| one-to-one | `@OneToOne` | user → profile |
| many-to-many | `@ManyToMany` or a join entity | student ↔ course |

The `@UniqueConstraint` makes the **database** refuse a second enrolment of the same student in the same course — a safety net under the Java check.

## 2. Lazy loading and the N+1 problem

`fetch = FetchType.LAZY` means the course is loaded only when you touch it. Now imagine listing a student's 20 enrolments and reading each `e.getCourse().getTitle()`: **1 query** for the enrolments + **20 queries** for the courses = the **N+1 problem**, a top cause of slow APIs.

The fix is to load what you need in one query with **JOIN FETCH**:

```java
// File: course-api/src/main/java/com/skills101/courses/enrollment/EnrollmentRepository.java
package com.skills101.courses.enrollment;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {

    long countByCourseId(Long courseId);

    boolean existsByStudentIdAndCourseId(Long studentId, Long courseId);

    // JOIN FETCH loads each enrollment's course in the same query (no N+1 problem)
    @Query("select e from Enrollment e join fetch e.course where e.student.email = :email order by e.id")
    List<Enrollment> findForStudent(String email);
}
```

(Other fixes: `@EntityGraph`, or a query that returns DTOs directly.) Defaults to remember: `@ManyToOne` is EAGER unless you say LAZY; `@OneToMany` is LAZY.

## 3. Business rules in a transaction

```java
// File: course-api/src/main/java/com/skills101/courses/enrollment/EnrollmentException.java
package com.skills101.courses.enrollment;

// A business rule was broken (course full, already enrolled) → 409 Conflict
public class EnrollmentException extends RuntimeException {

    public EnrollmentException(String message) {
        super(message);
    }
}
```

```java
// File: course-api/src/main/java/com/skills101/courses/enrollment/EnrollmentResponse.java
package com.skills101.courses.enrollment;

public record EnrollmentResponse(Long id, String course, String student, long seatsLeft) {
}
```

```java
// File: course-api/src/main/java/com/skills101/courses/enrollment/EnrollmentService.java
package com.skills101.courses.enrollment;

import com.skills101.courses.course.Course;
import com.skills101.courses.course.CourseNotFoundException;
import com.skills101.courses.course.CourseRepository;
import com.skills101.courses.student.Student;
import com.skills101.courses.student.StudentRepository;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class EnrollmentService {

    private final EnrollmentRepository enrollments;
    private final CourseRepository courses;
    private final StudentRepository students;

    public EnrollmentService(EnrollmentRepository enrollments, CourseRepository courses, StudentRepository students) {
        this.enrollments = enrollments;
        this.courses = courses;
        this.students = students;
    }

    @Transactional   // all checks and the insert succeed together, or nothing is saved
    public EnrollmentResponse enroll(Long courseId, String studentEmail) {
        Course course = courses.findById(courseId).orElseThrow(() -> new CourseNotFoundException(courseId));
        Student student = students.findByEmail(studentEmail)
                .orElseThrow(() -> new EnrollmentException("No student profile for " + studentEmail));
        if (enrollments.existsByStudentIdAndCourseId(student.getId(), courseId)) {
            throw new EnrollmentException("You are already enrolled in " + course.getTitle());
        }
        long taken = enrollments.countByCourseId(courseId);
        if (taken >= course.getSeats()) {
            throw new EnrollmentException(course.getTitle() + " is full");
        }
        Enrollment saved = enrollments.save(new Enrollment(student, course));
        return new EnrollmentResponse(saved.getId(), course.getTitle(), student.getName(), course.getSeats() - taken - 1);
    }

    @Transactional(readOnly = true)
    public List<String> myCourses(String studentEmail) {
        return enrollments.findForStudent(studentEmail).stream()
                .map(e -> e.getCourse().getTitle())
                .toList();
    }
}
```

`@Transactional` wraps the method in one database transaction: if anything throws a `RuntimeException`, **everything is rolled back** (like the JDBC transfer in lesson 2, but with one annotation). `readOnly = true` lets the database optimise pure reads.

The seeded course "DSA in Python" has **1 seat**. Asha enrols first:

<!-- expect: course-api/enrolled.json -->
```json
{ "id": 1, "course": "DSA in Python", "student": "Asha Verma", "seatsLeft": 0 }
```

Ravi tries next → **409 Conflict**:

<!-- expect: course-api/course-full.json -->
```json
{
  "title": "Conflict",
  "status": 409,
  "detail": "DSA in Python is full",
  "instance": "/api/courses/2/enrollments"
}
```

Asha's courses (`GET /api/me/courses`):

<!-- expect: course-api/my-courses.json -->
```json
["DSA in Python"]
```

## 4. @Transactional rules interviewers ask about

- It works through a **proxy**: calling a `@Transactional` method from another method **of the same class** skips the proxy, so no transaction starts.
- Only **public** methods are intercepted.
- By default it rolls back on **unchecked** exceptions (`RuntimeException`, `Error`), not checked ones — use `rollbackFor = Exception.class` if needed.
- Put it on the **service** layer, not on controllers or repositories.
- Under heavy load two requests could both see "1 seat left". Production fixes: a pessimistic lock (`@Lock(PESSIMISTIC_WRITE)`), optimistic locking with `@Version`, or a database constraint on a seat counter.

## Common mistakes

- `LazyInitializationException`: touching a lazy relationship after the transaction ended — fetch what you need inside the service (JOIN FETCH).
- Bidirectional relationships serialised to JSON → infinite recursion. Return DTOs.
- Relying only on Java checks for uniqueness — add a database constraint too.

## Quick recap

- Use `@ManyToOne` (LAZY) and a join entity for many-to-many with extra data.
- N+1: one query per row; fix with JOIN FETCH, entity graphs or DTO queries.
- `@Transactional` on service methods: all-or-nothing, rollback on runtime exceptions, proxy rules.


Practice quiz and free certificate: https://skills101.in/learn/spring-boot/jpa-relationships-transactional