Build a Video Platform: Course & Lesson Data Model

Users can register and log in. Now we need something for them to actually watch. In this post, we'll build the content data model — courses, sections, and lessons — with proper JPA relationships, database migrations, and a full admin CRUD API.
The data model is the backbone of the platform. Get it right, and every feature from here on (upload, transcoding, player, catalog) just plugs in. Get it wrong, and you'll be fighting schema changes for the rest of the series.
Time commitment: 2–3 hours
Prerequisites: Phase 2: Authentication
What we'll build in this post:
✅ Course, Section, and Lesson JPA entities with relationships
✅ Flyway database migrations for versioned schema management
✅ Admin CRUD API for courses, sections, and lessons
✅ Slug generation from course titles
✅ Sort ordering with drag-and-drop support
✅ DTO mapping with MapStruct
✅ Validation with Bean Validation (Jakarta)
Data Model Architecture
Before writing any code, let's visualize how the entities relate to each other:
The hierarchy is simple: Course → Sections → Lessons. A course has multiple sections (like "Getting Started", "Advanced Topics"), and each section has multiple lessons. This three-level structure is what Udemy, Coursera, and every major course platform uses.
Setting Up Flyway
We've been creating tables manually in previous posts. Time to do it properly with versioned database migrations.
Add Flyway Dependency
// build.gradle
dependencies {
// ... existing dependencies
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
}Configure Flyway
# src/main/resources/application.yaml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0The baseline-on-migrate: true setting lets Flyway work with an existing database — it won't fail if tables already exist from previous posts.
Migration: Users Table (Baseline)
Since we created the users and oauth_accounts tables in the authentication post, we'll write a baseline migration that creates them if they don't exist:
-- src/main/resources/db/migration/V1__create_users_tables.sql
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
name VARCHAR(255) NOT NULL,
avatar_url VARCHAR(500),
role VARCHAR(20) NOT NULL DEFAULT 'USER',
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_account_id VARCHAR(255) NOT NULL,
access_token VARCHAR(500),
refresh_token VARCHAR(500),
UNIQUE(provider, provider_account_id)
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user_id ON oauth_accounts(user_id);Migration: Course Tables
-- src/main/resources/db/migration/V2__create_course_tables.sql
-- Courses
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(255) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
description TEXT,
short_description VARCHAR(500),
thumbnail_url VARCHAR(500),
difficulty VARCHAR(20) NOT NULL DEFAULT 'BEGINNER',
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
total_duration INT NOT NULL DEFAULT 0,
total_lessons INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Sections
CREATE TABLE sections (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Lessons
CREATE TABLE lessons (
id BIGSERIAL PRIMARY KEY,
section_id BIGINT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
video_path VARCHAR(500),
duration INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
is_free_preview BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_courses_slug ON courses(slug);
CREATE INDEX idx_courses_status ON courses(status);
CREATE INDEX idx_sections_course_id ON sections(course_id);
CREATE INDEX idx_sections_sort_order ON sections(course_id, sort_order);
CREATE INDEX idx_lessons_section_id ON lessons(section_id);
CREATE INDEX idx_lessons_sort_order ON lessons(section_id, sort_order);Migration: Progress Tables
-- src/main/resources/db/migration/V3__create_progress_tables.sql
-- Course enrollments
CREATE TABLE course_enrollments (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
progress_pct INT NOT NULL DEFAULT 0,
last_lesson_id BIGINT REFERENCES lessons(id) ON DELETE SET NULL,
enrolled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
PRIMARY KEY (user_id, course_id)
);
-- Lesson progress
CREATE TABLE lesson_progress (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
watched_seconds INT NOT NULL DEFAULT 0,
completed BOOLEAN NOT NULL DEFAULT FALSE,
last_position INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, lesson_id)
);
CREATE INDEX idx_course_enrollments_user ON course_enrollments(user_id);
CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id);When Spring Boot starts, Flyway automatically runs any pending migrations. You'll see this in the logs:
Flyway Community Edition 10.x.x
Successfully validated 3 migrations
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version 1 - create users tables
Migrating schema "public" to version 2 - create course tables
Migrating schema "public" to version 3 - create progress tablesJPA Entities
Enums
First, define the enums used across entities:
// src/main/java/com/videoplatform/api/model/enums/CourseStatus.java
package com.videoplatform.api.model.enums;
public enum CourseStatus {
DRAFT,
PUBLISHED,
ARCHIVED
}// src/main/java/com/videoplatform/api/model/enums/Difficulty.java
package com.videoplatform.api.model.enums;
public enum Difficulty {
BEGINNER,
INTERMEDIATE,
ADVANCED
}// src/main/java/com/videoplatform/api/model/enums/LessonStatus.java
package com.videoplatform.api.model.enums;
public enum LessonStatus {
PROCESSING,
READY,
FAILED
}Course Entity
// src/main/java/com/videoplatform/api/model/Course.java
package com.videoplatform.api.model;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String slug;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "short_description", length = 500)
private String shortDescription;
@Column(name = "thumbnail_url", length = 500)
private String thumbnailUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Difficulty difficulty = Difficulty.BEGINNER;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CourseStatus status = CourseStatus.DRAFT;
@Column(name = "total_duration")
private int totalDuration = 0;
@Column(name = "total_lessons")
private int totalLessons = 0;
@Column(name = "sort_order")
private int sortOrder = 0;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC")
private List<Section> sections = new ArrayList<>();
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getShortDescription() { return shortDescription; }
public void setShortDescription(String shortDescription) { this.shortDescription = shortDescription; }
public String getThumbnailUrl() { return thumbnailUrl; }
public void setThumbnailUrl(String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; }
public Difficulty getDifficulty() { return difficulty; }
public void setDifficulty(Difficulty difficulty) { this.difficulty = difficulty; }
public CourseStatus getStatus() { return status; }
public void setStatus(CourseStatus status) { this.status = status; }
public int getTotalDuration() { return totalDuration; }
public void setTotalDuration(int totalDuration) { this.totalDuration = totalDuration; }
public int getTotalLessons() { return totalLessons; }
public void setTotalLessons(int totalLessons) { this.totalLessons = totalLessons; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public LocalDateTime getPublishedAt() { return publishedAt; }
public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<Section> getSections() { return sections; }
public void setSections(List<Section> sections) { this.sections = sections; }
public void addSection(Section section) {
sections.add(section);
section.setCourse(this);
}
public void removeSection(Section section) {
sections.remove(section);
section.setCourse(null);
}
}Section Entity
// src/main/java/com/videoplatform/api/model/Section.java
package com.videoplatform.api.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "sections")
public class Section {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
@Column(nullable = false)
private String title;
@Column(name = "sort_order")
private int sortOrder = 0;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "section", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC")
private List<Lesson> lessons = new ArrayList<>();
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Course getCourse() { return course; }
public void setCourse(Course course) { this.course = course; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<Lesson> getLessons() { return lessons; }
public void setLessons(List<Lesson> lessons) { this.lessons = lessons; }
public void addLesson(Lesson lesson) {
lessons.add(lesson);
lesson.setSection(this);
}
public void removeLesson(Lesson lesson) {
lessons.remove(lesson);
lesson.setSection(null);
}
}Lesson Entity
// src/main/java/com/videoplatform/api/model/Lesson.java
package com.videoplatform.api.model;
import com.videoplatform.api.model.enums.LessonStatus;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "lessons")
public class Lesson {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "section_id", nullable = false)
private Section section;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "video_path", length = 500)
private String videoPath;
private int duration = 0;
@Column(name = "sort_order")
private int sortOrder = 0;
@Column(name = "is_free_preview")
private boolean freePreview = false;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private LessonStatus status = LessonStatus.PROCESSING;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Section getSection() { return section; }
public void setSection(Section section) { this.section = section; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getVideoPath() { return videoPath; }
public void setVideoPath(String videoPath) { this.videoPath = videoPath; }
public int getDuration() { return duration; }
public void setDuration(int duration) { this.duration = duration; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isFreePreview() { return freePreview; }
public void setFreePreview(boolean freePreview) { this.freePreview = freePreview; }
public LessonStatus getStatus() { return status; }
public void setStatus(LessonStatus status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}Key JPA Decisions
| Decision | Why |
|---|---|
CascadeType.ALL + orphanRemoval on sections | Delete a course → all sections and lessons gone |
FetchType.LAZY on @ManyToOne | Don't load the parent course when you just want a section |
@OrderBy("sortOrder ASC") | Sections and lessons always come back in the right order |
@PrePersist / @PreUpdate | Timestamps managed by JPA, not by SQL defaults |
Enums as STRING not ORDINAL | Adding ARCHIVED between DRAFT and PUBLISHED won't break existing data |
Slug Generation
Every course needs a URL-friendly slug generated from its title. We'll build a utility that handles unicode, special characters, and duplicates.
// src/main/java/com/videoplatform/api/util/SlugUtils.java
package com.videoplatform.api.util;
import java.text.Normalizer;
import java.util.Locale;
import java.util.regex.Pattern;
public final class SlugUtils {
private static final Pattern NON_LATIN = Pattern.compile("[^\\w-]");
private static final Pattern WHITESPACE = Pattern.compile("[\\s]+");
private static final Pattern MULTIPLE_DASHES = Pattern.compile("-{2,}");
private SlugUtils() {}
public static String toSlug(String input) {
if (input == null || input.isBlank()) {
throw new IllegalArgumentException("Slug input cannot be empty");
}
String slug = input.trim().toLowerCase(Locale.ROOT);
// Normalize unicode characters (é → e, ñ → n)
slug = Normalizer.normalize(slug, Normalizer.Form.NFD);
slug = slug.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
// Replace whitespace with dashes
slug = WHITESPACE.matcher(slug).replaceAll("-");
// Remove non-latin characters
slug = NON_LATIN.matcher(slug).replaceAll("");
// Collapse multiple dashes
slug = MULTIPLE_DASHES.matcher(slug).replaceAll("-");
// Trim leading/trailing dashes
slug = slug.replaceAll("^-|-$", "");
return slug;
}
public static String toUniqueSlug(String input, java.util.function.Predicate<String> existsCheck) {
String baseSlug = toSlug(input);
if (!existsCheck.test(baseSlug)) {
return baseSlug;
}
// Append incrementing number until unique
for (int i = 2; i < 1000; i++) {
String candidate = baseSlug + "-" + i;
if (!existsCheck.test(candidate)) {
return candidate;
}
}
throw new IllegalStateException("Could not generate unique slug for: " + input);
}
}Example transformations:
| Input | Output |
|---|---|
"Spring Boot Masterclass" | spring-boot-masterclass |
"React & Next.js: The Complete Guide" | react--nextjs-the-complete-guide → react-nextjs-the-complete-guide |
"Café Résumé Builder" | cafe-resume-builder |
"Spring Boot Masterclass" (duplicate) | spring-boot-masterclass-2 |
Repositories
// src/main/java/com/videoplatform/api/repository/CourseRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.model.Course;
import com.videoplatform.api.model.enums.CourseStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface CourseRepository extends JpaRepository<Course, Long> {
Optional<Course> findBySlug(String slug);
boolean existsBySlug(String slug);
List<Course> findByStatusOrderBySortOrderAsc(CourseStatus status);
@Query("SELECT c FROM Course c LEFT JOIN FETCH c.sections s " +
"LEFT JOIN FETCH s.lessons WHERE c.slug = :slug")
Optional<Course> findBySlugWithSectionsAndLessons(String slug);
@Query("SELECT c FROM Course c LEFT JOIN FETCH c.sections s " +
"LEFT JOIN FETCH s.lessons WHERE c.id = :id")
Optional<Course> findByIdWithSectionsAndLessons(Long id);
List<Course> findAllByOrderBySortOrderAsc();
}// src/main/java/com/videoplatform/api/repository/SectionRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.model.Section;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface SectionRepository extends JpaRepository<Section, Long> {
List<Section> findByCourseIdOrderBySortOrderAsc(Long courseId);
@Query("SELECT COALESCE(MAX(s.sortOrder), -1) + 1 FROM Section s WHERE s.course.id = :courseId")
int findNextSortOrder(Long courseId);
}// src/main/java/com/videoplatform/api/repository/LessonRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.model.Lesson;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface LessonRepository extends JpaRepository<Lesson, Long> {
List<Lesson> findBySectionIdOrderBySortOrderAsc(Long sectionId);
@Query("SELECT COALESCE(MAX(l.sortOrder), -1) + 1 FROM Lesson l WHERE l.section.id = :sectionId")
int findNextSortOrder(Long sectionId);
@Query("SELECT COUNT(l) FROM Lesson l WHERE l.section.course.id = :courseId")
int countByCourseId(Long courseId);
@Query("SELECT COALESCE(SUM(l.duration), 0) FROM Lesson l WHERE l.section.course.id = :courseId")
int sumDurationByCourseId(Long courseId);
}The findNextSortOrder queries calculate the next available sort position. When you add a new section or lesson, it automatically gets appended to the end.
DTOs and MapStruct Mapping
We never expose JPA entities directly in API responses. DTOs let us control exactly what data goes over the wire, avoid lazy loading issues, and decouple the API contract from the database schema.
Add MapStruct Dependency
// build.gradle
dependencies {
// ... existing dependencies
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}Request DTOs
// src/main/java/com/videoplatform/api/dto/request/CreateCourseRequest.java
package com.videoplatform.api.dto.request;
import com.videoplatform.api.model.enums.Difficulty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateCourseRequest(
@NotBlank(message = "Title is required")
@Size(max = 255, message = "Title must be under 255 characters")
String title,
String description,
@Size(max = 500, message = "Short description must be under 500 characters")
String shortDescription,
Difficulty difficulty
) {
public CreateCourseRequest {
if (difficulty == null) {
difficulty = Difficulty.BEGINNER;
}
}
}// src/main/java/com/videoplatform/api/dto/request/UpdateCourseRequest.java
package com.videoplatform.api.dto.request;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import jakarta.validation.constraints.Size;
public record UpdateCourseRequest(
@Size(max = 255, message = "Title must be under 255 characters")
String title,
String description,
@Size(max = 500)
String shortDescription,
String thumbnailUrl,
Difficulty difficulty,
CourseStatus status
) {}// src/main/java/com/videoplatform/api/dto/request/CreateSectionRequest.java
package com.videoplatform.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateSectionRequest(
@NotBlank(message = "Title is required")
@Size(max = 255)
String title
) {}// src/main/java/com/videoplatform/api/dto/request/CreateLessonRequest.java
package com.videoplatform.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateLessonRequest(
@NotBlank(message = "Title is required")
@Size(max = 255)
String title,
String description,
boolean freePreview
) {}// src/main/java/com/videoplatform/api/dto/request/ReorderRequest.java
package com.videoplatform.api.dto.request;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record ReorderRequest(
@NotEmpty(message = "Order list cannot be empty")
List<Long> orderedIds
) {}Response DTOs
// src/main/java/com/videoplatform/api/dto/response/CourseResponse.java
package com.videoplatform.api.dto.response;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import java.time.LocalDateTime;
public record CourseResponse(
Long id,
String slug,
String title,
String description,
String shortDescription,
String thumbnailUrl,
Difficulty difficulty,
CourseStatus status,
int totalDuration,
int totalLessons,
int sortOrder,
LocalDateTime publishedAt,
LocalDateTime createdAt
) {}// src/main/java/com/videoplatform/api/dto/response/CourseDetailResponse.java
package com.videoplatform.api.dto.response;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.model.enums.Difficulty;
import java.time.LocalDateTime;
import java.util.List;
public record CourseDetailResponse(
Long id,
String slug,
String title,
String description,
String shortDescription,
String thumbnailUrl,
Difficulty difficulty,
CourseStatus status,
int totalDuration,
int totalLessons,
int sortOrder,
LocalDateTime publishedAt,
LocalDateTime createdAt,
List<SectionResponse> sections
) {}// src/main/java/com/videoplatform/api/dto/response/SectionResponse.java
package com.videoplatform.api.dto.response;
import java.util.List;
public record SectionResponse(
Long id,
String title,
int sortOrder,
List<LessonResponse> lessons
) {}// src/main/java/com/videoplatform/api/dto/response/LessonResponse.java
package com.videoplatform.api.dto.response;
import com.videoplatform.api.model.enums.LessonStatus;
public record LessonResponse(
Long id,
String title,
String description,
int duration,
int sortOrder,
boolean freePreview,
LessonStatus status,
String videoPath
) {}MapStruct Mapper
// src/main/java/com/videoplatform/api/mapper/CourseMapper.java
package com.videoplatform.api.mapper;
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.model.*;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface CourseMapper {
CourseResponse toCourseResponse(Course course);
@Mapping(target = "sections", source = "sections")
CourseDetailResponse toCourseDetailResponse(Course course);
@Mapping(target = "lessons", source = "lessons")
SectionResponse toSectionResponse(Section section);
LessonResponse toLessonResponse(Lesson lesson);
}MapStruct generates the implementation at compile time — no reflection, no runtime overhead. The @Mapper(componentModel = "spring") annotation makes it a Spring bean you can inject anywhere.
Service Layer
CourseService
// src/main/java/com/videoplatform/api/service/CourseService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.request.*;
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.mapper.CourseMapper;
import com.videoplatform.api.model.Course;
import com.videoplatform.api.model.enums.CourseStatus;
import com.videoplatform.api.repository.CourseRepository;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.util.SlugUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class CourseService {
private final CourseRepository courseRepository;
private final LessonRepository lessonRepository;
private final CourseMapper courseMapper;
public CourseService(CourseRepository courseRepository,
LessonRepository lessonRepository,
CourseMapper courseMapper) {
this.courseRepository = courseRepository;
this.lessonRepository = lessonRepository;
this.courseMapper = courseMapper;
}
public List<CourseResponse> getAllCourses() {
return courseRepository.findAllByOrderBySortOrderAsc()
.stream()
.map(courseMapper::toCourseResponse)
.toList();
}
public List<CourseResponse> getPublishedCourses() {
return courseRepository.findByStatusOrderBySortOrderAsc(CourseStatus.PUBLISHED)
.stream()
.map(courseMapper::toCourseResponse)
.toList();
}
public CourseDetailResponse getCourseBySlug(String slug) {
Course course = courseRepository.findBySlugWithSectionsAndLessons(slug)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + slug));
return courseMapper.toCourseDetailResponse(course);
}
public CourseDetailResponse getCourseById(Long id) {
Course course = courseRepository.findByIdWithSectionsAndLessons(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + id));
return courseMapper.toCourseDetailResponse(course);
}
@Transactional
public CourseResponse createCourse(CreateCourseRequest request) {
Course course = new Course();
course.setTitle(request.title());
course.setDescription(request.description());
course.setShortDescription(request.shortDescription());
course.setDifficulty(request.difficulty());
// Generate unique slug from title
String slug = SlugUtils.toUniqueSlug(
request.title(),
courseRepository::existsBySlug
);
course.setSlug(slug);
Course saved = courseRepository.save(course);
return courseMapper.toCourseResponse(saved);
}
@Transactional
public CourseResponse updateCourse(Long id, UpdateCourseRequest request) {
Course course = courseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + id));
if (request.title() != null) {
course.setTitle(request.title());
// Regenerate slug if title changed
String slug = SlugUtils.toUniqueSlug(
request.title(),
s -> !s.equals(course.getSlug()) && courseRepository.existsBySlug(s)
);
course.setSlug(slug);
}
if (request.description() != null) {
course.setDescription(request.description());
}
if (request.shortDescription() != null) {
course.setShortDescription(request.shortDescription());
}
if (request.thumbnailUrl() != null) {
course.setThumbnailUrl(request.thumbnailUrl());
}
if (request.difficulty() != null) {
course.setDifficulty(request.difficulty());
}
if (request.status() != null) {
course.setStatus(request.status());
// Set publishedAt when first published
if (request.status() == CourseStatus.PUBLISHED && course.getPublishedAt() == null) {
course.setPublishedAt(LocalDateTime.now());
}
}
Course saved = courseRepository.save(course);
return courseMapper.toCourseResponse(saved);
}
@Transactional
public void deleteCourse(Long id) {
if (!courseRepository.existsById(id)) {
throw new ResourceNotFoundException("Course not found: " + id);
}
courseRepository.deleteById(id);
}
@Transactional
public void reorderCourses(ReorderRequest request) {
List<Long> orderedIds = request.orderedIds();
for (int i = 0; i < orderedIds.size(); i++) {
Course course = courseRepository.findById(orderedIds.get(i))
.orElseThrow(() -> new ResourceNotFoundException("Course not found"));
course.setSortOrder(i);
courseRepository.save(course);
}
}
@Transactional
public void refreshCourseStats(Long courseId) {
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + courseId));
course.setTotalLessons(lessonRepository.countByCourseId(courseId));
course.setTotalDuration(lessonRepository.sumDurationByCourseId(courseId));
courseRepository.save(course);
}
}SectionService
// src/main/java/com/videoplatform/api/service/SectionService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.request.CreateSectionRequest;
import com.videoplatform.api.dto.request.ReorderRequest;
import com.videoplatform.api.dto.response.SectionResponse;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.mapper.CourseMapper;
import com.videoplatform.api.model.Course;
import com.videoplatform.api.model.Section;
import com.videoplatform.api.repository.CourseRepository;
import com.videoplatform.api.repository.SectionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class SectionService {
private final SectionRepository sectionRepository;
private final CourseRepository courseRepository;
private final CourseMapper courseMapper;
public SectionService(SectionRepository sectionRepository,
CourseRepository courseRepository,
CourseMapper courseMapper) {
this.sectionRepository = sectionRepository;
this.courseRepository = courseRepository;
this.courseMapper = courseMapper;
}
public List<SectionResponse> getSectionsByCourseId(Long courseId) {
return sectionRepository.findByCourseIdOrderBySortOrderAsc(courseId)
.stream()
.map(courseMapper::toSectionResponse)
.toList();
}
@Transactional
public SectionResponse createSection(Long courseId, CreateSectionRequest request) {
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> new ResourceNotFoundException("Course not found: " + courseId));
Section section = new Section();
section.setTitle(request.title());
section.setSortOrder(sectionRepository.findNextSortOrder(courseId));
section.setCourse(course);
Section saved = sectionRepository.save(section);
return courseMapper.toSectionResponse(saved);
}
@Transactional
public SectionResponse updateSection(Long sectionId, CreateSectionRequest request) {
Section section = sectionRepository.findById(sectionId)
.orElseThrow(() -> new ResourceNotFoundException("Section not found: " + sectionId));
section.setTitle(request.title());
Section saved = sectionRepository.save(section);
return courseMapper.toSectionResponse(saved);
}
@Transactional
public void deleteSection(Long sectionId) {
if (!sectionRepository.existsById(sectionId)) {
throw new ResourceNotFoundException("Section not found: " + sectionId);
}
sectionRepository.deleteById(sectionId);
}
@Transactional
public void reorderSections(Long courseId, ReorderRequest request) {
List<Long> orderedIds = request.orderedIds();
for (int i = 0; i < orderedIds.size(); i++) {
Section section = sectionRepository.findById(orderedIds.get(i))
.orElseThrow(() -> new ResourceNotFoundException("Section not found"));
section.setSortOrder(i);
sectionRepository.save(section);
}
}
}LessonService
// src/main/java/com/videoplatform/api/service/LessonService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.request.CreateLessonRequest;
import com.videoplatform.api.dto.request.ReorderRequest;
import com.videoplatform.api.dto.response.LessonResponse;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.mapper.CourseMapper;
import com.videoplatform.api.model.Lesson;
import com.videoplatform.api.model.Section;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.repository.SectionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class LessonService {
private final LessonRepository lessonRepository;
private final SectionRepository sectionRepository;
private final CourseService courseService;
private final CourseMapper courseMapper;
public LessonService(LessonRepository lessonRepository,
SectionRepository sectionRepository,
CourseService courseService,
CourseMapper courseMapper) {
this.lessonRepository = lessonRepository;
this.sectionRepository = sectionRepository;
this.courseService = courseService;
this.courseMapper = courseMapper;
}
public List<LessonResponse> getLessonsBySectionId(Long sectionId) {
return lessonRepository.findBySectionIdOrderBySortOrderAsc(sectionId)
.stream()
.map(courseMapper::toLessonResponse)
.toList();
}
@Transactional
public LessonResponse createLesson(Long sectionId, CreateLessonRequest request) {
Section section = sectionRepository.findById(sectionId)
.orElseThrow(() -> new ResourceNotFoundException("Section not found: " + sectionId));
Lesson lesson = new Lesson();
lesson.setTitle(request.title());
lesson.setDescription(request.description());
lesson.setFreePreview(request.freePreview());
lesson.setSortOrder(lessonRepository.findNextSortOrder(sectionId));
lesson.setSection(section);
Lesson saved = lessonRepository.save(lesson);
// Update course stats
courseService.refreshCourseStats(section.getCourse().getId());
return courseMapper.toLessonResponse(saved);
}
@Transactional
public LessonResponse updateLesson(Long lessonId, CreateLessonRequest request) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
lesson.setTitle(request.title());
lesson.setDescription(request.description());
lesson.setFreePreview(request.freePreview());
Lesson saved = lessonRepository.save(lesson);
return courseMapper.toLessonResponse(saved);
}
@Transactional
public void deleteLesson(Long lessonId) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
Long courseId = lesson.getSection().getCourse().getId();
lessonRepository.deleteById(lessonId);
// Update course stats after deletion
courseService.refreshCourseStats(courseId);
}
@Transactional
public void reorderLessons(Long sectionId, ReorderRequest request) {
List<Long> orderedIds = request.orderedIds();
for (int i = 0; i < orderedIds.size(); i++) {
Lesson lesson = lessonRepository.findById(orderedIds.get(i))
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found"));
lesson.setSortOrder(i);
lessonRepository.save(lesson);
}
}
}REST Controllers
Admin Course Controller
All admin endpoints are under /api/admin/courses and require the ADMIN role:
// src/main/java/com/videoplatform/api/controller/AdminCourseController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.ApiResponse;
import com.videoplatform.api.dto.request.*;
import com.videoplatform.api.dto.response.*;
import com.videoplatform.api.service.CourseService;
import com.videoplatform.api.service.SectionService;
import com.videoplatform.api.service.LessonService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/courses")
@PreAuthorize("hasRole('ADMIN')")
public class AdminCourseController {
private final CourseService courseService;
private final SectionService sectionService;
private final LessonService lessonService;
public AdminCourseController(CourseService courseService,
SectionService sectionService,
LessonService lessonService) {
this.courseService = courseService;
this.sectionService = sectionService;
this.lessonService = lessonService;
}
// ==================== Courses ====================
@GetMapping
public ResponseEntity<ApiResponse<List<CourseResponse>>> getAllCourses() {
List<CourseResponse> courses = courseService.getAllCourses();
return ResponseEntity.ok(ApiResponse.success(courses));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<CourseDetailResponse>> getCourse(@PathVariable Long id) {
CourseDetailResponse course = courseService.getCourseById(id);
return ResponseEntity.ok(ApiResponse.success(course));
}
@PostMapping
public ResponseEntity<ApiResponse<CourseResponse>> createCourse(
@Valid @RequestBody CreateCourseRequest request) {
CourseResponse course = courseService.createCourse(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Course created", course));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<CourseResponse>> updateCourse(
@PathVariable Long id,
@Valid @RequestBody UpdateCourseRequest request) {
CourseResponse course = courseService.updateCourse(id, request);
return ResponseEntity.ok(ApiResponse.success("Course updated", course));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteCourse(@PathVariable Long id) {
courseService.deleteCourse(id);
return ResponseEntity.ok(ApiResponse.success("Course deleted", null));
}
@PutMapping("/reorder")
public ResponseEntity<ApiResponse<Void>> reorderCourses(
@Valid @RequestBody ReorderRequest request) {
courseService.reorderCourses(request);
return ResponseEntity.ok(ApiResponse.success("Courses reordered", null));
}
// ==================== Sections ====================
@GetMapping("/{courseId}/sections")
public ResponseEntity<ApiResponse<List<SectionResponse>>> getSections(
@PathVariable Long courseId) {
List<SectionResponse> sections = sectionService.getSectionsByCourseId(courseId);
return ResponseEntity.ok(ApiResponse.success(sections));
}
@PostMapping("/{courseId}/sections")
public ResponseEntity<ApiResponse<SectionResponse>> createSection(
@PathVariable Long courseId,
@Valid @RequestBody CreateSectionRequest request) {
SectionResponse section = sectionService.createSection(courseId, request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Section created", section));
}
@PutMapping("/sections/{sectionId}")
public ResponseEntity<ApiResponse<SectionResponse>> updateSection(
@PathVariable Long sectionId,
@Valid @RequestBody CreateSectionRequest request) {
SectionResponse section = sectionService.updateSection(sectionId, request);
return ResponseEntity.ok(ApiResponse.success("Section updated", section));
}
@DeleteMapping("/sections/{sectionId}")
public ResponseEntity<ApiResponse<Void>> deleteSection(@PathVariable Long sectionId) {
sectionService.deleteSection(sectionId);
return ResponseEntity.ok(ApiResponse.success("Section deleted", null));
}
@PutMapping("/{courseId}/sections/reorder")
public ResponseEntity<ApiResponse<Void>> reorderSections(
@PathVariable Long courseId,
@Valid @RequestBody ReorderRequest request) {
sectionService.reorderSections(courseId, request);
return ResponseEntity.ok(ApiResponse.success("Sections reordered", null));
}
// ==================== Lessons ====================
@GetMapping("/sections/{sectionId}/lessons")
public ResponseEntity<ApiResponse<List<LessonResponse>>> getLessons(
@PathVariable Long sectionId) {
List<LessonResponse> lessons = lessonService.getLessonsBySectionId(sectionId);
return ResponseEntity.ok(ApiResponse.success(lessons));
}
@PostMapping("/sections/{sectionId}/lessons")
public ResponseEntity<ApiResponse<LessonResponse>> createLesson(
@PathVariable Long sectionId,
@Valid @RequestBody CreateLessonRequest request) {
LessonResponse lesson = lessonService.createLesson(sectionId, request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Lesson created", lesson));
}
@PutMapping("/lessons/{lessonId}")
public ResponseEntity<ApiResponse<LessonResponse>> updateLesson(
@PathVariable Long lessonId,
@Valid @RequestBody CreateLessonRequest request) {
LessonResponse lesson = lessonService.updateLesson(lessonId, request);
return ResponseEntity.ok(ApiResponse.success("Lesson updated", lesson));
}
@DeleteMapping("/lessons/{lessonId}")
public ResponseEntity<ApiResponse<Void>> deleteLesson(@PathVariable Long lessonId) {
lessonService.deleteLesson(lessonId);
return ResponseEntity.ok(ApiResponse.success("Lesson deleted", null));
}
@PutMapping("/sections/{sectionId}/lessons/reorder")
public ResponseEntity<ApiResponse<Void>> reorderLessons(
@PathVariable Long sectionId,
@Valid @RequestBody ReorderRequest request) {
lessonService.reorderLessons(sectionId, request);
return ResponseEntity.ok(ApiResponse.success("Lessons reordered", null));
}
}Public Course Controller
Public endpoints serve the course catalog — only published courses, no admin fields:
// src/main/java/com/videoplatform/api/controller/CourseController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.ApiResponse;
import com.videoplatform.api.dto.response.CourseDetailResponse;
import com.videoplatform.api.dto.response.CourseResponse;
import com.videoplatform.api.service.CourseService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/courses")
public class CourseController {
private final CourseService courseService;
public CourseController(CourseService courseService) {
this.courseService = courseService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<CourseResponse>>> getPublishedCourses() {
List<CourseResponse> courses = courseService.getPublishedCourses();
return ResponseEntity.ok(ApiResponse.success(courses));
}
@GetMapping("/{slug}")
public ResponseEntity<ApiResponse<CourseDetailResponse>> getCourseBySlug(
@PathVariable String slug) {
CourseDetailResponse course = courseService.getCourseBySlug(slug);
return ResponseEntity.ok(ApiResponse.success(course));
}
}Update Security Config
Add the new public and admin routes to the Spring Security config from Post #3:
// Add to SecurityConfig.java filterChain() method:
.requestMatchers("/api/courses/**").permitAll() // Public catalog
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Admin endpointsAPI Request Flow
Here's the complete flow when an admin creates a new course with sections and lessons:
Sort Ordering and Drag-and-Drop
The reorder endpoints accept an array of IDs in the desired order. The frontend (which we'll build in Post #5) sends this array after drag-and-drop:
# Reorder sections within course 1
curl -X PUT http://localhost:8080/api/admin/courses/1/sections/reorder \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"orderedIds": [3, 1, 2]}'This sets sort_order to 0, 1, 2 for sections with IDs 3, 1, 2 respectively. The @OrderBy("sortOrder ASC") annotation ensures queries always return items in the correct order.
Testing the API
1. Create a Course
# First, log in as admin to get a token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "admin123"}' \
| jq -r '.data.accessToken')
# Create a course
curl -X POST http://localhost:8080/api/admin/courses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{
"title": "Spring Boot Masterclass",
"description": "Learn Spring Boot from scratch to production deployment",
"shortDescription": "Complete Spring Boot course",
"difficulty": "BEGINNER"
}'Response:
{
"success": true,
"message": "Course created",
"data": {
"id": 1,
"slug": "spring-boot-masterclass",
"title": "Spring Boot Masterclass",
"description": "Learn Spring Boot from scratch to production deployment",
"shortDescription": "Complete Spring Boot course",
"thumbnailUrl": null,
"difficulty": "BEGINNER",
"status": "DRAFT",
"totalDuration": 0,
"totalLessons": 0,
"sortOrder": 0,
"publishedAt": null,
"createdAt": "2026-03-22T10:30:00"
}
}2. Add Sections
# Add "Getting Started" section
curl -X POST http://localhost:8080/api/admin/courses/1/sections \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"title": "Getting Started"}'
# Add "Core Concepts" section
curl -X POST http://localhost:8080/api/admin/courses/1/sections \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"title": "Core Concepts"}'3. Add Lessons
# Add lessons to section 1
curl -X POST http://localhost:8080/api/admin/courses/sections/1/lessons \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{
"title": "What is Spring Boot?",
"description": "Introduction to the Spring Boot framework",
"freePreview": true
}'
curl -X POST http://localhost:8080/api/admin/courses/sections/1/lessons \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{
"title": "Setting Up Your Development Environment",
"description": "Install Java, IntelliJ, and create your first project",
"freePreview": true
}'4. Get Course with Full Structure
curl http://localhost:8080/api/admin/courses/1 \
-H "Authorization: Bearer ${TOKEN}" | jqResponse:
{
"success": true,
"data": {
"id": 1,
"slug": "spring-boot-masterclass",
"title": "Spring Boot Masterclass",
"status": "DRAFT",
"totalLessons": 2,
"sections": [
{
"id": 1,
"title": "Getting Started",
"sortOrder": 0,
"lessons": [
{
"id": 1,
"title": "What is Spring Boot?",
"sortOrder": 0,
"freePreview": true,
"status": "PROCESSING"
},
{
"id": 2,
"title": "Setting Up Your Development Environment",
"sortOrder": 1,
"freePreview": true,
"status": "PROCESSING"
}
]
},
{
"id": 2,
"title": "Core Concepts",
"sortOrder": 1,
"lessons": []
}
]
}
}5. Publish the Course
curl -X PUT http://localhost:8080/api/admin/courses/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"status": "PUBLISHED"}'6. Access Public Catalog
# No auth needed
curl http://localhost:8080/api/courses | jq
curl http://localhost:8080/api/courses/spring-boot-masterclass | jqComplete API Reference
| Method | Endpoint | Description | Auth |
|---|---|---|---|
GET | /api/courses | List published courses | Public |
GET | /api/courses/:slug | Get course by slug (with sections/lessons) | Public |
GET | /api/admin/courses | List all courses (including drafts) | Admin |
GET | /api/admin/courses/:id | Get course by ID (with sections/lessons) | Admin |
POST | /api/admin/courses | Create course | Admin |
PUT | /api/admin/courses/:id | Update course | Admin |
DELETE | /api/admin/courses/:id | Delete course (cascades) | Admin |
PUT | /api/admin/courses/reorder | Reorder courses | Admin |
GET | /api/admin/courses/:courseId/sections | List sections | Admin |
POST | /api/admin/courses/:courseId/sections | Create section | Admin |
PUT | /api/admin/courses/sections/:sectionId | Update section | Admin |
DELETE | /api/admin/courses/sections/:sectionId | Delete section (cascades) | Admin |
PUT | /api/admin/courses/:courseId/sections/reorder | Reorder sections | Admin |
GET | /api/admin/courses/sections/:sectionId/lessons | List lessons | Admin |
POST | /api/admin/courses/sections/:sectionId/lessons | Create lesson | Admin |
PUT | /api/admin/courses/lessons/:lessonId | Update lesson | Admin |
DELETE | /api/admin/courses/lessons/:lessonId | Delete lesson | Admin |
PUT | /api/admin/courses/sections/:sectionId/lessons/reorder | Reorder lessons | Admin |
Recap: What We've Built
At this point your platform has:
- 3 Flyway migrations that version your schema and run automatically on startup
- 3 JPA entities (Course → Section → Lesson) with cascading relationships
- Slug generation that handles duplicates and unicode characters
- Sort ordering ready for drag-and-drop reordering
- MapStruct DTOs that separate API contracts from database entities
- 20 REST endpoints for full CRUD on courses, sections, and lessons
- Role-based access: admin endpoints gated behind
@PreAuthorize("hasRole('ADMIN')")
Common Mistakes
1. LazyInitializationException
If you access course.getSections() outside a transaction, Hibernate throws LazyInitializationException. This is why we use JOIN FETCH queries in the repository:
// Wrong — will throw LazyInitializationException
Course course = courseRepository.findById(id).orElseThrow();
course.getSections(); // BOOM
// Right — fetch everything in one query
Course course = courseRepository.findByIdWithSectionsAndLessons(id).orElseThrow();
course.getSections(); // Works fine2. N+1 Query Problem
Without JOIN FETCH, loading a course with 10 sections and 50 lessons generates 1 + 10 + 50 = 61 queries. The findByIdWithSectionsAndLessons query does it in 1.
3. Forgetting orphanRemoval = true
Without orphanRemoval, removing a section from a course's list doesn't delete it from the database — it just sets course_id to null (which violates the NOT NULL constraint). Always pair CascadeType.ALL with orphanRemoval = true for parent-child relationships.
4. Enum ORDINAL vs STRING
// Wrong — stored as integer (0, 1, 2)
@Enumerated(EnumType.ORDINAL)
private CourseStatus status; // DRAFT=0, PUBLISHED=1, ARCHIVED=2
// Right — stored as string
@Enumerated(EnumType.STRING)
private CourseStatus status; // "DRAFT", "PUBLISHED", "ARCHIVED"With ORDINAL, adding a new enum value in the middle shifts all existing data. Always use STRING.
What's Next?
The content data model is in place. You can create, update, delete, and reorder courses, sections, and lessons through the admin API. In Post #5, we'll build the admin dashboard frontend:
- Next.js admin layout with sidebar navigation
- Course editor form with
react-hook-form+zodvalidation - Section and lesson management panels
- Drag-and-drop reordering with
@dnd-kit - Real-time form validation and error handling
Time to give the admin a proper UI.
Series: Build a Video Streaming Platform
Previous: Phase 2: Authentication
Next: Phase 4: Admin Dashboard
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.