Building a Full Learning Management System in Spring Boot
Most LMS tutorials stop at a course table and call it done. This guide goes further — covering the data model, JWT security, enrollment logic, and progress tracking that a real LMS actually needs.
Building a Full LMS with Spring Boot
Most LMS tutorials show you a CRUD app with a Course table and call it done. That's not an LMS — that's a course catalogue. A real Learning Management System needs enrollment logic, role separation, progress tracking, and access control baked in from day one. Bolt it on later and you're refactoring forever.
This guide builds the core of a production-ready LMS: data model, security, enrollment API, and progress tracking. We'll skip the boilerplate lectures and go straight to the decisions that matter.
What We're Actually Building
Before writing a line of code, let's define the system boundary.
An LMS has four core domains:
| Domain | Responsibility |
|---|---|
| Users & Roles | Admin, Instructor, Student — with scoped permissions |
| Courses & Content | Course → Sections → Lessons (nested hierarchy) |
| Enrollment | Student ↔ Course relationships with state |
| Progress | Per-lesson completion tracking + course-level aggregation |
We will not cover: video hosting, payment integration, live sessions, or notifications. Those are real problems — but they're integration problems, not architecture problems. Solve the core first.
Project Setup
Generate the project at start.spring.io with these dependencies:
- Spring Web
- Spring Data JPA
- Spring Security
- PostgreSQL Driver
- Lombok
- Validation
<!-- pom.xml — key dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
Data Model
This is where most LMS implementations go wrong. They start flat and pay for it later.
User & Roles
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL)
private List<Enrollment> enrollments = new ArrayList<>();
public enum Role {
ADMIN, INSTRUCTOR, STUDENT
}
}
Course Hierarchy
@Entity
@Table(name = "courses")
@Getter @Setter @NoArgsConstructor
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
private CourseStatus status = CourseStatus.DRAFT;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "instructor_id", nullable = false)
private User instructor;
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC")
private List<Section> sections = new ArrayList<>();
public enum CourseStatus {
DRAFT, PUBLISHED, ARCHIVED
}
}
@Entity
@Table(name = "sections")
@Getter @Setter @NoArgsConstructor
public class Section {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int position;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
@OneToMany(mappedBy = "section", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC")
private List<Lesson> lessons = new ArrayList<>();
}
@Entity
@Table(name = "lessons")
@Getter @Setter @NoArgsConstructor
public class Lesson {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private int position;
@Enumerated(EnumType.STRING)
private LessonType type; // VIDEO, TEXT, QUIZ
@Column(columnDefinition = "TEXT")
private String contentUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id", nullable = false)
private Section section;
public enum LessonType {
VIDEO, TEXT, QUIZ
}
}
Enrollment & Progress
@Entity
@Table(
name = "enrollments",
uniqueConstraints = @UniqueConstraint(columnNames = {"student_id", "course_id"})
)
@Getter @Setter @NoArgsConstructor
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id", nullable = false)
private User student;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
@Enumerated(EnumType.STRING)
private EnrollmentStatus status = EnrollmentStatus.ACTIVE;
private LocalDateTime enrolledAt = LocalDateTime.now();
private LocalDateTime completedAt;
public enum EnrollmentStatus {
ACTIVE, COMPLETED, DROPPED
}
}
@Entity
@Table(
name = "lesson_progress",
uniqueConstraints = @UniqueConstraint(columnNames = {"enrollment_id", "lesson_id"})
)
@Getter @Setter @NoArgsConstructor
public class LessonProgress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "enrollment_id", nullable = false)
private Enrollment enrollment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "lesson_id", nullable = false)
private Lesson lesson;
private boolean completed = false;
private LocalDateTime completedAt;
}
Security: JWT + Method-Level Authorization
Spring Security config is notoriously verbose. Here's the minimal working setup with JWT:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/courses/**").hasAnyRole("STUDENT", "INSTRUCTOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/courses/**").hasAnyRole("INSTRUCTOR", "ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
For finer-grained control — like "an instructor can only edit their own courses" — use @PreAuthorize at the service layer:
@Service
@RequiredArgsConstructor
public class CourseService {
private final CourseRepository courseRepository;
@PreAuthorize("hasRole('INSTRUCTOR') and #course.instructor.email == authentication.name")
public Course update(Course course) {
return courseRepository.save(course);
}
@PreAuthorize("hasRole('ADMIN') or @enrollmentService.isEnrolled(#courseId, authentication.name)")
public Course getCourseContent(Long courseId) {
return courseRepository.findById(courseId)
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
}
}
Building something like this? Hire me to build your backend — NestJS, Node.js, PostgreSQL & Redis, shipped production-ready.
Enrollment API
@RestController
@RequestMapping("/api/enrollments")
@RequiredArgsConstructor
public class EnrollmentController {
private final EnrollmentService enrollmentService;
@PostMapping("/{courseId}")
@PreAuthorize("hasRole('STUDENT')")
public ResponseEntity<EnrollmentResponse> enroll(
@PathVariable Long courseId,
Authentication auth
) {
Enrollment enrollment = enrollmentService.enroll(courseId, auth.getName());
return ResponseEntity.status(HttpStatus.CREATED)
.body(EnrollmentResponse.from(enrollment));
}
@DeleteMapping("/{courseId}")
@PreAuthorize("hasRole('STUDENT')")
public ResponseEntity<Void> drop(
@PathVariable Long courseId,
Authentication auth
) {
enrollmentService.drop(courseId, auth.getName());
return ResponseEntity.noContent().build();
}
@GetMapping("/my-courses")
@PreAuthorize("hasRole('STUDENT')")
public ResponseEntity<List<EnrolledCourseResponse>> getMyCourses(Authentication auth) {
return ResponseEntity.ok(enrollmentService.getEnrolledCourses(auth.getName()));
}
}
@Service
@RequiredArgsConstructor
@Transactional
public class EnrollmentService {
private final EnrollmentRepository enrollmentRepository;
private final CourseRepository courseRepository;
private final UserRepository userRepository;
public Enrollment enroll(Long courseId, String email) {
User student = userRepository.findByEmail(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
Course course = courseRepository.findByIdAndStatus(courseId, Course.CourseStatus.PUBLISHED)
.orElseThrow(() -> new ResourceNotFoundException("Published course not found"));
if (enrollmentRepository.existsByStudentAndCourse(student, course)) {
throw new ConflictException("Already enrolled in this course");
}
Enrollment enrollment = new Enrollment();
enrollment.setStudent(student);
enrollment.setCourse(course);
return enrollmentRepository.save(enrollment);
}
public boolean isEnrolled(Long courseId, String email) {
return enrollmentRepository.existsByStudentEmailAndCourseId(email, courseId);
}
}
Progress Tracking
Progress is deceptively tricky. The naive approach recalculates from scratch on every request. Instead, use a derived query that JPA can execute in a single SQL statement:
public interface LessonProgressRepository extends JpaRepository<LessonProgress, Long> {
int countByEnrollmentAndCompleted(Enrollment enrollment, boolean completed);
Optional<LessonProgress> findByEnrollmentAndLesson(Enrollment enrollment, Lesson lesson);
}
@Service
@RequiredArgsConstructor
@Transactional
public class ProgressService {
private final LessonProgressRepository progressRepository;
private final EnrollmentRepository enrollmentRepository;
private final LessonRepository lessonRepository;
public ProgressResponse markLessonComplete(Long lessonId, String email) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
Course course = lesson.getSection().getCourse();
Enrollment enrollment = enrollmentRepository
.findByStudentEmailAndCourse(email, course)
.orElseThrow(() -> new ForbiddenException("Not enrolled in this course"));
LessonProgress progress = progressRepository
.findByEnrollmentAndLesson(enrollment, lesson)
.orElseGet(() -> {
LessonProgress p = new LessonProgress();
p.setEnrollment(enrollment);
p.setLesson(lesson);
return p;
});
progress.setCompleted(true);
progress.setCompletedAt(LocalDateTime.now());
progressRepository.save(progress);
return buildProgressResponse(enrollment, course);
}
private ProgressResponse buildProgressResponse(Enrollment enrollment, Course course) {
int totalLessons = lessonRepository.countByCourseId(course.getId());
int completedLessons = progressRepository
.countByEnrollmentAndCompleted(enrollment, true);
double percentage = totalLessons == 0 ? 0
: (double) completedLessons / totalLessons * 100;
if (completedLessons == totalLessons && totalLessons > 0) {
enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED);
enrollment.setCompletedAt(LocalDateTime.now());
enrollmentRepository.save(enrollment);
}
return new ProgressResponse(totalLessons, completedLessons, percentage);
}
}
API Summary
| Method | Endpoint | Role | Description |
|---|---|---|---|
| POST | /api/auth/register | Public | Register a new user |
| POST | /api/auth/login | Public | Get JWT token |
| GET | /api/courses | Any | List published courses |
| POST | /api/courses | Instructor | Create a course |
| PUT | /api/courses/{id} | Instructor (owner) | Update own course |
| POST | /api/enrollments/{courseId} | Student | Enroll in a course |
| DELETE | /api/enrollments/{courseId} | Student | Drop a course |
| GET | /api/enrollments/my-courses | Student | Get enrolled courses |
| POST | /api/progress/{lessonId}/complete | Student | Mark lesson complete |
| GET | /api/progress/{courseId} | Student | Get course progress |
What to Build Next
The foundation above handles 80% of LMS core logic. What's still missing:
- Quiz engine —
Quiz,Question,Option,Attemptentities with scoring logic - Certificate generation — trigger on
COMPLETEDenrollment status, generate PDF - Admin dashboard — aggregate stats across users, courses, completion rates
- Content storage — integrate S3 for video/file uploads, return signed URLs
- Rate limiting — protect enrollment endpoints from abuse Each of these is a separate bounded context. Build them as separate modules, not as more tables in the same service.
Common Mistakes to Avoid
1. Eager loading everything. The Course → Section → Lesson chain will destroy your response time if you don't use FetchType.LAZY and write targeted queries with JOIN FETCH when you actually need the full tree.
2. No unique constraint on enrollments. Without the database-level constraint, concurrent requests will create duplicate enrollments that corrupt progress calculations.
3. Calculating progress in application code. Push aggregation back to the database with a count query. Don't fetch 500 LessonProgress rows and count them in Java.
4. One role for everything. Instructors should not be able to enroll students, access other instructors' content, or see admin analytics. Model permissions explicitly from day one.
Final Thoughts
The goal here wasn't to build a feature-complete LMS — it was to show you the structural decisions that make one maintainable. Data modeling and access control are the hard parts. CRUD endpoints are not.
If you're building this for production, add integration tests before you add more features. The enrollment + progress logic is exactly the kind of thing that silently breaks under concurrent load.
Have questions about the implementation? Drop a Message