Back to blog

Build a Video Platform: Course & Lesson Data Model

javaspring-bootreactnextjsvideo-streaming
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: 0

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

JPA 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

DecisionWhy
CascadeType.ALL + orphanRemoval on sectionsDelete a course → all sections and lessons gone
FetchType.LAZY on @ManyToOneDon'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 / @PreUpdateTimestamps managed by JPA, not by SQL defaults
Enums as STRING not ORDINALAdding 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:

InputOutput
"Spring Boot Masterclass"spring-boot-masterclass
"React & Next.js: The Complete Guide"react--nextjs-the-complete-guidereact-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 endpoints

API 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}" | jq

Response:

{
  "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 | jq

Complete API Reference

MethodEndpointDescriptionAuth
GET/api/coursesList published coursesPublic
GET/api/courses/:slugGet course by slug (with sections/lessons)Public
GET/api/admin/coursesList all courses (including drafts)Admin
GET/api/admin/courses/:idGet course by ID (with sections/lessons)Admin
POST/api/admin/coursesCreate courseAdmin
PUT/api/admin/courses/:idUpdate courseAdmin
DELETE/api/admin/courses/:idDelete course (cascades)Admin
PUT/api/admin/courses/reorderReorder coursesAdmin
GET/api/admin/courses/:courseId/sectionsList sectionsAdmin
POST/api/admin/courses/:courseId/sectionsCreate sectionAdmin
PUT/api/admin/courses/sections/:sectionIdUpdate sectionAdmin
DELETE/api/admin/courses/sections/:sectionIdDelete section (cascades)Admin
PUT/api/admin/courses/:courseId/sections/reorderReorder sectionsAdmin
GET/api/admin/courses/sections/:sectionId/lessonsList lessonsAdmin
POST/api/admin/courses/sections/:sectionId/lessonsCreate lessonAdmin
PUT/api/admin/courses/lessons/:lessonIdUpdate lessonAdmin
DELETE/api/admin/courses/lessons/:lessonIdDelete lessonAdmin
PUT/api/admin/courses/sections/:sectionId/lessons/reorderReorder lessonsAdmin

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 fine

2. 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 + zod validation
  • 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.