Back to blog

Xây Dựng Video Platform: Project Setup

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Project Setup

Đến lúc viết code rồi. Trong bài này, chúng ta sẽ đi từ một directory trống đến một development stack hoàn chỉnh — Spring Boot API, Next.js frontend, PostgreSQL và Redis, tất cả được kết nối bằng Docker Compose với hot reload trên cả backend và frontend.

Chưa có authentication. Chưa có video upload. Chưa có Stripe. Chỉ là một nền tảng vững chắc với cấu trúc project đúng chuẩn, profile và development workflow. Chúng ta sẽ xếp chồng mọi thứ khác lên trên trong các bài sau.

Thời gian: 1–2 giờ
Điều kiện: Tổng Quan Series & Architecture

Những gì chúng ta sẽ build trong bài này:
✅ Spring Boot API project với cấu trúc package phân lớp
✅ Next.js frontend với App Router, TypeScript và Tailwind CSS
✅ Docker Compose cho PostgreSQL và Redis
✅ Development và production Spring profile
✅ Health check endpoint và CORS configuration
✅ Hot reload workflow cho cả backend và frontend


Cấu Trúc Repository

Đây là layout monorepo chúng ta sẽ build. Backend và frontend nằm cạnh nhau:

video-platform/
├── backend/                          # Spring Boot API
│   ├── src/main/java/dev/chanhle/courses/
│   │   ├── CoursesApplication.java   # Main entry point
│   │   ├── config/
│   │   │   └── CorsConfig.java       # CORS configuration
│   │   ├── health/
│   │   │   └── HealthController.java # Health check endpoint
│   │   └── common/
│   │       ├── dto/
│   │       │   └── ApiResponse.java  # Standard API response wrapper
│   │       └── exception/
│   │           ├── GlobalExceptionHandler.java
│   │           └── ResourceNotFoundException.java
│   ├── src/main/resources/
│   │   ├── application.yml           # Config chung
│   │   ├── application-dev.yml       # Development profile
│   │   └── application-prod.yml      # Production profile
│   ├── build.gradle                  # Gradle build config
│   ├── settings.gradle
│   └── Dockerfile
├── frontend/                         # Next.js app
│   ├── app/
│   │   ├── layout.tsx                # Root layout
│   │   ├── page.tsx                  # Home page
│   │   └── globals.css               # Global styles
│   ├── components/
│   │   └── Header.tsx                # Navigation header
│   ├── lib/
│   │   └── api.ts                    # Axios instance
│   ├── next.config.ts
│   ├── tailwind.config.ts
│   ├── tsconfig.json
│   ├── package.json
│   └── Dockerfile
├── docker-compose.yml                # Development stack
├── docker-compose.prod.yml           # Production stack (sau này)
├── .env.example
├── .gitignore
└── README.md

Tại sao cấu trúc này:

  • Monorepo — backend và frontend trong một repository. Dễ quản lý, versioning và deploy cùng nhau
  • config/ — các class cấu hình tập trung (CORS, security, Redis — sẽ thêm sau)
  • common/ — DTO và exception handling chung, dùng xuyên suốt các domain
  • health/ — health check endpoint. Các domain sau (auth/, course/, video/, subscription/) theo cùng pattern

Setup Spring Boot Backend

Tạo Project

Truy cập start.spring.io và tạo project với các setting sau:

SettingValue
ProjectGradle - Groovy
LanguageJava
Spring Boot3.4.x (bản stable mới nhất)
Groupdev.chanhle
Artifactcourses
Namecourses
Packagedev.chanhle.courses
Java17

Dependency cần thêm:

  • Spring Web
  • Spring Data JPA
  • PostgreSQL Driver
  • Spring Data Redis
  • Validation
  • Lombok
  • Spring Boot DevTools

Hoặc dùng command line:

mkdir video-platform && cd video-platform
 
