Course Enrollment API with Spring Boot
Build a REST API with validation, exception handling, and pagination.
Introduction
30 Second Summary
Think about the last time you registered for a course or workshop online. Behind that simple "Enroll" button is a system managing students, courses, and the relationships between them.
In this project, you will build a REST API that handles course enrollments, validates incoming data, returns structured error messages, and supports paginated course listings.
What You'll Build
A Spring Boot REST API with layered architecture that manages students, courses, and enrollments through validated endpoints with global error handling and pagination.
By the end of this project, you'll have:
- A working REST API with Student, Course, and Enrollment entities connected through proper JPA relationships.
- Automatic request validation using Bean Validation annotations with structured error responses via @ControllerAdvice.
- A paginated and sortable course listing endpoint demonstrating Spring Data's Pageable integration.
- Secret Mission: Add a student transcript endpoint that returns enrollments grouped by status using JPQL and Java Streams.
Are there any prerequisites?
This project assumes you are familiar with the Spring Boot Controller-Service-Repository pattern and JPA entity basics. You should also have MySQL installed and running on your machine.
Set Up the Project
Before you can build any API endpoints, you need a properly configured project with the right dependencies.
In this step, you will scaffold a new Spring Boot project using Spring Initializr, connect it to MySQL, and set up the package structure that will organize your code throughout the project.
In this step, get ready to:
- Scaffold a Spring Boot project from start.spring.io with all required dependencies.
- Configure MySQL connection and application properties.
- Verify the application starts on port 8080 and the database is created.
Scaffold the project from Spring Initializr
Spring Initializr is a web tool that generates a ready-to-go Spring Boot project with the dependencies you choose.
- Open your browser and navigate to start.spring.io.
- Set the project type to Maven.
- Set the language to Java.
- Set the Spring Boot version to 4.0.6.
- Under Project Metadata, set Group to com.ijse.
- Set Artifact to course-enrollment.
- Leave Packaging as Jar and Java version as 21.
- Click ADD DEPENDENCIES and search for each of these five dependencies, adding them one at a time:
- Spring Web MVC (provides the REST controller framework).
- Spring Data JPA (provides database access with JPA).
- MySQL Driver (connects your app to MySQL).
- Lombok (reduces boilerplate code for getters, setters, and constructors).
- Validation (provides Bean Validation annotations for input checking).
- Click GENERATE to download the project as a zip file.
- Extract the zip file to a location on your computer.
- Open IntelliJ IDEA.
- Click File in the top menu bar.
- Click Open.
- Navigate to the extracted course-enrollment folder and click OK.
- Wait for IntelliJ to import the Maven dependencies. You will see a progress bar in the bottom-right corner while it downloads.
What is Spring Initializr?
Spring Initializr (start.spring.io) is a project generator maintained by the Spring team. You select your build tool, Java version, and dependencies, and it produces a ready-to-run project with the correct folder structure and pom.xml configuration.
This saves you from manually setting up Maven coordinates and dependency versions yourself.
IntelliJ stuck on importing?
Make sure you have an active internet connection. IntelliJ needs to download all Maven dependencies from the remote repository.
If the import seems frozen, click View then Tool Windows then Maven and click the refresh icon to re-trigger the import.
Help me troubleshoot my Maven import in IntelliJ
Configure application properties and create the package structure
Your application needs to know how to connect to MySQL. Spring Boot reads connection details from a file called application.properties.
- In the Project sidebar in IntelliJ, expand src then main then resources.
- Open the application.properties file.
- Replace all of the existing content with the following configuration:
spring.application.name=course-enrollment
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/course-enrollment?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=Root@123
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
What does this configuration do?
- server.port=8080 tells Spring Boot to run your API on port 8080.
- spring.datasource.url points to your local MySQL server. The createDatabaseIfNotExist=true parameter means Spring will create the course-enrollment database automatically if it does not exist yet.
- spring.jpa.hibernate.ddl-auto=update tells Hibernate to create or modify database tables to match your entity classes whenever the app starts.
- spring.jpa.show-sql=true prints every SQL statement to the console so you can see what queries your app runs.
- Save application.properties.
Now you will create the package structure that organizes your code into layers.
- In the Project sidebar, expand src then main then java then com.ijse.course_enrollment.
- Right-click on course_enrollment and select New then Package.
- Type controller and press Enter.
- Repeat the same process to create these additional packages: service, repository, entity, dto, constant, exception.
- Inside the service package, create one more sub-package called impl.
Why this package structure?
Each package holds a specific layer of your application. The controller package handles HTTP requests. The service package holds business logic. The repository package talks to the database.
The service/impl sub-package separates service interfaces from their implementations. This pattern lets you swap implementations without changing the rest of your code.
Don't see the package option?
Make sure you are right-clicking on the course_enrollment folder specifically, not on a file. The New then Package option only appears when right-clicking on a package or source root folder.
Help me create packages in IntelliJ IDEA
✔️ Awesome, I've got everything!
Great. Make sure you have saved application.properties and that all eight packages are visible in the sidebar.
ⓧ I'd like to double check the full code
Here is the complete application.properties file:
spring.application.name=course-enrollment
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/course-enrollment?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=Root@123
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
And the auto-generated CourseEnrollmentApplication.java (do not modify this file):
package com.ijse.course_enrollment;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CourseEnrollmentApplication {
public static void main(String[] args) {
SpringApplication.run(CourseEnrollmentApplication.class, args);
}
}
Run the application and verify the database
With the configuration in place, you can now start the application to confirm everything connects properly.
- Make sure your local MySQL server is running.
- In IntelliJ, open CourseEnrollmentApplication.java from the Project sidebar (it is directly inside com.ijse.course_enrollment).
- Click the green play button next to the main method, then click Run 'CourseEnrollmentApplication'.
The application should start within a few seconds. In the Run panel at the bottom, you should see a log line containing Started CourseEnrollmentApplication and the port number 8080.
- Open a terminal or MySQL client and verify the database was created by running:
mysql -u root -pRoot@123 -e "SHOW DATABASES;"
You should see course-enrollment listed in the output. This confirms Spring Boot connected to MySQL and created the database automatically.
Seeing a connection refused error?
This means your MySQL server is not running. Start it before launching the Spring Boot application.
Also check that the username and password in application.properties match your local MySQL credentials. The file uses root with password Root@123. If your MySQL has a different password, update the file to match.
Help me fix my Spring Boot MySQL connection error
Your project is scaffolded and connected to MySQL. Next up, you will create the entity classes that define your database tables and relationships.
Create Entities with Relationships and Enums
Your project is configured and running on port 8080. Now it's time to define the data model that powers your API.
Every REST API needs entities that map to database tables. In this step, you'll create three JPA entities (Student, Course, and Enrollment) along with enum types that represent their statuses.
In this step, get ready to:
- Create enum types for course and enrollment statuses.
- Build the Student and Course entities with JPA annotations.
- Connect Student and Course through an Enrollment join entity.
Create the enums and Student entity
Before building entities, you need enum types to represent fixed sets of values. A course can be ACTIVE or INACTIVE. An enrollment can be ENROLLED, COMPLETED, or DROPPED.
- In IntelliJ IDEA, right-click the entity package inside src/main/java/com/ijse/course_enrollment.
- Select New then Java Class.
- Choose Enum from the kind dropdown and name it CourseStatus.
- Replace the file contents with this code:
package com.ijse.course_enrollment.entity;
public enum CourseStatus {
ACTIVE,
INACTIVE
}
- Create another enum in the same entity package named EnrollmentStatus with this code:
package com.ijse.course_enrollment.entity;
public enum EnrollmentStatus {
ENROLLED,
COMPLETED,
DROPPED
}
What are these enums for?
Enums define a fixed set of allowed values for a field. Instead of storing arbitrary strings like "active" or "Active" in your database, the enum ensures only ACTIVE or INACTIVE are ever stored.
You'll annotate entity fields with @Enumerated(EnumType.STRING) so Hibernate saves the enum name as a readable string in MySQL, not an integer index.
Now create the Student entity. This represents a student who can enroll in courses.
- Create a new Java class in the entity package named Student.
- Replace the file contents with this code:
package com.ijse.course_enrollment.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long studentId;
private String studentName;
private String email;
private String phone;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Enrollment> enrollmentList;
}
What does this code do?
The @Entity annotation tells JPA to map this class to a database table. @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) make studentId an auto-incrementing primary key.
The @OneToMany annotation means one student can have many enrollments. The mappedBy = "student" tells JPA that the Enrollment entity owns the relationship through its student field.
The Lombok annotations (@Data, @AllArgsConstructor, @NoArgsConstructor) auto-generate getters, setters, and constructors so you don't have to write boilerplate.
- Save all three files: CourseStatus.java, EnrollmentStatus.java, and Student.java.
IntelliJ will show a red error on List<Enrollment> because the Enrollment class doesn't exist yet. You'll create it in substep 3.
Seeing unresolved import errors?
Make sure you created all files inside the entity package (com.ijse.course_enrollment.entity). If your package declaration doesn't match, IntelliJ won't resolve the enum references.
Also confirm your pom.xml includes the Lombok dependency and that Lombok annotation processing is enabled in IntelliJ under Settings > Build, Execution, Deployment > Compiler > Annotation Processors.
help me fix unresolved import errors in my entity package
Create the Course entity
The Course entity represents a class that students can enroll in. It uses the CourseStatus enum you just created to track whether a course is active or inactive.
- Create a new Java class in the entity package named Course.
- Replace the file contents with this code:
package com.ijse.course_enrollment.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long courseId;
private String title;
private String description;
private int credits;
@Enumerated(EnumType.STRING)
private CourseStatus status;
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Enrollment> enrollmentList;
}
What does this code do?
The @Enumerated(EnumType.STRING) annotation on the status field tells JPA to store the enum value as a string ("ACTIVE" or "INACTIVE") in the database column, not as an integer ordinal.
Like Student, Course has a @OneToMany relationship to Enrollment with mappedBy = "course". This means Enrollment owns the foreign key pointing back to Course.
- Save Course.java.
You'll still see the red error on List<Enrollment> since the Enrollment entity doesn't exist yet. The next substep resolves this.
CourseStatus not resolving?
If IntelliJ shows CourseStatus in red, check that CourseStatus.java is in the same entity package as Course.java. Since they share the same package, no import is needed.
help me fix CourseStatus not being recognized in my Course entity
Create the Enrollment entity
The Enrollment entity is the join entity that connects Student and Course. Instead of using a simple @ManyToMany annotation, you create a dedicated entity so you can store extra data like the enrollment date and status.
Why a join entity instead of @ManyToMany?
A plain @ManyToMany creates a join table with only two foreign keys. You can't add extra columns like enrollmentDate or status to it.
By creating an Enrollment entity with @ManyToOne to both Student and Course, you get full control over the join table's columns. This is the recommended pattern whenever a relationship carries its own data.
- Create a new Java class in the entity package named Enrollment.
- Replace the file contents with this code:
package com.ijse.course_enrollment.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "enrollments")
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long enrollmentId;
private LocalDateTime enrollmentDate;
@Enumerated(EnumType.STRING)
private EnrollmentStatus status;
@ManyToOne
private Student student;
@ManyToOne
private Course course;
}
What does this code do?
The @Table(name = "enrollments") annotation overrides the default table name. Without it, JPA would name the table "enrollment" (the class name lowercased).
The two @ManyToOne fields create foreign key columns in the enrollments table pointing to the student and course tables. This is the owning side of the relationship that mappedBy references in Student and Course.
The enrollmentDate uses LocalDateTime to record exactly when a student enrolled. The status field tracks progression from ENROLLED to COMPLETED or DROPPED.
- Save Enrollment.java.
- All red errors in Student and Course should now resolve. Run the application by clicking the green play button in IntelliJ (or pressing Shift+F10).
- Check the console output. You should see Hibernate DDL statements creating the student, course, and enrollments tables, followed by "Started CourseEnrollmentApplication" with no errors.
Application won't start?
If you see a BeanCreationException or mapping error, double-check that the mappedBy values match exactly. In Student, mappedBy = "student" must match the field name private Student student in Enrollment. Same for Course with mappedBy = "course".
If MySQL connection fails, confirm your MySQL server is running and the credentials in application.properties are correct (root / Root@123).
help me fix my Spring Boot application startup error with JPA entity mappings
✔️ Awesome, I've got everything!
Great. Double check you've saved all five files in the entity package.
ⓧ I'd like to double check the full code
Here are all five entity files for comparison:
package com.ijse.course_enrollment.entity;
public enum CourseStatus {
ACTIVE,
INACTIVE
}
package com.ijse.course_enrollment.entity;
public enum EnrollmentStatus {
ENROLLED,
COMPLETED,
DROPPED
}
package com.ijse.course_enrollment.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long studentId;
private String studentName;
private String email;
private String phone;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Enrollment> enrollmentList;
}
package com.ijse.course_enrollment.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long courseId;
private String title;
private String description;
private int credits;
@Enumerated(EnumType.STRING)
private CourseStatus status;
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Enrollment> enrollmentList;
}
package com.ijse.course_enrollment.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "enrollments")
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long enrollmentId;
private LocalDateTime enrollmentDate;
@Enumerated(EnumType.STRING)
private EnrollmentStatus status;
@ManyToOne
private Student student;
@ManyToOne
private Course course;
}
Your data model is locked in. Next up, you'll create DTOs for request/response shaping and repositories for database access.
Build DTOs, Constants, and Repositories
Your entities define how data is stored in the database, but you never want to send raw entity objects directly to API consumers.
DTOs (Data Transfer Objects) act as a filter between your internal data model and the outside world. They let you control exactly which fields are exposed, add validation rules, and keep your entity layer decoupled from your API layer.
In this step, get ready to:
- Create DTOs with Bean Validation annotations for input checking.
- Build reusable response constants for consistent API responses.
- Set up Spring Data JPA repositories with a custom native query.
Create DTOs with Bean Validation
Bean Validation lets you declare constraints directly on DTO fields using annotations. When a request comes in, Spring automatically checks these rules before your code ever runs.
- Create a new Java class called StudentDTO.java inside the dto package by right-clicking the dto folder, then selecting New > Java Class.
- Replace the file contents with this code:
package com.ijse.course_enrollment.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StudentDTO {
private long studentId;
@NotBlank(message = "Student name is required")
private String studentName;
@Email(message = "Email must be valid")
private String email;
@Size(min = 10, max = 15, message = "Phone must be between 10 and 15 characters")
private String phone;
}
What do these validation annotations do?
- @NotBlank rejects null values and empty strings. If someone sends a request without a student name, Spring returns an error automatically.
- @Email checks that the string follows a valid email format.
- @Size(min = 10, max = 15) enforces a character length range on the phone field.
These annotations come from the jakarta.validation.constraints package, provided by the spring-boot-starter-validation dependency you added in Step 1.
- Create another Java class called CourseDTO.java in the same dto package with this code:
package com.ijse.course_enrollment.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CourseDTO {
private long courseId;
@NotBlank(message = "Course title is required")
private String title;
private String description;
@Min(value = 1, message = "Credits must be at least 1")
private int credits;
private String status;
}
What does @Min do?
@Min(value = 1) ensures the credits field is at least 1. A course with zero credits would not make sense, so the API rejects it before it reaches the database.
- Create one more class called EnrollmentDTO.java in the dto package with this code:
package com.ijse.course_enrollment.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EnrollmentDTO {
private long enrollmentId;
private long studentId;
private long courseId;
private String enrollmentDate;
private String status;
}
Why no validation on EnrollmentDTO?
Enrollment creation is controlled by the service layer, which validates that the student and course actually exist in the database. Field-level constraints like @NotBlank would not help here because the IDs are numeric references, not user-typed strings.
- Save all three DTO files.
- Confirm that IntelliJ shows no red underlines in any of the DTO files. The imports should resolve cleanly.
Seeing unresolved import errors?
- If jakarta.validation.constraints is red, check that spring-boot-starter-validation is in your pom.xml dependencies. Reload Maven by clicking the refresh icon in the Maven tool window.
- If Lombok annotations are red, make sure the Lombok plugin is enabled in IntelliJ under Settings > Plugins.
Help me fix unresolved imports in my DTOs
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
package com.ijse.course_enrollment.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StudentDTO {
private long studentId;
@NotBlank(message = "Student name is required")
private String studentName;
@Email(message = "Email must be valid")
private String email;
@Size(min = 10, max = 15, message = "Phone must be between 10 and 15 characters")
private String phone;
}
package com.ijse.course_enrollment.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CourseDTO {
private long courseId;
@NotBlank(message = "Course title is required")
private String title;
private String description;
@Min(value = 1, message = "Credits must be at least 1")
private int credits;
private String status;
}
package com.ijse.course_enrollment.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EnrollmentDTO {
private long enrollmentId;
private long studentId;
private long courseId;
private String enrollmentDate;
private String status;
}
Create reusable response constants
Every API endpoint in this project returns the same response structure: a status code, a body, and a message. Defining this structure once as a reusable class keeps your controllers clean and your API responses consistent.
- Create a new Java class called CommonResponse.java inside the constant package with this code:
package com.ijse.course_enrollment.constant;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResponse {
private int status;
private Object body;
private String message;
// Two-arg constructor for responses without a body
public CommonResponse(int status, String message) {
this.status = status;
this.message = message;
}
}
Why two constructors?
The three-arg constructor (generated by @AllArgsConstructor) is for responses that carry data in the body. The two-arg constructor is for simple success or error messages where there is no body to return.
The body field is typed as Object so it can hold any type: a single DTO, a list, a map of validation errors, or a paginated result.
- Create ResponseCode.java in the constant package with this code:
package com.ijse.course_enrollment.constant;
public class ResponseCode {
public static int OPERATION_SUCCESS = 0;
public static int OPERATION_FAILED = 1;
}
- Create ResponseMessage.java in the constant package with this code:
package com.ijse.course_enrollment.constant;
public class ResponseMessage {
public static String SUCCESS_MESSAGE = "Operation successful";
public static String FAILED_MESSAGE = "Operation failed";
}
What do ResponseCode and ResponseMessage do?
These classes hold static values that every controller reuses. Instead of scattering magic numbers like 0 and 1 throughout your code, you reference OPERATION_SUCCESS and OPERATION_FAILED. This makes the code self-documenting.
- Save all three constant files.
- Confirm no red underlines appear in any of the files.
CommonResponse constructor showing an error?
Make sure the two-arg constructor is explicitly written below the fields. Lombok generates the all-args and no-args constructors, but the custom two-arg constructor must be written manually.
Help me fix my CommonResponse constructor error
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in the constant package.
ⓧ I'd like to double check the full code
package com.ijse.course_enrollment.constant;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResponse {
private int status;
private Object body;
private String message;
public CommonResponse(int status, String message) {
this.status = status;
this.message = message;
}
}
package com.ijse.course_enrollment.constant;
public class ResponseCode {
public static int OPERATION_SUCCESS = 0;
public static int OPERATION_FAILED = 1;
}
package com.ijse.course_enrollment.constant;
public class ResponseMessage {
public static String SUCCESS_MESSAGE = "Operation successful";
public static String FAILED_MESSAGE = "Operation failed";
}
Create Spring Data JPA repositories
Repositories are the data access layer in Spring Boot. By extending JpaRepository, you get full CRUD operations and pagination support without writing a single SQL query.
- Create a new Java interface called StudentRepository.java inside the repository package. Right-click the repository folder, select New > Java Class, then choose Interface from the kind dropdown.
- Replace the file contents with this code:
package com.ijse.course_enrollment.repository;
import com.ijse.course_enrollment.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
}
- Create CourseRepository.java as an interface in the same repository package with this code:
package com.ijse.course_enrollment.repository;
import com.ijse.course_enrollment.entity.Course;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CourseRepository extends JpaRepository<Course, Long> {
}
What does JpaRepository give you for free?
By extending JpaRepository<Student, Long>, Spring Data auto-generates implementations for save(), findById(), findAll(), deleteById(), and pagination methods. The second type parameter (Long) matches the primary key type of the entity.
- Create EnrollmentRepository.java as an interface in the repository package with this code:
package com.ijse.course_enrollment.repository;
import com.ijse.course_enrollment.entity.Enrollment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
@Query(value = "SELECT * FROM enrollments WHERE student_student_id = ?1", nativeQuery = true)
List<Enrollment> filterEnrollmentsByStudent(Long studentId);
}
What is this native query doing?
The @Query annotation lets you write custom SQL when the auto-generated methods are not enough. Setting nativeQuery = true means this is raw SQL, not JPQL.
The column name student_student_id is auto-generated by Hibernate from the @ManyToOne private Student student field. It combines the field name (student) with the entity's ID field (studentId) using an underscore.
- Save all three repository files.
- Run the application by clicking the green play button in IntelliJ. It should start on port 8080 with no errors.
Application fails to start?
- Check that each repository interface is in the com.ijse.course_enrollment.repository package. Spring's component scan needs them under the base package.
- Verify that the entity imports match. For example, EnrollmentRepository must import com.ijse.course_enrollment.entity.Enrollment.
- If you see a SQL syntax error, confirm your MySQL service is running and the course-enrollment database exists.
Help me fix my Spring Boot application startup error after adding repositories
✔️ Awesome, I've got everything!
Great. Your application is running with all three repositories ready to use.
ⓧ I'd like to double check the full code
package com.ijse.course_enrollment.repository;
import com.ijse.course_enrollment.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
}
package com.ijse.course_enrollment.repository;
import com.ijse.course_enrollment.entity.Course;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CourseRepository extends JpaRepository<Course, Long> {
}
package com.ijse.course_enrollment.repository;
import com.ijse.course_enrollment.entity.Enrollment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
@Query(value = "SELECT * FROM enrollments WHERE student_student_id = ?1", nativeQuery = true)
List<Enrollment> filterEnrollmentsByStudent(Long studentId);
}
Your data layer is complete. You have DTOs to shape API input and output, constants for consistent responses, and repositories for database access. Next up, you will build the service layer that contains the business logic connecting these pieces together.
Implement Service Layer and Global Exception Handling
Your entities, DTOs, and repositories are ready. The next challenge is handling the business logic that sits between your controllers and the database.
In this step, you will build the service layer that processes requests and a global exception handler that catches errors across the entire API. This means your controllers stay thin, and every error returns a clean, structured response.
In this step, get ready to:
- Create a custom exception class and a global exception handler with @ControllerAdvice.
- Build service interfaces and implementations for Student, Course, and Enrollment.
- Wire up pagination support in the Course service using Pageable.
Create the custom exception and global exception handler
When something goes wrong in your API (a student ID doesn't exist, a validation rule fails), you need a consistent way to report the error. Without centralized handling, every controller method would need its own try-catch blocks.
- In the exception package, create a new file called ResourceNotFoundException.java and add this code:
package com.ijse.course_enrollment.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
What does this code do?
This is a custom unchecked exception that extends RuntimeException. Your service layer will throw this whenever a requested resource (student, course, or enrollment) does not exist in the database.
By extending RuntimeException, you avoid forcing callers to declare it in a throws clause. The global handler catches it instead.
- Save ResourceNotFoundException.java.
Now you need the class that catches this exception (and others) across the entire application.
- In the same exception package, create a new file called GlobalExceptionHandler.java and add this code:
package com.ijse.course_enrollment.exception;
import com.ijse.course_enrollment.constant.CommonResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.HashMap;
import java.util.Map;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_FAILED;
@ControllerAdvice
public class GlobalExceptionHandler {
// Handles Bean Validation failures from @Valid annotations
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<CommonResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
CommonResponse response = new CommonResponse(OPERATION_FAILED, errors, "Validation failed");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
// Handles custom ResourceNotFoundException
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<CommonResponse> handleResourceNotFound(ResourceNotFoundException ex) {
CommonResponse response = new CommonResponse(OPERATION_FAILED, ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
// Catches any other unhandled exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<CommonResponse> handleGenericException(Exception ex) {
CommonResponse response = new CommonResponse(OPERATION_FAILED, ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
What does this code do?
- @ControllerAdvice makes this class intercept exceptions thrown from any controller method in the application.
- handleValidationErrors catches MethodArgumentNotValidException (triggered when @Valid fails). It extracts each field error into a map and returns HTTP 400.
- handleResourceNotFound catches your custom ResourceNotFoundException and returns HTTP 404 with the error message.
- handleGenericException is a catch-all for any unexpected exception, returning HTTP 500.
- Save GlobalExceptionHandler.java.
- Run your application. It should start without errors on port 8080, confirming Spring detected the new @ControllerAdvice class.
Application won't start?
- Check that the import static for OPERATION_FAILED references com.ijse.course_enrollment.constant.ResponseCode.OPERATION_FAILED exactly.
- Confirm both files are in the exception package and the package declaration matches com.ijse.course_enrollment.exception.
Help me fix my GlobalExceptionHandler startup error.
Why use @ControllerAdvice instead of try-catch in controllers?
Without @ControllerAdvice, every controller method would need its own try-catch to format error responses. That duplicates code across your entire API.
With a centralized handler, you define error formatting once. Every controller in your application automatically benefits from structured error responses.
Create the Student and Course service layers
The service layer holds your business logic. You define an interface (the contract) and an implementation (the logic). This separation makes testing and swapping implementations easy.
- In the service package, create a new file called StudentService.java and add this interface:
package com.ijse.course_enrollment.service;
import com.ijse.course_enrollment.dto.StudentDTO;
import java.util.List;
// Defines the contract for student-related operations
public interface StudentService {
void saveStudent(StudentDTO studentDTO);
List<StudentDTO> getAllStudents();
StudentDTO getStudentDetails(long studentId);
}
What does this code do?
This interface declares three operations: saving a student, listing all students, and fetching one student by ID. The interface has no logic. The implementation class you create next provides the actual behaviour.
- Save StudentService.java.
Now create the implementation that fulfils this contract.
- In the service/impl package, create a new file called StudentServiceImpl.java and add this code:
package com.ijse.course_enrollment.service.impl;
import com.ijse.course_enrollment.dto.StudentDTO;
import com.ijse.course_enrollment.entity.Student;
import com.ijse.course_enrollment.exception.ResourceNotFoundException;
import com.ijse.course_enrollment.repository.StudentRepository;
import com.ijse.course_enrollment.service.StudentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
@Slf4j
public class StudentServiceImpl implements StudentService {
private final StudentRepository studentRepository;
// Constructor injection for the repository
public StudentServiceImpl(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
@Override
public void saveStudent(StudentDTO studentDTO) {
log.info("Execute method saveStudent()");
try {
Student student = new Student();
student.setStudentName(studentDTO.getStudentName());
student.setEmail(studentDTO.getEmail());
student.setPhone(studentDTO.getPhone());
studentRepository.save(student);
} catch (Exception e) {
log.error("Error in saveStudent : " + e.getMessage());
throw e;
}
}
@Override
public List<StudentDTO> getAllStudents() {
log.info("Execute method getAllStudents()");
try {
List<StudentDTO> responseList = new ArrayList<>();
List<Student> studentList = studentRepository.findAll();
for (Student student : studentList) {
StudentDTO dto = new StudentDTO(student.getStudentId(),
student.getStudentName(), student.getEmail(), student.getPhone());
responseList.add(dto);
}
return responseList;
} catch (Exception e) {
log.error("Error in getAllStudents : " + e.getMessage());
throw e;
}
}
@Override
public StudentDTO getStudentDetails(long studentId) {
log.info("Execute method getStudentDetails()");
try {
Optional<Student> optionalStudent = studentRepository.findById(studentId);
if (optionalStudent.isEmpty())
throw new ResourceNotFoundException("Student not found with id: " + studentId);
Student student = optionalStudent.get();
return new StudentDTO(student.getStudentId(),
student.getStudentName(), student.getEmail(), student.getPhone());
} catch (Exception e) {
log.error("Error in getStudentDetails : " + e.getMessage());
throw e;
}
}
}
What does this code do?
- @Service registers this class as a Spring-managed bean so it can be injected into controllers.
- @Slf4j generates a logger field automatically via Lombok.
- saveStudent maps the DTO fields onto a new Student entity and persists it via the repository.
- getStudentDetails throws ResourceNotFoundException when the ID doesn't exist. Your global handler catches this and returns a 404 response.
- Save StudentServiceImpl.java.
Seeing a compile error on ResourceNotFoundException?
Make sure you import com.ijse.course_enrollment.exception.ResourceNotFoundException at the top of the file. IntelliJ usually auto-imports, but verify the import points to your custom class, not a different library's version.
Help me fix import errors in my StudentServiceImpl.
Next, create the Course service. This one introduces pagination support.
- In the service package, create a new file called CourseService.java and add this interface:
package com.ijse.course_enrollment.service;
import com.ijse.course_enrollment.dto.CourseDTO;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
// Defines the contract for course-related operations
public interface CourseService {
void saveCourse(CourseDTO courseDTO);
Page<CourseDTO> getCoursePage(Pageable pageable);
CourseDTO getCourseDetails(long courseId);
}
What is Pageable?
Pageable is a Spring Data interface that carries page number, page size, and sort direction. Instead of fetching all courses at once, your API returns them in chunks. The controller passes Pageable to the service, which passes it to the repository.
- Save CourseService.java.
- In the service/impl package, create a new file called CourseServiceImpl.java and add this code:
package com.ijse.course_enrollment.service.impl;
import com.ijse.course_enrollment.dto.CourseDTO;
import com.ijse.course_enrollment.entity.Course;
import com.ijse.course_enrollment.entity.CourseStatus;
import com.ijse.course_enrollment.exception.ResourceNotFoundException;
import com.ijse.course_enrollment.repository.CourseRepository;
import com.ijse.course_enrollment.service.CourseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@Slf4j
public class CourseServiceImpl implements CourseService {
private final CourseRepository courseRepository;
public CourseServiceImpl(CourseRepository courseRepository) {
this.courseRepository = courseRepository;
}
@Override
public void saveCourse(CourseDTO courseDTO) {
log.info("Execute method saveCourse()");
try {
Course course = new Course();
course.setTitle(courseDTO.getTitle());
course.setDescription(courseDTO.getDescription());
course.setCredits(courseDTO.getCredits());
// New courses default to ACTIVE status
course.setStatus(CourseStatus.ACTIVE);
courseRepository.save(course);
} catch (Exception e) {
log.error("Error in saveCourse : " + e.getMessage());
throw e;
}
}
@Override
public Page<CourseDTO> getCoursePage(Pageable pageable) {
log.info("Execute method getCoursePage()");
try {
// Delegates pagination to the repository
Page<Course> coursePage = courseRepository.findAll(pageable);
// Maps each Course entity to a CourseDTO
return coursePage.map(course -> new CourseDTO(
course.getCourseId(),
course.getTitle(),
course.getDescription(),
course.getCredits(),
course.getStatus().name()
));
} catch (Exception e) {
log.error("Error in getCoursePage : " + e.getMessage());
throw e;
}
}
@Override
public CourseDTO getCourseDetails(long courseId) {
log.info("Execute method getCourseDetails()");
try {
Optional<Course> optionalCourse = courseRepository.findById(courseId);
if (optionalCourse.isEmpty())
throw new ResourceNotFoundException("Course not found with id: " + courseId);
Course course = optionalCourse.get();
return new CourseDTO(course.getCourseId(), course.getTitle(),
course.getDescription(), course.getCredits(), course.getStatus().name());
} catch (Exception e) {
log.error("Error in getCourseDetails : " + e.getMessage());
throw e;
}
}
}
What does this code do?
- saveCourse sets the status to CourseStatus.ACTIVE by default. The API consumer does not need to supply a status when creating a course.
- getCoursePage calls courseRepository.findAll(pageable). Spring Data automatically handles the SQL LIMIT and OFFSET based on the Pageable parameters.
- The .map() on Page<Course> transforms each entity into a DTO while preserving pagination metadata (total pages, total elements).
- Save CourseServiceImpl.java.
- Run your application. It should start successfully, confirming Spring can wire the service beans with their repository dependencies.
Seeing a bean creation error?
- Verify that each implementation class has the @Service annotation. Without it, Spring cannot find the bean.
- Check that your constructor parameter types match the repository interfaces exactly (e.g. CourseRepository, not Course).
Help me debug my Spring bean creation error.
Create the Enrollment service layer
The Enrollment service is the most interesting one. It coordinates between three repositories to validate that both the student and course exist before creating the enrollment record.
- In the service package, create a new file called EnrollmentService.java and add this interface:
package com.ijse.course_enrollment.service;
import com.ijse.course_enrollment.dto.EnrollmentDTO;
// Defines the contract for enrollment operations
public interface EnrollmentService {
EnrollmentDTO enrollStudent(EnrollmentDTO enrollmentDTO);
void dropStudent(long enrollmentId);
}
What does this code do?
enrollStudent returns an EnrollmentDTO so the API can respond with the created enrollment's details (including the generated ID and timestamp). dropStudent returns void because the caller only needs confirmation that the status change succeeded.
- Save EnrollmentService.java.
- In the service/impl package, create a new file called EnrollmentServiceImpl.java and add this code:
package com.ijse.course_enrollment.service.impl;
import com.ijse.course_enrollment.dto.EnrollmentDTO;
import com.ijse.course_enrollment.entity.Course;
import com.ijse.course_enrollment.entity.Enrollment;
import com.ijse.course_enrollment.entity.EnrollmentStatus;
import com.ijse.course_enrollment.entity.Student;
import com.ijse.course_enrollment.exception.ResourceNotFoundException;
import com.ijse.course_enrollment.repository.CourseRepository;
import com.ijse.course_enrollment.repository.EnrollmentRepository;
import com.ijse.course_enrollment.repository.StudentRepository;
import com.ijse.course_enrollment.service.EnrollmentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
@Slf4j
public class EnrollmentServiceImpl implements EnrollmentService {
private final EnrollmentRepository enrollmentRepository;
private final StudentRepository studentRepository;
private final CourseRepository courseRepository;
// Injects all three repositories needed for enrollment logic
public EnrollmentServiceImpl(EnrollmentRepository enrollmentRepository,
StudentRepository studentRepository,
CourseRepository courseRepository) {
this.enrollmentRepository = enrollmentRepository;
this.studentRepository = studentRepository;
this.courseRepository = courseRepository;
}
@Override
public EnrollmentDTO enrollStudent(EnrollmentDTO enrollmentDTO) {
log.info("Execute method enrollStudent()");
try {
// Validate the student exists
Optional<Student> optionalStudent = studentRepository.findById(enrollmentDTO.getStudentId());
if (optionalStudent.isEmpty())
throw new ResourceNotFoundException("Student not found with id: " + enrollmentDTO.getStudentId());
// Validate the course exists
Optional<Course> optionalCourse = courseRepository.findById(enrollmentDTO.getCourseId());
if (optionalCourse.isEmpty())
throw new ResourceNotFoundException("Course not found with id: " + enrollmentDTO.getCourseId());
// Create the enrollment with current timestamp and ENROLLED status
Enrollment enrollment = new Enrollment();
enrollment.setStudent(optionalStudent.get());
enrollment.setCourse(optionalCourse.get());
enrollment.setEnrollmentDate(LocalDateTime.now());
enrollment.setStatus(EnrollmentStatus.ENROLLED);
Enrollment saved = enrollmentRepository.save(enrollment);
return new EnrollmentDTO(saved.getEnrollmentId(), saved.getStudent().getStudentId(),
saved.getCourse().getCourseId(), saved.getEnrollmentDate().toString(),
saved.getStatus().name());
} catch (Exception e) {
log.error("Error in enrollStudent : " + e.getMessage());
throw e;
}
}
@Override
public void dropStudent(long enrollmentId) {
log.info("Execute method dropStudent()");
try {
Optional<Enrollment> optionalEnrollment = enrollmentRepository.findById(enrollmentId);
if (optionalEnrollment.isEmpty())
throw new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId);
// Change status to DROPPED instead of deleting the record
Enrollment enrollment = optionalEnrollment.get();
enrollment.setStatus(EnrollmentStatus.DROPPED);
enrollmentRepository.save(enrollment);
} catch (Exception e) {
log.error("Error in dropStudent : " + e.getMessage());
throw e;
}
}
}
What does this code do?
- enrollStudent first checks that both the student and course IDs are valid. If either is missing, it throws ResourceNotFoundException and your global handler returns a 404.
- The enrollment date is set to LocalDateTime.now() automatically. The API consumer does not send a date.
- dropStudent changes the status to DROPPED rather than deleting the row. This preserves the enrollment history.
- Save EnrollmentServiceImpl.java.
- Run your application. It should start on port 8080 with no errors, confirming all three service beans and their dependencies are wired correctly.
Seeing a 'No qualifying bean' error for EnrollmentServiceImpl?
- Make sure the constructor accepts all three repositories: EnrollmentRepository, StudentRepository, and CourseRepository. Missing one causes a wiring failure.
- Confirm that each repository interface has the @Repository annotation and extends JpaRepository.
Help me fix my EnrollmentServiceImpl bean wiring error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all 8 new files before moving on.
ⓧ I'd like to double check the full code
Here are all 8 files created in this step. Compare them against yours.
package com.ijse.course_enrollment.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.ijse.course_enrollment.exception;
import com.ijse.course_enrollment.constant.CommonResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.HashMap;
import java.util.Map;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_FAILED;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<CommonResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
CommonResponse response = new CommonResponse(OPERATION_FAILED, errors, "Validation failed");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<CommonResponse> handleResourceNotFound(ResourceNotFoundException ex) {
CommonResponse response = new CommonResponse(OPERATION_FAILED, ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<CommonResponse> handleGenericException(Exception ex) {
CommonResponse response = new CommonResponse(OPERATION_FAILED, ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
package com.ijse.course_enrollment.service;
import com.ijse.course_enrollment.dto.StudentDTO;
import java.util.List;
public interface StudentService {
void saveStudent(StudentDTO studentDTO);
List<StudentDTO> getAllStudents();
StudentDTO getStudentDetails(long studentId);
}
package com.ijse.course_enrollment.service.impl;
import com.ijse.course_enrollment.dto.StudentDTO;
import com.ijse.course_enrollment.entity.Student;
import com.ijse.course_enrollment.exception.ResourceNotFoundException;
import com.ijse.course_enrollment.repository.StudentRepository;
import com.ijse.course_enrollment.service.StudentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
@Slf4j
public class StudentServiceImpl implements StudentService {
private final StudentRepository studentRepository;
public StudentServiceImpl(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
@Override
public void saveStudent(StudentDTO studentDTO) {
log.info("Execute method saveStudent()");
try {
Student student = new Student();
student.setStudentName(studentDTO.getStudentName());
student.setEmail(studentDTO.getEmail());
student.setPhone(studentDTO.getPhone());
studentRepository.save(student);
} catch (Exception e) {
log.error("Error in saveStudent : " + e.getMessage());
throw e;
}
}
@Override
public List<StudentDTO> getAllStudents() {
log.info("Execute method getAllStudents()");
try {
List<StudentDTO> responseList = new ArrayList<>();
List<Student> studentList = studentRepository.findAll();
for (Student student : studentList) {
StudentDTO dto = new StudentDTO(student.getStudentId(),
student.getStudentName(), student.getEmail(), student.getPhone());
responseList.add(dto);
}
return responseList;
} catch (Exception e) {
log.error("Error in getAllStudents : " + e.getMessage());
throw e;
}
}
@Override
public StudentDTO getStudentDetails(long studentId) {
log.info("Execute method getStudentDetails()");
try {
Optional<Student> optionalStudent = studentRepository.findById(studentId);
if (optionalStudent.isEmpty())
throw new ResourceNotFoundException("Student not found with id: " + studentId);
Student student = optionalStudent.get();
return new StudentDTO(student.getStudentId(),
student.getStudentName(), student.getEmail(), student.getPhone());
} catch (Exception e) {
log.error("Error in getStudentDetails : " + e.getMessage());
throw e;
}
}
}
package com.ijse.course_enrollment.service;
import com.ijse.course_enrollment.dto.CourseDTO;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface CourseService {
void saveCourse(CourseDTO courseDTO);
Page<CourseDTO> getCoursePage(Pageable pageable);
CourseDTO getCourseDetails(long courseId);
}
package com.ijse.course_enrollment.service.impl;
import com.ijse.course_enrollment.dto.CourseDTO;
import com.ijse.course_enrollment.entity.Course;
import com.ijse.course_enrollment.entity.CourseStatus;
import com.ijse.course_enrollment.exception.ResourceNotFoundException;
import com.ijse.course_enrollment.repository.CourseRepository;
import com.ijse.course_enrollment.service.CourseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@Slf4j
public class CourseServiceImpl implements CourseService {
private final CourseRepository courseRepository;
public CourseServiceImpl(CourseRepository courseRepository) {
this.courseRepository = courseRepository;
}
@Override
public void saveCourse(CourseDTO courseDTO) {
log.info("Execute method saveCourse()");
try {
Course course = new Course();
course.setTitle(courseDTO.getTitle());
course.setDescription(courseDTO.getDescription());
course.setCredits(courseDTO.getCredits());
course.setStatus(CourseStatus.ACTIVE);
courseRepository.save(course);
} catch (Exception e) {
log.error("Error in saveCourse : " + e.getMessage());
throw e;
}
}
@Override
public Page<CourseDTO> getCoursePage(Pageable pageable) {
log.info("Execute method getCoursePage()");
try {
Page<Course> coursePage = courseRepository.findAll(pageable);
return coursePage.map(course -> new CourseDTO(
course.getCourseId(),
course.getTitle(),
course.getDescription(),
course.getCredits(),
course.getStatus().name()
));
} catch (Exception e) {
log.error("Error in getCoursePage : " + e.getMessage());
throw e;
}
}
@Override
public CourseDTO getCourseDetails(long courseId) {
log.info("Execute method getCourseDetails()");
try {
Optional<Course> optionalCourse = courseRepository.findById(courseId);
if (optionalCourse.isEmpty())
throw new ResourceNotFoundException("Course not found with id: " + courseId);
Course course = optionalCourse.get();
return new CourseDTO(course.getCourseId(), course.getTitle(),
course.getDescription(), course.getCredits(), course.getStatus().name());
} catch (Exception e) {
log.error("Error in getCourseDetails : " + e.getMessage());
throw e;
}
}
}
package com.ijse.course_enrollment.service;
import com.ijse.course_enrollment.dto.EnrollmentDTO;
public interface EnrollmentService {
EnrollmentDTO enrollStudent(EnrollmentDTO enrollmentDTO);
void dropStudent(long enrollmentId);
}
package com.ijse.course_enrollment.service.impl;
import com.ijse.course_enrollment.dto.EnrollmentDTO;
import com.ijse.course_enrollment.entity.Course;
import com.ijse.course_enrollment.entity.Enrollment;
import com.ijse.course_enrollment.entity.EnrollmentStatus;
import com.ijse.course_enrollment.entity.Student;
import com.ijse.course_enrollment.exception.ResourceNotFoundException;
import com.ijse.course_enrollment.repository.CourseRepository;
import com.ijse.course_enrollment.repository.EnrollmentRepository;
import com.ijse.course_enrollment.repository.StudentRepository;
import com.ijse.course_enrollment.service.EnrollmentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
@Slf4j
public class EnrollmentServiceImpl implements EnrollmentService {
private final EnrollmentRepository enrollmentRepository;
private final StudentRepository studentRepository;
private final CourseRepository courseRepository;
public EnrollmentServiceImpl(EnrollmentRepository enrollmentRepository,
StudentRepository studentRepository,
CourseRepository courseRepository) {
this.enrollmentRepository = enrollmentRepository;
this.studentRepository = studentRepository;
this.courseRepository = courseRepository;
}
@Override
public EnrollmentDTO enrollStudent(EnrollmentDTO enrollmentDTO) {
log.info("Execute method enrollStudent()");
try {
Optional<Student> optionalStudent = studentRepository.findById(enrollmentDTO.getStudentId());
if (optionalStudent.isEmpty())
throw new ResourceNotFoundException("Student not found with id: " + enrollmentDTO.getStudentId());
Optional<Course> optionalCourse = courseRepository.findById(enrollmentDTO.getCourseId());
if (optionalCourse.isEmpty())
throw new ResourceNotFoundException("Course not found with id: " + enrollmentDTO.getCourseId());
Enrollment enrollment = new Enrollment();
enrollment.setStudent(optionalStudent.get());
enrollment.setCourse(optionalCourse.get());
enrollment.setEnrollmentDate(LocalDateTime.now());
enrollment.setStatus(EnrollmentStatus.ENROLLED);
Enrollment saved = enrollmentRepository.save(enrollment);
return new EnrollmentDTO(saved.getEnrollmentId(), saved.getStudent().getStudentId(),
saved.getCourse().getCourseId(), saved.getEnrollmentDate().toString(),
saved.getStatus().name());
} catch (Exception e) {
log.error("Error in enrollStudent : " + e.getMessage());
throw e;
}
}
@Override
public void dropStudent(long enrollmentId) {
log.info("Execute method dropStudent()");
try {
Optional<Enrollment> optionalEnrollment = enrollmentRepository.findById(enrollmentId);
if (optionalEnrollment.isEmpty())
throw new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId);
Enrollment enrollment = optionalEnrollment.get();
enrollment.setStatus(EnrollmentStatus.DROPPED);
enrollmentRepository.save(enrollment);
} catch (Exception e) {
log.error("Error in dropStudent : " + e.getMessage());
throw e;
}
}
}
Your service layer and global exception handler are complete. Next up, you will build the controllers that expose these services as REST endpoints with validation and pagination.
Build Controllers with Validation and Pagination
Your service layer is ready to handle business logic, but there is no way for external clients to reach it yet. Controllers are the entry point that maps HTTP requests to service method calls.
In this step, you will create three REST controllers that expose your Student, Course, and Enrollment services over HTTP. You will also use @Valid to trigger the Bean Validation annotations you added to your DTOs, and accept a Pageable parameter for paginated course listings.
In this step, get ready to:
- Create StudentController and CourseController with validation and pagination.
- Create EnrollmentController with enroll and drop endpoints.
- Test all endpoints using Postman to confirm validation, pagination, and error handling.
Create the student and course controllers
Controllers use @RestController to mark a class as an HTTP endpoint handler. Each method maps to a specific HTTP verb and path.
- Create a new Java class called StudentController.java inside the controller package by pasting the following code:
package com.ijse.course_enrollment.controller;
import com.ijse.course_enrollment.constant.CommonResponse;
import com.ijse.course_enrollment.dto.StudentDTO;
import com.ijse.course_enrollment.service.StudentService;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_SUCCESS;
import static com.ijse.course_enrollment.constant.ResponseMessage.SUCCESS_MESSAGE;
@RestController
@RequestMapping(value = "v1/students")
public class StudentController {
private final StudentService studentService;
// Constructor injection wires the service automatically
public StudentController(StudentService studentService) {
this.studentService = studentService;
}
// @Valid triggers Bean Validation on the incoming DTO
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse saveStudent(@Valid @RequestBody StudentDTO studentDTO) {
studentService.saveStudent(studentDTO);
return new CommonResponse(OPERATION_SUCCESS, SUCCESS_MESSAGE);
}
// Returns all students wrapped in CommonResponse
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getAllStudents() {
List<StudentDTO> students = studentService.getAllStudents();
return new CommonResponse(OPERATION_SUCCESS, students, SUCCESS_MESSAGE);
}
// Path variable extracts the studentId from the URL
@GetMapping(value = "/{studentId}", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getStudentDetails(@PathVariable long studentId) {
StudentDTO studentDetails = studentService.getStudentDetails(studentId);
return new CommonResponse(OPERATION_SUCCESS, studentDetails, SUCCESS_MESSAGE);
}
}
What does this code do?
@RequestMapping(value = "v1/students") sets the base URL path for all endpoints in this controller.
@Valid before @RequestBody tells Spring to run the Bean Validation annotations (@NotBlank, @Email, @Size) on the incoming DTO before the method body runs. If validation fails, Spring throws a MethodArgumentNotValidException which your GlobalExceptionHandler catches.
- Save StudentController.java.
Seeing import errors?
Make sure you are importing jakarta.validation.Valid (not javax.validation.Valid). Spring Boot 4.0.6 uses the Jakarta namespace.
Also check that the static import for OPERATION_SUCCESS and SUCCESS_MESSAGE resolve correctly to your constant package.
help me fix import errors in my StudentController
✔️ Awesome, I've got everything!
Great. Make sure you have saved StudentController.java before moving on.
ⓧ I'd like to double check the full code
package com.ijse.course_enrollment.controller;
import com.ijse.course_enrollment.constant.CommonResponse;
import com.ijse.course_enrollment.dto.StudentDTO;
import com.ijse.course_enrollment.service.StudentService;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_SUCCESS;
import static com.ijse.course_enrollment.constant.ResponseMessage.SUCCESS_MESSAGE;
@RestController
@RequestMapping(value = "v1/students")
public class StudentController {
private final StudentService studentService;
public StudentController(StudentService studentService) {
this.studentService = studentService;
}
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse saveStudent(@Valid @RequestBody StudentDTO studentDTO) {
studentService.saveStudent(studentDTO);
return new CommonResponse(OPERATION_SUCCESS, SUCCESS_MESSAGE);
}
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getAllStudents() {
List<StudentDTO> students = studentService.getAllStudents();
return new CommonResponse(OPERATION_SUCCESS, students, SUCCESS_MESSAGE);
}
@GetMapping(value = "/{studentId}", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getStudentDetails(@PathVariable long studentId) {
StudentDTO studentDetails = studentService.getStudentDetails(studentId);
return new CommonResponse(OPERATION_SUCCESS, studentDetails, SUCCESS_MESSAGE);
}
}
- Create a new Java class called CourseController.java inside the controller package by pasting the following code:
package com.ijse.course_enrollment.controller;
import com.ijse.course_enrollment.constant.CommonResponse;
import com.ijse.course_enrollment.dto.CourseDTO;
import com.ijse.course_enrollment.service.CourseService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_SUCCESS;
import static com.ijse.course_enrollment.constant.ResponseMessage.SUCCESS_MESSAGE;
@RestController
@RequestMapping(value = "v1/courses")
public class CourseController {
private final CourseService courseService;
public CourseController(CourseService courseService) {
this.courseService = courseService;
}
// @Valid enforces @NotBlank and @Min on CourseDTO fields
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse saveCourse(@Valid @RequestBody CourseDTO courseDTO) {
courseService.saveCourse(courseDTO);
return new CommonResponse(OPERATION_SUCCESS, SUCCESS_MESSAGE);
}
// Pageable is resolved automatically from query params: page, size, sort
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getCourses(Pageable pageable) {
Page<CourseDTO> coursePage = courseService.getCoursePage(pageable);
return new CommonResponse(OPERATION_SUCCESS, coursePage, SUCCESS_MESSAGE);
}
@GetMapping(value = "/{courseId}", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getCourseDetails(@PathVariable long courseId) {
CourseDTO courseDetails = courseService.getCourseDetails(courseId);
return new CommonResponse(OPERATION_SUCCESS, courseDetails, SUCCESS_MESSAGE);
}
}
How does Pageable work without annotations?
Spring Data automatically resolves a Pageable parameter from request query strings. It reads page (0-indexed, default 0), size (default 20), and sort (format: property,ASC|DESC) without any extra configuration.
This means a GET request to /v1/courses?page=0&size=5&sort=title,asc returns the first page of 5 courses sorted alphabetically by title.
- Save CourseController.java.
Cannot resolve Pageable or Page?
Make sure you are importing from org.springframework.data.domain.Pageable and org.springframework.data.domain.Page. These come from the spring-boot-starter-data-jpa dependency you added in Step 1.
help me resolve Pageable import issues in CourseController
✔️ Awesome, I've got everything!
Great. Make sure you have saved CourseController.java before moving on.
ⓧ I'd like to double check the full code
package com.ijse.course_enrollment.controller;
import com.ijse.course_enrollment.constant.CommonResponse;
import com.ijse.course_enrollment.dto.CourseDTO;
import com.ijse.course_enrollment.service.CourseService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_SUCCESS;
import static com.ijse.course_enrollment.constant.ResponseMessage.SUCCESS_MESSAGE;
@RestController
@RequestMapping(value = "v1/courses")
public class CourseController {
private final CourseService courseService;
public CourseController(CourseService courseService) {
this.courseService = courseService;
}
@PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse saveCourse(@Valid @RequestBody CourseDTO courseDTO) {
courseService.saveCourse(courseDTO);
return new CommonResponse(OPERATION_SUCCESS, SUCCESS_MESSAGE);
}
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getCourses(Pageable pageable) {
Page<CourseDTO> coursePage = courseService.getCoursePage(pageable);
return new CommonResponse(OPERATION_SUCCESS, coursePage, SUCCESS_MESSAGE);
}
@GetMapping(value = "/{courseId}", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse getCourseDetails(@PathVariable long courseId) {
CourseDTO courseDetails = courseService.getCourseDetails(courseId);
return new CommonResponse(OPERATION_SUCCESS, courseDetails, SUCCESS_MESSAGE);
}
}
Create the enrollment controller
The enrollment controller connects students to courses. It does not use @Valid because the enrollment DTO has no validation constraints.
- Create a new Java class called EnrollmentController.java inside the controller package by pasting the following code:
package com.ijse.course_enrollment.controller;
import com.ijse.course_enrollment.constant.CommonResponse;
import com.ijse.course_enrollment.dto.EnrollmentDTO;
import com.ijse.course_enrollment.service.EnrollmentService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_SUCCESS;
import static com.ijse.course_enrollment.constant.ResponseMessage.SUCCESS_MESSAGE;
@RestController
@RequestMapping(value = "v1/enrollments")
public class EnrollmentController {
private final EnrollmentService enrollmentService;
public EnrollmentController(EnrollmentService enrollmentService) {
this.enrollmentService = enrollmentService;
}
// POST /enroll creates a new enrollment linking student to course
@PostMapping(value = "/enroll", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse enrollStudent(@RequestBody EnrollmentDTO enrollmentDTO) {
EnrollmentDTO result = enrollmentService.enrollStudent(enrollmentDTO);
return new CommonResponse(OPERATION_SUCCESS, result, SUCCESS_MESSAGE);
}
// PUT /drop/{enrollmentId} changes the enrollment status to DROPPED
@PutMapping(value = "/drop/{enrollmentId}", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse dropStudent(@PathVariable long enrollmentId) {
enrollmentService.dropStudent(enrollmentId);
return new CommonResponse(OPERATION_SUCCESS, SUCCESS_MESSAGE);
}
}
What does this code do?
The @PostMapping("/enroll") endpoint accepts a JSON body with studentId and courseId. The service layer validates that both exist before creating the enrollment.
The @PutMapping("/drop/{enrollmentId}") endpoint uses PUT because it updates an existing enrollment's status to DROPPED rather than creating a new resource.
- Save EnrollmentController.java.
- Run the application in IntelliJ IDEA. It should start on port 8080 with no errors in the console.
Application fails to start?
Check for duplicate @RequestMapping paths across controllers. Each controller must have a unique base path (v1/students, v1/courses, v1/enrollments).
Also verify that each controller's constructor parameter matches the service interface type, not the implementation class.
help me debug why my Spring Boot application fails to start after adding controllers
✔️ Awesome, I've got everything!
Great. Make sure you see Started CourseEnrollmentApplication in your console output before continuing.
ⓧ I'd like to double check the full code
package com.ijse.course_enrollment.controller;
import com.ijse.course_enrollment.constant.CommonResponse;
import com.ijse.course_enrollment.dto.EnrollmentDTO;
import com.ijse.course_enrollment.service.EnrollmentService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import static com.ijse.course_enrollment.constant.ResponseCode.OPERATION_SUCCESS;
import static com.ijse.course_enrollment.constant.ResponseMessage.SUCCESS_MESSAGE;
@RestController
@RequestMapping(value = "v1/enrollments")
public class EnrollmentController {
private final EnrollmentService enrollmentService;
public EnrollmentController(EnrollmentService enrollmentService) {
this.enrollmentService = enrollmentService;
}
@PostMapping(value = "/enroll", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse enrollStudent(@RequestBody EnrollmentDTO enrollmentDTO) {
EnrollmentDTO result = enrollmentService.enrollStudent(enrollmentDTO);
return new CommonResponse(OPERATION_SUCCESS, result, SUCCESS_MESSAGE);
}
@PutMapping(value = "/drop/{enrollmentId}", produces = MediaType.APPLICATION_JSON_VALUE)
public CommonResponse dropStudent(@PathVariable long enrollmentId) {
enrollmentService.dropStudent(enrollmentId);
return new CommonResponse(OPERATION_SUCCESS, SUCCESS_MESSAGE);
}
}
Test all endpoints using Postman
With all three controllers wired up, you can now verify that validation, pagination, and exception handling work end-to-end.
- Open Postman.
- Create a new request. Set the method to POST and the URL to http://localhost:8080/v1/students.
- Click the Body tab, select raw, and set the type to JSON.
- Paste this invalid student body to test validation:
{
"studentName": "",
"email": "not-an-email",
"phone": "123"
}
- Click Send.
You should see a 400 Bad Request response with a body that lists field-specific errors for studentName, email, and phone. This confirms your GlobalExceptionHandler caught the MethodArgumentNotValidException.
- Now replace the body with a valid student and click Send:
{
"studentName": "Alice Johnson",
"email": "alice@example.com",
"phone": "0771234567"
}
You should see a 200 response with "status": 0 and "message": "Operation successful".
- Create a new request. Set the method to POST and the URL to http://localhost:8080/v1/courses.
- Set the body to raw JSON and paste this course:
{
"title": "Introduction to Spring Boot",
"description": "Learn the fundamentals of Spring Boot",
"credits": 3
}
- Click Send. You should see a success response.
- Create another course by changing the title to Advanced Java and sending again. This gives you two courses for pagination testing.
- Create a new request. Set the method to POST and the URL to http://localhost:8080/v1/enrollments/enroll.
- Set the body to raw JSON and paste this enrollment:
{
"studentId": 1,
"courseId": 1
}
- Click Send. You should see a success response that includes the enrollmentId, enrollmentDate, and status of ENROLLED.
- Create a new GET request with the URL http://localhost:8080/v1/courses?page=0&size=5&sort=title,asc and click Send.
The response body should include a paginated structure with fields like totalElements, totalPages, and a content array listing your courses sorted alphabetically by title.
- Finally, create a GET request with the URL http://localhost:8080/v1/students/999 and click Send.
You should see a 404 response with the message Student not found with id: 999. This confirms your ResourceNotFoundException is handled by the GlobalExceptionHandler.
Why use @ControllerAdvice instead of try-catch in controllers?
Without @ControllerAdvice, you would need try-catch blocks in every controller method to format error responses consistently. The global handler centralizes all error formatting in one place.
When you add new controllers later, they automatically get the same structured error responses without any extra code.
Your API is fully functional. You have controllers that validate input automatically, return paginated results, and handle errors gracefully through a single global handler. Next up, you will tackle the Secret Mission and build a student transcript endpoint.
Secret mission
Build a Student Transcript Endpoint
Your API can enroll and drop students, but what about viewing a student's full academic history? In this mission, you'll build a transcript endpoint that returns all courses grouped by enrollment status using JPQL JOIN FETCH and Java Streams.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with MySQL, so there are no ongoing cloud costs.
Resources you used:
- The course-enrollment MySQL database (tables persist after the application stops).
- The Spring Boot project folder on your machine.
Keep everything running
No action needed. Choose this if you're still actively building on the API or plan to add more features.
- Your course-enrollment database stays in MySQL with all your test data intact.
- Your project folder and all source files remain on disk, ready to open in IntelliJ IDEA.
Pause - I'll come back to this later
Stop the running application to free up memory. Your database and project files stay on disk for when you return.
- Stop the Spring Boot application in IntelliJ IDEA by clicking the red square (stop) button in the Run panel.
- The course-enrollment database remains in MySQL. No resources are consumed while the application is stopped.
- When you're ready to resume, open the project in IntelliJ IDEA and run CourseEnrollmentApplication again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your database and project files permanently.
- Stop the Spring Boot application in IntelliJ IDEA if it is still running.
- Drop the MySQL database by opening a MySQL terminal and running this command:
mysql -u root -pRoot@123 -e "DROP DATABASE \`course-enrollment\`;"
- Delete the project folder. On macOS or Linux, run:
rm -rf ~/Desktop/course-enrollment
- On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\course-enrollment
- Confirm the database is gone by running:
mysql -u root -pRoot@123 -e "SHOW DATABASES;"
You should no longer see course-enrollment in the list.
Nice Work!
Nice Work!
Nice work! You just built a production-style Course Enrollment Management API using Spring Boot 4.0.6 with proper validation, error handling, and pagination.
You've learned how to:
- Build a Many-to-Many relationship through a join entity (Enrollment) with JPA annotations and enum mapping.
- Add Bean Validation annotations on DTOs and handle validation errors globally with @ControllerAdvice and custom exceptions.
- Implement pagination and sorting using Pageable and Page on the course listing endpoint.
- Secret Mission: Built a student transcript endpoint using JPQL JOIN FETCH and Java Streams grouping to return courses organized by enrollment status.
Ready to quiz yourself?