EthicsComputerCourses
Join training +91 90532 10052

Project: Course Enrolment REST API

Spring Boot · Lesson 29 of 29 Advanced 9 min read Updated

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 URLPurposeSuccess
GET /api/coursesList courses with seats left200
POST /api/coursesAdd a course201
POST /api/studentsRegister a student201
GET /api/students/{id}/enrolmentsA student's enrolments200
POST /api/enrolmentsEnrol a student in a course201
DELETE /api/enrolments/{id}Cancel an enrolment204

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:

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,info

The 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.

Java
@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:

SQL
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:

Java
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:

Java
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

Java
@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:

Java
@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):

Output
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:

Terminal
curl -X POST http://localhost:8080/api/courses -H "Content-Type: application/json" -d '{"code": "react-301", "title": "React", "fee": 14000, "seats": 0}'
Output
{"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:

Output
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:

Java
@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:

Java
@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:

Terminal
./mvnw test
Output
[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) to Course, or a pessimistic lock with @Lock on 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 prod profile, as in Database Setup.
  • Paging: page the course list with Pageable when 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 @ManyToOne fields.
  • 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 @RestControllerAdvice turns 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:

Java
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:

Java
@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?

2. Why does cancel() not call enrolments.save(...)?

3. Which status code does the API return when a course is full?

4. Which test type checks that missing fields produce errors.courseCode in the response?

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