curl https://start.spring.io/starter.zip \
  -d type=gradle-project \
  -d language=java \
  -d bootVersion=3.4.4 \
  -d groupId=dev.chanhle \
  -d artifactId=courses \
  -d name=courses \
  -d packageName=dev.chanhle.courses \
  -d javaVersion=17 \
  -d dependencies=web,data-jpa,postgresql,data-redis,validation,lombok,devtools \
  -o backend.zip
 
unzip backend.zip -d backend
rm backend.zip

Cấu Hình Gradle Build

Mở backend/build.gradle và kiểm tra dependency:

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.4'
    id 'io.spring.dependency-management' version '1.1.7'
}
 
group = 'dev.chanhle'
version = '0.0.1-SNAPSHOT'
 
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}
 
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
 
repositories {
    mavenCentral()
}
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
 
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
 
    runtimeOnly 'org.postgresql:postgresql'
 
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
 
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
 
tasks.named('test') {
    useJUnitPlatform()
}

Tại sao những dependency này:

  • spring-boot-starter-web — REST controller, Jackson JSON, embedded Tomcat
  • spring-boot-starter-data-jpa — Hibernate ORM, repository pattern, transaction management
  • spring-boot-starter-data-redis — Redis client cho caching và session storage
  • spring-boot-starter-validation — Bean validation (@Valid, @NotBlank, @Email)
  • lombok — giảm boilerplate (@Data, @Builder, @Slf4j)
  • devtools — tự động restart khi code thay đổi (hot reload)

Cấu Hình Application

Spring Boot dùng YAML profile để tách cấu hình theo environment. Chúng ta sẽ tạo ba file: chung, development và production.

Cấu Hình Chung

backend/src/main/resources/application.yml:

spring:
  application:
    name: video-courses-api
 
  profiles:
    active: dev
 
  jackson:
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
 
server:
  port: 8080
  error:
    include-message: always
    include-binding-errors: always

Các setting quan trọng:

  • default-property-inclusion: non_null — bỏ field null khỏi JSON response (API sạch hơn)
  • write-dates-as-timestamps: false — serialize date dạng ISO 8601 (2026-03-22T10:30:00Z)
  • include-message: always — hiển thị error message trong response (hữu ích khi debug)

Development Profile

backend/src/main/resources/application-dev.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/courses_dev
    username: courses
    password: courses_dev_password
    hikari:
      maximum-pool-size: 5
 
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        format_sql: true
 
  data:
    redis:
      host: localhost
      port: 6379
 
  devtools:
    restart:
      enabled: true
 
logging:
  level:
    dev.chanhle.courses: DEBUG
    org.hibernate.SQL: DEBUG

Các setting development quan trọng:

  • ddl-auto: validate — validate schema với entity nhưng không sửa đổi (Flyway lo migration, sẽ thêm ở Bài #4)
  • show-sql: true — in SQL query ra console để debug
  • maximum-pool-size: 5 — connection pool nhỏ cho local development
  • DevTools restart — Spring Boot tự restart khi file .class thay đổi

Production Profile

backend/src/main/resources/application-prod.yml:

spring:
  datasource:
    url: ${DATABASE_URL}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
 
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
 
  data:
    redis:
      host: ${REDIS_HOST:redis}
      port: ${REDIS_PORT:6379}
 
  devtools:
    restart:
      enabled: false
 
logging:
  level:
    dev.chanhle.courses: INFO
    org.hibernate.SQL: WARN
 
server:
  error:
    include-message: never
    include-stacktrace: never

Điểm khác biệt production:

  • Environment variable — giá trị nhạy cảm lấy từ ${DATABASE_URL}, không hardcode
  • Connection pool lớn hơn — 20 connection max cho production load
  • Không log SQL — tránh log noise
  • Ẩn error message — không lộ chi tiết nội bộ cho client

Standard API Response Wrapper

Mọi API endpoint nên trả về format response nhất quán. Tạo một generic wrapper:

backend/src/main/java/dev/chanhle/courses/common/dto/ApiResponse.java:

package dev.chanhle.courses.common.dto;
 
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
 
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
    private Object errors;
 
    public static <T> ApiResponse<T> success(T data) {
        return ApiResponse.<T>builder()
                .success(true)
                .data(data)
                .build();
    }
 
    public static <T> ApiResponse<T> success(String message, T data) {
        return ApiResponse.<T>builder()
                .success(true)
                .message(message)
                .data(data)
                .build();
    }
 
    public static <T> ApiResponse<T> error(String message) {
        return ApiResponse.<T>builder()
                .success(false)
                .message(message)
                .build();
    }
 
    public static <T> ApiResponse<T> error(String message, Object errors) {
        return ApiResponse.<T>builder()
                .success(false)
                .message(message)
                .errors(errors)
                .build();
    }
}

