spring-boot
8 Jun 2026
14 MIN READ

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:

DomainResponsibility
Users & RolesAdmin, Instructor, Student — with scoped permissions
Courses & ContentCourse → Sections → Lessons (nested hierarchy)
EnrollmentStudent ↔ Course relationships with state
ProgressPer-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
    }
}
Don't use a separate `roles` join table unless you genuinely need multi-role users. Most LMSes don't. A single enum column is simpler, faster to query, and easier to reason about.

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;
}
The `uniqueConstraint` on `LessonProgress` is load-bearing. Without it, a double-click on a "Mark Complete" button creates duplicate rows and your progress calculation breaks. ---

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

MethodEndpointRoleDescription
POST/api/auth/registerPublicRegister a new user
POST/api/auth/loginPublicGet JWT token
GET/api/coursesAnyList published courses
POST/api/coursesInstructorCreate a course
PUT/api/courses/{id}Instructor (owner)Update own course
POST/api/enrollments/{courseId}StudentEnroll in a course
DELETE/api/enrollments/{courseId}StudentDrop a course
GET/api/enrollments/my-coursesStudentGet enrolled courses
POST/api/progress/{lessonId}/completeStudentMark lesson complete
GET/api/progress/{courseId}StudentGet course progress

What to Build Next

The foundation above handles 80% of LMS core logic. What's still missing:

  • Quiz engineQuiz, Question, Option, Attempt entities with scoring logic
  • Certificate generation — trigger on COMPLETED enrollment 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 CourseSectionLesson 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

Mentioned Technologies

#spring-boot#java#lms#learning-management-system#rest-api#jwt-authentication#role-based-access-control#spring-security#jpa#backend-development

Frequently Asked Questions

It covers JPA data modeling, JWT authentication, role-based access control, course enrollment APIs, and lesson progress tracking with full code examples.

Authentication uses JWT with Spring Security, and authorization is enforced through role-based access control for students, instructors, and admins.

Yes. Spring Boot mature ecosystem, security features, and JPA support make it well-suited for building a structured, scalable LMS backend.

Built this in production

EDUPORTAL — LMS & Online Examination Platform

Learning Management System — read the case study

Related Reading

Need this built right?

I'm a freelance NestJS & Node.js backend developer. Let's ship your MVP or SaaS.

Hire Me

Ready for more?

Explore other insights in the gallery.

Browse All Posts