Advanced Java & Spring Boot · Lesson 8 of 16 · Updated ·
JPA Relationships and @Transactional — ManyToOne, Lazy Loading, N+1 and Business Rules
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
Student ↔ Course is many-to-many. Instead of @ManyToMany, we use a join entity Enrollment with two @ManyToOnes — then we can add fields later (date, fee paid, grade), which real systems always need.
// 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; }
}
// 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:
// 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
// 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);
}
}
// 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) {
}
// 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 -->{ "id": 1, "course": "DSA in Python", "student": "Asha Verma", "seatsLeft": 0 }
Ravi tries next → 409 Conflict:
<!-- expect: course-api/course-full.json -->{
"title": "Conflict",
"status": 409,
"detail": "DSA in Python is full",
"instance": "/api/courses/2/enrollments"
}
Asha's courses (GET /api/me/courses):
["DSA in Python"]
4. @Transactional rules interviewers ask about
- It works through a proxy: calling a
@Transactionalmethod 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 — userollbackFor = Exception.classif 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.
@Transactionalon service methods: all-or-nothing, rollback on runtime exceptions, proxy rules.