Cách dùng trong controller:

// Response thành công
return ResponseEntity.ok(ApiResponse.success(courseDto));
 
// Thành công có message
return ResponseEntity.status(HttpStatus.CREATED)
    .body(ApiResponse.success("Course created", courseDto));
 
// Response lỗi
return ResponseEntity.status(HttpStatus.NOT_FOUND)
    .body(ApiResponse.error("Course not found"));

Ví dụ JSON output:

{
  "success": true,
  "message": "Course created",
  "data": {
    "id": 1,
    "title": "Spring Boot Fundamentals",
    "slug": "spring-boot-fundamentals"
  }
}

Global Exception Handler

Bắt tất cả exception ở một chỗ và trả về error response nhất quán:

backend/src/main/java/dev/chanhle/courses/common/exception/ResourceNotFoundException.java:

package dev.chanhle.courses.common.exception;
 
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resource, String field, Object value) {
        super(String.format("%s not found with %s: %s", resource, field, value));
    }
}

backend/src/main/java/dev/chanhle/courses/common/exception/GlobalExceptionHandler.java:

package dev.chanhle.courses.common.exception;
 
import dev.chanhle.courses.common.dto.ApiResponse;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
 
import java.util.HashMap;
import java.util.Map;
 
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.error(ex.getMessage()));
    }
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error("Validation failed", errors));
    }
 
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getConstraintViolations().forEach(violation ->
                errors.put(violation.getPropertyPath().toString(), violation.getMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error("Validation failed", errors));
    }
 
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error(ex.getMessage()));
    }
 
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
        log.error("Unexpected error", ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.error("An unexpected error occurred"));
    }
}

Điều này cho chúng ta error response nhất quán trên toàn bộ API:

{
  "success": false,
  "message": "Validation failed",
  "errors": {
    "email": "must not be blank",
    "title": "size must be between 3 and 255"
  }
}

Cấu Hình CORS

Frontend (Next.js trên port 3000) cần gọi API đến Spring Boot (port 8080). Không có CORS, browser sẽ block các cross-origin request.

backend/src/main/java/dev/chanhle/courses/config/CorsConfig.java:

package dev.chanhle.courses.config;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
 
import java.util.List;
 
@Configuration
public class CorsConfig {
 
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.setAllowedOrigins(List.of(
                "http://localhost:3000",
                "http://localhost:3001"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setExposedHeaders(List.of("Authorization"));
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
 
        return new CorsFilter(source);
    }
}

Các setting quan trọng:

