Project: Course Enrolment REST API
Time to put the whole course together in one project. You will build the back end for a small computer-training institute in Kochi: it offers courses with a limited number of seats, students register, and students enrol in courses. The rules are the kind every real business has: a student cannot enrol twice in the same course, a full course refuses new students, a cancelled enrolment frees its seat, and every error comes back in one clear format.
You will use almost every lesson: Initializr, entities and relationships, repositories with derived and custom queries, a transactional service layer, DTO records, validation, ProblemDetail error handling, logging, and three kinds of tests. Every output below comes from running the finished project.
What we are building
| Method and URL | Purpose | Success |
|---|---|---|
GET /api/courses | List courses with seats left | 200 |
POST /api/courses | Add a course | 201 |
POST /api/students | Register a student | 201 |
GET /api/students/{id}/enrolments | A student's enrolments | 200 |
POST /api/enrolments | Enrol a student in a course | 201 |
DELETE /api/enrolments/{id} | Cancel an enrolment | 204 |
Step 1: Create the project
Generate a Maven project on start.spring.io with group in.college, artifact enrolment, Java 21 or 25, and the dependencies Spring Web, Spring Data JPA, H2 Database, Validation and Spring Boot Actuator. Then set up application.properties:
spring.application.name=enrolment
spring.datasource.url=jdbc:h2:mem:enrolment
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.defer-datasource-initialization=true
spring.jpa.open-in-view=false
spring.mvc.problemdetails.enabled=true
management.endpoints.web.exposure.include=health,infoThe code is organised by feature: course, student, enrolment and common packages under in.college.enrolment.
Step 2: The entities
Course and Student are simple entities like the ones in JPA Entities & Repositories. A course has a unique code (such as JAVA-101), a title, a fee in whole rupees and a number of seats; a student has a name and a unique email. The interesting entity is Enrolment, which links them. Instead of a @ManyToMany, it is an entity of its own, because the link carries data: the date and a status.
@Entity
@Table(name = "enrolments")
public class Enrolment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "course_id")
private Course course;
@Column(nullable = false)
private LocalDate enrolledOn;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private EnrolmentStatus status = EnrolmentStatus.ACTIVE;
protected Enrolment() {
}
public Enrolment(Student student, Course course, LocalDate enrolledOn) {
this.student = student;
this.course = course;
this.enrolledOn = enrolledOn;
}
public void cancel() {
this.status = EnrolmentStatus.CANCELLED;
}
// getters
}EnrolmentStatus is an enum with ACTIVE and CANCELLED. Cancelling does not delete the row: the institute keeps its history, and the seat count simply ignores cancelled enrolments. The cancel() method puts that rule inside the entity instead of letting any code set any status.
Sample data goes in data.sql. The Spring Boot course has only two seats so that the "full" rule is easy to try:
insert into courses (code, title, fee, seats) values
('JAVA-101', 'Core Java', 12000, 30),
('SPRING-201', 'Spring Boot APIs', 15000, 2),
('SQL-101', 'SQL and MySQL', 8500, 25);
insert into students (name, email) values
('Aarav Mehta', 'aarav@student.example'),
('Priya Nair', 'priya@student.example'),
('Kabir Singh', 'kabir@student.example');Step 3: Repositories
Derived queries count seats and detect duplicates; one JPQL query loads a student's enrolments together with their courses, avoiding the N+1 problem from Entity Relationships:
public interface EnrolmentRepository extends JpaRepository<Enrolment, Long> {
long countByCourseIdAndStatus(Long courseId, EnrolmentStatus status);
boolean existsByStudentIdAndCourseIdAndStatus(Long studentId, Long courseId, EnrolmentStatus status);
@Query("""
select e from Enrolment e
join fetch e.course
join fetch e.student
where e.student.id = :studentId
order by e.enrolledOn, e.id
""")
List<Enrolment> findForStudent(@Param("studentId") long studentId);
}CourseRepository adds findByCode and existsByCode; StudentRepository adds existsByEmailIgnoreCase.
Step 4: DTOs and validation
Requests and responses are records. Validation annotations describe what a valid request looks like:
public record EnrolmentRequest(
@NotNull @Positive Long studentId,
@NotBlank String courseCode) {}
public record EnrolmentResponse(Long id, String student, String courseCode, String courseTitle,
int fee, LocalDate enrolledOn, EnrolmentStatus status) {
public static EnrolmentResponse from(Enrolment e) {
return new EnrolmentResponse(e.getId(), e.getStudent().getName(), e.getCourse().getCode(),
e.getCourse().getTitle(), e.getCourse().getFee(), e.getEnrolledOn(), e.getStatus());
}
}The course request uses @Pattern(regexp = "[A-Z]+-\\d{3}", message = "must look like JAVA-101") for the code and @Min(1) @Max(200) for seats; the student request uses @NotBlank and @Email.
Step 5: The service, where the rules live
@Service
@Transactional(readOnly = true)
public class EnrolmentService {
private static final Logger log = LoggerFactory.getLogger(EnrolmentService.class);
// constructor injection of EnrolmentRepository, StudentRepository, CourseRepository and Clock
@Transactional
public EnrolmentResponse enrol(EnrolmentRequest request) {
Student student = students.findById(request.studentId())
.orElseThrow(() -> new NotFoundException("No student with id " + request.studentId()));
Course course = courses.findByCode(request.courseCode())
.orElseThrow(() -> new NotFoundException("No course with code " + request.courseCode()));
if (enrolments.existsByStudentIdAndCourseIdAndStatus(student.getId(), course.getId(), EnrolmentStatus.ACTIVE)) {
throw new ConflictException(student.getName() + " is already enrolled in " + course.getCode());
}
long taken = enrolments.countByCourseIdAndStatus(course.getId(), EnrolmentStatus.ACTIVE);
if (taken >= course.getSeats()) {
throw new ConflictException("Course " + course.getCode() + " is full (" + course.getSeats() + " seats)");
}
Enrolment saved = enrolments.save(new Enrolment(student, course, LocalDate.now(clock)));
log.info("Enrolled student {} in {} ({} of {} seats taken)",
student.getId(), course.getCode(), taken + 1, course.getSeats());
return EnrolmentResponse.from(saved);
}
@Transactional
public void cancel(long enrolmentId) {
Enrolment enrolment = enrolments.findById(enrolmentId)
.orElseThrow(() -> new NotFoundException("No enrolment with id " + enrolmentId));
enrolment.cancel();
}
}The checks run in a fixed order: does the student exist, does the course exist, is the student already enrolled, is there a seat. Each failure throws a small domain exception, NotFoundException or ConflictException. cancel() needs no save(), because dirty checking writes the new status when the transaction commits. The mapping to EnrolmentResponse happens inside the transaction, so the lazy student and course can be read safely.
Step 6: Controllers and errors
The controllers stay thin:
@RestController
@RequestMapping("/api/enrolments")
public class EnrolmentController {
private final EnrolmentService service;
public EnrolmentController(EnrolmentService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<EnrolmentResponse> enrol(@Valid @RequestBody EnrolmentRequest request) {
EnrolmentResponse created = service.enrol(request);
return ResponseEntity.created(URI.create("/api/enrolments/" + created.id())).body(created);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancel(@PathVariable long id) {
service.cancel(id);
return ResponseEntity.noContent().build();
}
}ApiExceptionHandler is the class from Exception Handling with ProblemDetail, with two handlers: NotFoundException becomes 404 with the title "Not found", ConflictException becomes 409 with the title "Cannot complete request", and the overridden handleMethodArgumentNotValid adds the errors map.
Step 7: Try the whole flow
Start the app with ./mvnw spring-boot:run and call it. First the courses, then a few enrolments (one result per line, headers left out):
GET /api/courses -> [{"code":"JAVA-101","title":"Core Java","fee":12000,"seats":30,"seatsLeft":30},{"code":"SPRING-201","title":"Spring Boot APIs","fee":15000,"seats":2,"seatsLeft":2},{"code":"SQL-101","title":"SQL and MySQL","fee":8500,"seats":25,"seatsLeft":25}]
POST /api/students Meera Iyer -> 201 {"id":4,"name":"Meera Iyer","email":"meera@student.example"}
POST /api/enrolments student 1 SPRING-201 -> 201 {"id":1,"student":"Aarav Mehta","courseCode":"SPRING-201","courseTitle":"Spring Boot APIs","fee":15000,"enrolledOn":"2026-11-02","status":"ACTIVE"}
POST /api/enrolments student 1 again -> {"detail":"Aarav Mehta is already enrolled in SPRING-201","instance":"/api/enrolments","status":409,"title":"Cannot complete request"}
POST /api/enrolments student 2 SPRING-201 -> {"id":2,"student":"Priya Nair","courseCode":"SPRING-201","courseTitle":"Spring Boot APIs","fee":15000,"enrolledOn":"2026-11-02","status":"ACTIVE"}
POST /api/enrolments student 4 SPRING-201 -> {"detail":"Course SPRING-201 is full (2 seats)","instance":"/api/enrolments","status":409,"title":"Cannot complete request"}
POST /api/enrolments student 4 RUST-999 -> {"detail":"No course with code RUST-999","instance":"/api/enrolments","status":404,"title":"Not found"}
DELETE /api/enrolments/1 -> 204
POST /api/enrolments student 4 SPRING-201 -> {"id":4,"student":"Meera Iyer","courseCode":"SPRING-201","courseTitle":"Spring Boot APIs","fee":15000,"enrolledOn":"2026-11-02","status":"ACTIVE"}Aarav's cancellation freed a seat, so Meera could take it. Invalid input is caught before the service runs:
curl -X POST http://localhost:8080/api/courses -H "Content-Type: application/json" -d '{"code": "react-301", "title": "React", "fee": 14000, "seats": 0}'{"detail":"Invalid request content.","instance":"/api/courses","status":400,"title":"Invalid request","errors":{"code":"must look like JAVA-101","seats":"must be greater than or equal to 1"}}The log records each enrolment with the seat count, which is exactly what the office staff will ask about:
INFO 9000 --- [enrolment] [nio-8080-exec-3] i.c.e.enrolment.EnrolmentService : Enrolled student 1 in SPRING-201 (1 of 2 seats taken) INFO 9000 --- [enrolment] [nio-8080-exec-1] i.c.e.enrolment.EnrolmentService : Enrolled student 2 in SPRING-201 (2 of 2 seats taken)
Step 8: Tests
The rules that cost the institute money if they break are "no double enrolment" and "no enrolment beyond the seats". Test them at three levels.
A unit test of the service with Mockito checks each rule in milliseconds:
@Test
void refusesWhenCourseIsFull() {
when(enrolments.countByCourseIdAndStatus(2L, EnrolmentStatus.ACTIVE)).thenReturn(2L);
assertThatThrownBy(() -> service.enrol(new EnrolmentRequest(2L, "SPRING-201")))
.isInstanceOf(ConflictException.class)
.hasMessage("Course SPRING-201 is full (2 seats)");
verify(enrolments, never()).save(any());
}The same class tests a successful enrolment (with a fixed Clock, so the date is always 2 November 2026), a duplicate enrolment and an unknown course. A @WebMvcTest checks that missing fields give a 400 with errors.courseCode and that a ConflictException becomes application/problem+json with status 409. A @DataJpaTest checks that cancelled enrolments do not count towards seats. Finally, one @SpringBootTest runs the full story through MockMvc:
@Test
void secondSeatFillsTheCourseAndThirdStudentIsRefused() {
assertThat(enrol(1, "SPRING-201")).hasStatus(HttpStatus.CREATED);
assertThat(enrol(2, "SPRING-201")).hasStatus(HttpStatus.CREATED);
assertThat(enrol(3, "SPRING-201"))
.hasStatus(HttpStatus.CONFLICT)
.bodyJson().extractingPath("$.detail").isEqualTo("Course SPRING-201 is full (2 seats)");
assertThat(mvc.get().uri("/api/courses"))
.hasStatusOk()
.bodyJson().extractingPath("$[1].seatsLeft").isEqualTo(0);
}enrol(...) is a small helper that builds the POST request. Run everything:
./mvnw test[INFO] Running in.college.enrolment.enrolment.EnrolmentControllerTest [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 20.44 s -- in in.college.enrolment.enrolment.EnrolmentControllerTest [INFO] Running in.college.enrolment.enrolment.EnrolmentRepositoryTest [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.95 s -- in in.college.enrolment.enrolment.EnrolmentRepositoryTest [INFO] Running in.college.enrolment.enrolment.EnrolmentServiceTest [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.501 s -- in in.college.enrolment.enrolment.EnrolmentServiceTest [INFO] Running in.college.enrolment.EnrolmentApplicationTests [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.660 s -- in in.college.enrolment.EnrolmentApplicationTests [INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS
Ideas to extend it
- Concurrency: two students could take the last seat at the same moment, because counting and saving are separate steps. Add a version column with
@Version(optimistic locking) toCourse, or a pessimistic lock with@Lockon the course query, and test it. - Security: office staff create courses (role
STAFF), students enrol themselves using a JWT whose subject is their id, following Spring Security Basics and JWT & Token-based APIs. - MySQL and Flyway: move the schema to migration scripts and add a
prodprofile, as in Database Setup. - Paging: page the course list with
Pageablewhen the institute grows. - Docker: build an image and run it with MySQL in Docker Compose, as in Docker & Deployment.
Key points
- Model a relationship that carries data (date, status) as its own entity with two
@ManyToOnefields. - Put business rules in a transactional service that throws domain exceptions; keep controllers thin.
- Use request and response records with validation, and map entities inside the transaction.
- One
@RestControllerAdviceturns domain exceptions and validation failures into consistent ProblemDetail responses. - Test rules with fast unit tests, the web contract with
@WebMvcTest, queries with@DataJpaTest, and one end-to-end flow with@SpringBootTest.
Exercise
Add a waiting list. When a course is full, instead of returning 409, save the enrolment with a new status WAITING and return 202 Accepted. When an active enrolment is cancelled, the oldest WAITING enrolment for that course becomes ACTIVE. Describe the changes to the enum, the repository, the service and the controller, and write the new cancel() method.
Show solution
Add WAITING to EnrolmentStatus, a promote() method on Enrolment that sets the status to ACTIVE, and a derived query that finds the oldest waiting enrolment:
Optional<Enrolment> findFirstByCourseIdAndStatusOrderByEnrolledOnAscIdAsc(Long courseId, EnrolmentStatus status);In enrol(), replace the "full" exception with new Enrolment(...) saved with status WAITING, and let the controller return ResponseEntity.accepted().body(response) when the response status is WAITING. Then:
@Transactional
public void cancel(long enrolmentId) {
Enrolment enrolment = enrolments.findById(enrolmentId)
.orElseThrow(() -> new NotFoundException("No enrolment with id " + enrolmentId));
boolean wasActive = enrolment.getStatus() == EnrolmentStatus.ACTIVE;
enrolment.cancel();
if (wasActive) {
enrolments.findFirstByCourseIdAndStatusOrderByEnrolledOnAscIdAsc(
enrolment.getCourse().getId(), EnrolmentStatus.WAITING)
.ifPresent(Enrolment::promote);
}
}Both changes happen in one transaction, so a seat is never lost between the cancellation and the promotion. Ordering by date and then id gives a fair first-come, first-served queue even when several students joined on the same day.
Quick Quiz
1. Why is Enrolment an entity instead of a @ManyToMany between students and courses?
A join entity can carry extra columns and behaviour such as cancel().
2. Why does cancel() not call enrolments.save(...)?
Managed entities are saved automatically when the transaction commits.
3. Which status code does the API return when a course is full?
The request is valid but conflicts with the current state of the course.
4. Which test type checks that missing fields produce errors.courseCode in the response?
Validation and error mapping belong to the web layer.
Learn Spring Boot with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
