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.mdTạ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 domainhealth/— 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:
| Setting | Value |
|---|---|
| Project | Gradle - Groovy |
| Language | Java |
| Spring Boot | 3.4.x (bản stable mới nhất) |
| Group | dev.chanhle |
| Artifact | courses |
| Name | courses |
| Package | dev.chanhle.courses |
| Java | 17 |
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.zipCấ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: alwaysCá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: DEBUGCá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 để debugmaximum-pool-size: 5— connection pool nhỏ cho local development- DevTools restart — Spring Boot tự restart khi file
.classthay đổ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ậtexposedHeaders: Authorization— frontend cần đọc headerAuthorizationtừ 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="@/*" \
--turbopackLệ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ướifrontend/
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/apiPrefix 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 -dKiểm tra chúng đang chạy:
docker compose psNAME STATUS PORTS
courses-postgres Up (healthy) 0.0.0.0:5432->5432/tcp
courses-redis Up (healthy) 0.0.0.0:6379->6379/tcpTest 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
# PONGChạ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 -dBước 2: Chạy Spring Boot API
cd backend
./gradlew bootRunBạn sẽ thấy:
Started CoursesApplication in 2.3 secondsTest 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 devMở 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
| Layer | Technology | Chuyện gì xảy ra khi Save |
|---|---|---|
| Frontend | Next.js + Turbopack | Trang refresh ngay (~100ms) |
| Backend | Spring Boot DevTools | JVM tự restart (~2-3s) |
| Database | PostgreSQL (Docker) | Persistent — sống sót qua restart |
| Cache | Redis (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/apiTạ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 | jqOutput 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 -5Request 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 policyKiể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ó trongbuild.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.