  • allowCredentials: true — cần cho HTTP-only cookie (refresh token ở Bài #3)
  • allowedOrigins — chỉ frontend của chúng ta mới được gọi request. Ở production, đổi sang domain thật
  • exposedHeaders: Authorization — frontend cần đọc header Authorization từ response
  • /api/** — CORS chỉ áp dụng cho API route, không áp dụng cho static file serving

Health Check Endpoint

Một endpoint đơn giản để xác nhận API đang chạy. Hữu ích cho Docker health check, load balancer và monitoring.

backend/src/main/java/dev/chanhle/courses/health/HealthController.java:

package dev.chanhle.courses.health;
 
import dev.chanhle.courses.common.dto.ApiResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.time.Instant;
import java.util.Map;
 
@RestController
@RequestMapping("/api")
public class HealthController {
 
    @GetMapping("/health")
    public ResponseEntity<ApiResponse<Map<String, Object>>> health() {
        Map<String, Object> healthData = Map.of(
                "status", "UP",
                "timestamp", Instant.now().toString(),
                "service", "video-courses-api"
        );
        return ResponseEntity.ok(ApiResponse.success(healthData));
    }
}

Test thử:

curl http://localhost:8080/api/health
{
  "success": true,
  "data": {
    "status": "UP",
    "timestamp": "2026-03-22T10:30:00Z",
    "service": "video-courses-api"
  }
}

Setup Next.js Frontend

Tạo Project

cd video-platform
 
npx create-next-app@latest frontend \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir=false \
  --import-alias="@/*" \
  --turbopack

Lệnh này tạo Next.js 15 project với:

  • App Router — hệ thống routing hiện đại
  • TypeScript — type safety
  • Tailwind CSS — utility-first styling
  • Turbopack — dev build nhanh
  • Không có src/ directory — file nằm trực tiếp dưới frontend/

Cài Dependency Bổ Sung

cd frontend
npm install axios
npm install -D @types/node
  • axios — HTTP client với interceptor support (quan trọng cho token refresh sau này)

API Client

Tạo Axios instance tái sử dụng cho mọi API call:

frontend/lib/api.ts:

import axios from 'axios';
 
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
 
const api = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    'Content-Type': 'application/json',
  },
  withCredentials: true,
});
 
// Response interceptor — trích xuất data hoặc xử lý error
api.interceptors.response.use(
  (response) => response.data,
  (error) => {
    const message = error.response?.data?.message || 'An unexpected error occurred';
    return Promise.reject(new Error(message));
  }
);
 
export default api;

Các setting quan trọng:

  • withCredentials: true — gửi cookie với mọi request (cần cho refresh token)
  • baseURL — mọi API call tương đối với URL này. Ở production, trỏ đến API domain thật
  • Response interceptor — tự động unwrap response và trích xuất error message

Chúng ta sẽ thêm request interceptor ở Bài #3 để đính kèm JWT access token và xử lý automatic token refresh.

Environment Variable

frontend/.env.local:

NEXT_PUBLIC_API_URL=http://localhost:8080/api

Prefix NEXT_PUBLIC_ làm cho variable có sẵn trong client-side code.

Root Layout

frontend/app/layout.tsx:

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
 
const inter = Inter({ subsets: ['latin'] });
 
export const metadata: Metadata = {
  title: 'Video Courses',
  description: 'Learn programming with video courses',
};
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${inter.className} antialiased bg-gray-50 min-h-screen`}>
        <Header />
        <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
          {children}
        </main>
      </body>
    </html>
  );
}
 
function Header() {
  return (
    <header className="bg-white border-b border-gray-200">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex justify-between items-center h-16">
          <a href="/" className="text-xl font-bold text-gray-900">
            Video Courses
          </a>
          <nav className="flex items-center space-x-6">
            <a href="/courses" className="text-gray-600 hover:text-gray-900">
              Courses
            </a>
            <a
              href="/login"
              className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
            >
              Sign In
            </a>
          </nav>
        </div>
      </div>
    </header>
  );
}

Home Page

frontend/app/page.tsx:

'use client';
 
import { useEffect, useState } from 'react';
import api from '@/lib/api';
 
interface HealthData {
  success: boolean;
  data: {
    status: string;
    timestamp: string;
    service: string;
  };
}
 
export default function Home() {
  const [health, setHealth] = useState<HealthData | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    api
      .get('/health')
      .then((data) => setHealth(data as unknown as HealthData))
      .catch((err) => setError(err.message));
  }, []);
 
  return (
    <div className="text-center py-20">
      <h1 className="text-4xl font-bold text-gray-900 mb-4">
        Video Course Platform
      </h1>
      <p className="text-lg text-gray-600 mb-8">
        Learn programming with high-quality video courses
      </p>
 
      <div className="max-w-md mx-auto bg-white rounded-lg shadow p-6">
        <h2 className="text-lg font-semibold mb-4">API Connection Status</h2>
        {error && (
          <div className="text-red-600 bg-red-50 p-3 rounded">
            API Error: {error}
          </div>
        )}
        {health && (
          <div className="text-green-600 bg-green-50 p-3 rounded">
            <p>Status: {health.data.status}</p>
            <p className="text-sm text-gray-500 mt-1">
              Service: {health.data.service}
            </p>
          </div>
        )}
        {!health && !error && (
          <div className="text-gray-400">Connecting to API...</div>
        )}
      </div>
    </div>
  );
}

Trang đơn giản này kết nối đến Spring Boot health endpoint — một sanity check nhanh xác nhận frontend đang nói chuyện với backend.


Docker Compose

Đây là development Docker Compose kết nối mọi thứ lại với nhau:

docker-compose.yml:

services:
  postgres:
    image: postgres:17-alpine
    container_name: courses-postgres
    environment:
      POSTGRES_DB: courses_dev
      POSTGRES_USER: courses
      POSTGRES_PASSWORD: courses_dev_password
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U courses -d courses_dev"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    container_name: courses-redis
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
 
volumes:
  pgdata:
  redisdata:

Quan trọng: Chúng ta chỉ containerize PostgreSQL và Redis — không phải Spring Boot API hay Next.js frontend. Khi develop, chạy backend và frontend native cho chúng ta:

  • Hot reload nhanh hơn — không cần Docker build step khi code thay đổi
  • Debug dễ hơn — attach IDE debugger trực tiếp
  • Log đơn giản hơn — mỗi process trong terminal riêng

Ở Bài #15 (Deployment), chúng ta sẽ containerize mọi thứ cho production.

Khởi Động Service

docker compose up -d

Kiểm tra chúng đang chạy:

docker compose ps
NAME               STATUS          PORTS
courses-postgres   Up (healthy)    0.0.0.0:5432->5432/tcp
courses-redis      Up (healthy)    0.0.0.0:6379->6379/tcp

Test kết nối database:

docker exec -it courses-postgres psql -U courses -d courses_dev -c "SELECT 1;"

Test kết nối Redis:

docker exec -it courses-redis redis-cli ping
# PONG

Chạy Development Stack

Bây giờ chúng ta có tất cả các mảnh ghép. Đây là workflow development hoàn chỉnh:

Bước 1: Khởi Động Infrastructure

# Từ thư mục gốc project
docker compose up -d

Bước 2: Chạy Spring Boot API

cd backend
./gradlew bootRun

Bạn sẽ thấy:

Started CoursesApplication in 2.3 seconds

Test health endpoint:

curl http://localhost:8080/api/health | jq
{
  "success": true,
  "data": {
    "status": "UP",
    "timestamp": "2026-03-22T10:30:00Z",
    "service": "video-courses-api"
  }
}

Bước 3: Chạy Next.js Frontend

cd frontend
npm run dev

Mở http://localhost:3000 trong browser. Bạn sẽ thấy home page hiển thị "Status: UP" — xác nhận frontend đang kết nối được với backend.

Development Architecture

Hot Reload Behavior

LayerTechnologyChuyện gì xảy ra khi Save
FrontendNext.js + TurbopackTrang refresh ngay (~100ms)
BackendSpring Boot DevToolsJVM tự restart (~2-3s)
DatabasePostgreSQL (Docker)Persistent — sống sót qua restart
CacheRedis (Docker)Persistent — data giữ trong Docker volume

Spring Boot DevTools theo dõi thay đổi file .class. Khi bạn save file Java, IDE compile nó, DevTools phát hiện class mới và restart application context. Việc restart dùng trick "base classloader" bỏ qua reload thư viện third-party, giữ thời gian restart dưới 3 giây.


Environment Variable

Tạo .env.example tại thư mục gốc project (commit vào git):

# PostgreSQL
POSTGRES_DB=courses_dev
POSTGRES_USER=courses
POSTGRES_PASSWORD=courses_dev_password
DATABASE_URL=jdbc:postgresql://localhost:5432/courses_dev
 
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
 
# Application
SPRING_PROFILES_ACTIVE=dev
NEXT_PUBLIC_API_URL=http://localhost:8080/api

Tạo .gitignore tại thư mục gốc project:

# Dependencies
backend/build/
frontend/node_modules/
frontend/.next/
 
# Environment
.env
.env.local
backend/src/main/resources/application-local.yml
 
# IDE
.idea/
.vscode/
*.iml
 
# OS
.DS_Store
Thumbs.db
 
# Docker
pgdata/
redisdata/

Test Toàn Bộ Stack

Hãy xác nhận mọi thứ hoạt động end-to-end:

1. Health Check qua curl

curl -s http://localhost:8080/api/health | jq

Output mong đợi:

{
  "success": true,
  "data": {
    "status": "UP",
    "timestamp": "2026-03-22T10:30:00Z",
    "service": "video-courses-api"
  }
}

2. Frontend Kết Nối Backend

Mở http://localhost:3000 — home page phải hiển thị "Status: UP" trong hộp xanh.

3. Kết Nối Database

docker exec -it courses-postgres psql -U courses -d courses_dev -c "\dt"

Sẽ trả về "Did not find any relations" — chưa có table nào, đúng rồi. Chúng ta sẽ tạo table bằng Flyway migration ở Bài #4.

4. Kết Nối Redis

docker exec -it courses-redis redis-cli ping
# PONG
 
docker exec -it courses-redis redis-cli info server | head -5

Request Flow Hoàn Chỉnh


Lỗi Thường Gặp

1. Xung Đột Port PostgreSQL

Nếu port 5432 đã được sử dụng (PostgreSQL local đang chạy):

# Kiểm tra gì đang dùng port 5432
lsof -i :5432
 
# Cách 1: Dừng PostgreSQL local
brew services stop postgresql
 
# Cách 2: Dùng port khác trong docker-compose.yml
ports:
  - "5433:5432"  # Map sang 5433 trên host

Đừng quên update application-dev.yml cho khớp port mới.

2. Spring Boot Không Kết Nối Được PostgreSQL

Nếu bạn thấy lỗi Connection refused:

# Kiểm tra container PostgreSQL đang chạy không
docker compose ps
 
# Kiểm tra log container
docker compose logs postgres
 
# Xác nhận container healthy
docker inspect courses-postgres --format='{{.State.Health.Status}}'

Đảm bảo Docker Compose khởi động trước Spring Boot.

3. Lỗi CORS Trong Browser Console

Nếu frontend không gọi được API:

Access to XMLHttpRequest at 'http://localhost:8080/api/health' from origin
'http://localhost:3000' has been blocked by CORS policy

Kiểm tra CorsConfig.java có http://localhost:3000 trong allowedOrigins. Cũng xác nhận withCredentials: true khớp với allowCredentials: true.

4. Lombok Không Hoạt Động

Nếu bạn thấy lỗi "cannot find symbol" cho @Data hay @Builder:

  • IntelliJ: Cài Lombok plugin (Settings → Plugins → search "Lombok")
  • VS Code: Java extension pack thường xử lý được
  • Đảm bảo annotationProcessor 'org.projectlombok:lombok' có trong build.gradle

Những Gì Chúng Ta Đã Build

Tóm tắt lại những gì chúng ta có sau bài này:


Tiếp Theo Là Gì?

Platform của chúng ta chạy rồi, nhưng chưa có authentication — ai cũng có thể truy cập mọi endpoint. Trong Bài #3, chúng ta sẽ thêm hệ thống auth hoàn chỉnh:

  • Cấu hình Spring Security cho JWT
  • Pattern access token + refresh token
  • Đăng ký và đăng nhập bằng email/password
  • Google và GitHub OAuth2 social login
  • Next.js auth context và protected route
  • Token refresh interceptor với Axios

Nền tảng đã vững. Đến lúc thêm security.

Series: Xây Dựng Video Streaming Platform
Trước đó: Tổng Quan Series & Architecture
Tiếp theo: Phase 2: Authentication

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.