Mini-challenge: TaskFlow v2 — Postgres + JPA capstone
Migrate TaskFlow từ phần REST API (in-memory ConcurrentHashMap) sang PostgreSQL với Spring Data JPA. Setup Flyway, JPA entities (Project + Task + User + Comment), relationships, fix N+1, projection DTO, transactions, integration test với Testcontainers.
TL;DR: Bài cuối course không thêm khái niệm mới — bạn migrate TaskFlow v1 từ in-memory sang PostgreSQL với Spring Data JPA. Domain mở rộng lên 4 entity (thêm User, Comment), schema quản lý bằng Flyway, integration test chạy trên Postgres thật qua Testcontainers. REST API contract giữ nguyên từ v1 — chỉ tầng persistence thay đổi, chứng minh sức mạnh Repository abstraction. Sau bài này TaskFlow có stack production-grade: Postgres + Hibernate + Flyway + Testcontainers, foundation cho course Spring Security.
📋 Tiếp nối từ TaskFlow v1
Bài này tiếp nối trực tiếp TaskFlow v1: bạn dùng lại toàn bộ tầng web — controller, DTO record, validation, @RestControllerAdvice + Problem Details. Hai cách làm đều đúng: tiếp tục repo cũ (thay in-memory repository bằng JPA, refactor tại chỗ) hoặc tạo project mới rồi copy controller/DTO/advice sang. Phần bạn viết mới trong bài này là tầng dưới: JPA entity + relationships, Flyway migration, và Testcontainers integration test. Nếu chưa làm v1, quay lại làm trước — đề bài này giả định tầng web đã tồn tại.
🎯 Đề bài
Migrate TaskFlow (từ phần REST API) sang Postgres với:
Domain mở rộng (4 entity)
User (mới)
├── id: Long
├── email: String (unique)
├── name: String
├── createdAt: Instant
Project (rebuild từ phần REST API)
├── id: Long
├── name: String (unique)
├── description: String?
├── status: ProjectStatus
├── ownerId → User
├── createdAt, updatedAt: Instant
└── tasks: List<Task> (@OneToMany)
Task (rebuild từ phần REST API)
├── id: Long
├── projectId → Project (@ManyToOne)
├── assigneeId → User? (@ManyToOne, nullable)
├── title: String
├── description: String?
├── status: TaskStatus
├── priority: TaskPriority
├── dueDate: LocalDate?
├── createdAt, updatedAt: Instant
└── comments: List<Comment> (@OneToMany)
Comment (mới)
├── id: Long
├── taskId → Task (@ManyToOne)
├── authorId → User (@ManyToOne)
├── body: String (max 2000 char)
├── createdAt: Instant
Yêu cầu kỹ thuật
- Spring Boot 3.4 + Postgres 16 + Java 21.
- Flyway migration: V1 init schema, V2 indexes, V3 seed dev data (profile dev only).
- Entities với JPA: class với getter/setter (no record), no-arg ctor, equals/hashCode null-safe constant.
- Repository: extend
JpaRepository. Mix derived query +@QueryJPQL + DTO projection. - Relationships đúng: LAZY default, helper methods bidirectional, cascade ALL + orphanRemoval cho parent-child.
- Service
@Transactional: class levelreadOnly = true, override write methods. - No N+1: detect via SQL log + fix via JOIN FETCH/EntityGraph/DTO projection.
- Pagination:
Page<ProjectDto>cho list endpoints. - Audit fields:
@CreatedDate,@LastModifiedDatequaAuditingEntityListener. - Integration test: Testcontainers Postgres +
@SpringBootTestcho Repository + Service. - REST API contract giữ nguyên từ phần REST API: 8+ endpoints, validation, Problem Details RFC 9457.
✅ Checklist hoàn thành
[Bắt buộc] là tối thiểu để qua bài; [Bonus] tương ứng các Extension level cuối bài.
- [Bắt buộc] 3 file Flyway migration chạy sạch: V1 schema, V2 indexes, V3 seed (dev profile) —
ddl-auto: validatekhông kêu. - [Bắt buộc] 4 entity đúng chuẩn: no-arg ctor, equals/hashCode null-safe constant, không Lombok
@Data. - [Bắt buộc] Relationships LAZY default + helper methods bidirectional; cascade ALL + orphanRemoval chỉ cho parent-child.
- [Bắt buộc] Service
@Transactional(readOnly = true)class-level, write methods override. - [Bắt buộc] Bật SQL log xác nhận không còn N+1 trên list endpoint (đúng 2 SQL: data + count).
- [Bắt buộc] Integration test Testcontainers xanh cho Repository + Service trên Postgres thật.
- [Bắt buộc] REST contract v1 không gãy: 8+ endpoint trả đúng status + Problem Details như trước migration.
- [Bonus] Audit fields tự ghi qua
AuditingEntityListener; Extension level 2+ (optimistic lock, soft delete...).
🔍 Kiến trúc mới
flowchart LR
Client[HTTP Client]
Filter[LoggingFilter MDC]
Ctrl["Controllers (REST part unchanged)"]
Svc["Services @Transactional"]
Repo[Spring Data JPA Repositories]
Hib[Hibernate ORM]
JDBC[JDBC Driver]
Hikari[Hikari Pool]
PG[(PostgreSQL 16)]
FW[Flyway]
Client --> Filter --> Ctrl --> Svc --> Repo --> Hib --> JDBC --> Hikari --> PG
FW -.startup.-> PG
style FW fill:#fef3c7
style Repo fill:#d1fae5Service layer không đổi — Repository interface giữ nguyên, swap in-memory impl bằng JPA. Liskov substitution principle.
📦 Concept dùng trong bài
| Concept | Module |
|---|---|
@Entity, @Id, @GeneratedValue | M04 bài 02 |
@OneToMany, @ManyToOne, LAZY fetch | M04 bài 04 |
| Repository abstraction, DTO projection | M04 bài 03 |
@Transactional, propagation | M04 bài 05 |
| Flyway migration, V/R scripts | M04 bài 06 |
| Pagination, Sort | M04 bài 07 |
Auditing @CreatedDate | M04 bài 03 |
@RestControllerAdvice Problem Details | M03 bài 05 |
▶️ Cấu trúc project mới
taskflow-api-v2/
├── pom.xml
├── docker-compose.yml # Postgres local
└── src/
├── main/
│ ├── java/com/olhub/taskflow/
│ │ ├── App.java
│ │ ├── config/
│ │ │ ├── JpaConfig.java # @EnableJpaAuditing
│ │ │ ├── SecurityAuditor.java # @CreatedBy resolver
│ │ │ └── LoggingFilter.java
│ │ ├── domain/ # JPA entities
│ │ │ ├── AuditableEntity.java # @MappedSuperclass
│ │ │ ├── User.java
│ │ │ ├── Project.java
│ │ │ ├── ProjectStatus.java
│ │ │ ├── Task.java
│ │ │ ├── TaskStatus.java
│ │ │ ├── TaskPriority.java
│ │ │ └── Comment.java
│ │ ├── api/ # REST layer (giữ từ M03)
│ │ │ ├── ProjectController.java
│ │ │ ├── TaskController.java
│ │ │ ├── UserController.java
│ │ │ ├── CommentController.java
│ │ │ ├── GlobalExceptionHandler.java
│ │ │ └── dto/
│ │ │ ├── CreateProjectRequest.java
│ │ │ ├── ProjectDto.java
│ │ │ ├── ProjectSummary.java
│ │ │ ├── TaskDto.java
│ │ │ ├── ...
│ │ ├── service/
│ │ │ ├── ProjectService.java # @Transactional
│ │ │ ├── TaskService.java
│ │ │ ├── UserService.java
│ │ │ └── CommentService.java
│ │ ├── repository/ # JPA repositories
│ │ │ ├── ProjectRepository.java
│ │ │ ├── TaskRepository.java
│ │ │ ├── UserRepository.java
│ │ │ └── CommentRepository.java
│ │ └── exception/
│ │ ├── ProjectNotFoundException.java
│ │ ├── TaskNotFoundException.java
│ │ └── DuplicateException.java
│ └── resources/
│ ├── application.yml
│ ├── application-dev.yml
│ ├── application-prod.yml
│ └── db/migration/
│ ├── V1__init_schema.sql
│ ├── V2__add_indexes.sql
│ └── dev/V100__seed_data.sql
└── test/
└── java/com/olhub/taskflow/
├── repository/
│ ├── ProjectRepositoryTest.java
│ └── TaskRepositoryTest.java
├── service/
│ └── ProjectServiceTest.java
└── api/
└── ProjectControllerIT.java
Dành 40-60 phút build. Hint chi tiết dưới.
💡 Hint — code chính
pom.xml — dependencies mới:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: taskflow
POSTGRES_USER: app
POSTGRES_PASSWORD: app
ports:
- "5432:5432"
volumes:
- taskflow-data:/var/lib/postgresql/data
volumes:
taskflow-data:
docker compose up -d
application.yml:
spring:
application:
name: taskflow-api
datasource:
url: jdbc:postgresql://localhost:5432/taskflow
username: app
password: app
hikari:
maximum-pool-size: 10
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
jdbc.batch_size: 50
order_inserts: true
order_updates: true
default_batch_fetch_size: 20
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
mvc:
problemdetails:
enabled: true
logging:
level:
com.olhub.taskflow: DEBUG
org.hibernate.SQL: DEBUG # log SQL
org.hibernate.orm.jdbc.bind: TRACE # log binding param
org.hibernate.stat: DEBUG # statistics
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{requestId:-no-req}] %logger{30} : %msg%n"
springdoc:
swagger-ui:
path: /swagger-ui.html
V1__init_schema.sql:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50)
);
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(500),
status VARCHAR(20) NOT NULL,
owner_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
CONSTRAINT fk_project_owner FOREIGN KEY (owner_id) REFERENCES users(id),
CONSTRAINT chk_project_status CHECK (status IN ('PLANNING', 'ACTIVE', 'DONE', 'ARCHIVED'))
);
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
assignee_id BIGINT,
title VARCHAR(200) NOT NULL,
description VARCHAR(1000),
status VARCHAR(20) NOT NULL,
priority VARCHAR(20) NOT NULL,
due_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
CONSTRAINT fk_task_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
CONSTRAINT fk_task_assignee FOREIGN KEY (assignee_id) REFERENCES users(id),
CONSTRAINT chk_task_status CHECK (status IN ('TODO', 'IN_PROGRESS', 'DONE')),
CONSTRAINT chk_task_priority CHECK (priority IN ('LOW', 'MEDIUM', 'HIGH', 'URGENT'))
);
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
CONSTRAINT fk_comment_task FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
CONSTRAINT fk_comment_author FOREIGN KEY (author_id) REFERENCES users(id)
);
V2__add_indexes.sql:
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_owner ON projects(owner_id);
CREATE INDEX idx_projects_created ON projects(created_at DESC);
CREATE INDEX idx_tasks_project ON tasks(project_id);
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
CREATE INDEX idx_tasks_status_priority ON tasks(status, priority);
CREATE INDEX idx_tasks_due_date ON tasks(due_date) WHERE due_date IS NOT NULL;
CREATE INDEX idx_comments_task ON comments(task_id);
CREATE INDEX idx_comments_created ON comments(created_at DESC);
AuditableEntity.java:
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class AuditableEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
protected Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
protected Instant updatedAt;
@CreatedBy
@Column(name = "created_by", updatable = false, length = 50)
protected String createdBy;
@LastModifiedBy
@Column(name = "updated_by", length = 50)
protected String updatedBy;
}
Project.java (entity, không record):
@Entity
@Table(name = "projects")
@Getter
@Setter
public class Project extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String name;
@Column(length = 500)
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ProjectStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Task> tasks = new ArrayList<>();
protected Project() {}
public Project(String name, String description, ProjectStatus status, User owner) {
this.name = name;
this.description = description;
this.status = status;
this.owner = owner;
}
// Helper methods bidirectional
public void addTask(Task task) {
tasks.add(task);
task.setProject(this);
}
public void removeTask(Task task) {
tasks.remove(task);
task.setProject(null);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Project p)) return false;
return id != null && id.equals(p.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
ProjectRepository.java:
public interface ProjectRepository extends JpaRepository<Project, Long>, JpaSpecificationExecutor<Project> {
Optional<Project> findByName(String name);
boolean existsByName(String name);
@Query("""
SELECT new com.olhub.taskflow.api.dto.ProjectSummary(
p.id, p.name, p.status, p.owner.name, COUNT(t.id)
)
FROM Project p
LEFT JOIN p.tasks t
WHERE (:status IS NULL OR p.status = :status)
GROUP BY p.id, p.name, p.status, p.owner.name
""",
countQuery = """
SELECT COUNT(p) FROM Project p
WHERE (:status IS NULL OR p.status = :status)
""")
Page<ProjectSummary> findSummaries(@Param("status") ProjectStatus status, Pageable pageable);
// Overrides findById to eager-load owner + tasks for the detail view (avoids N+1 / LazyInitException).
// Trade-off: this graph applies to EVERY findById call. For existence-only paths,
// prefer existsById to skip loading the graph.
@EntityGraph(attributePaths = {"owner", "tasks"})
Optional<Project> findById(Long id);
}
ProjectService.java:
@Service
@Transactional(readOnly = true)
@Slf4j
public class ProjectService {
private final ProjectRepository projectRepo;
private final UserRepository userRepo;
public ProjectService(ProjectRepository projectRepo, UserRepository userRepo) {
this.projectRepo = projectRepo;
this.userRepo = userRepo;
}
@Transactional
public Project create(CreateProjectRequest req) {
if (projectRepo.existsByName(req.name())) {
throw new DuplicateException("Project name '" + req.name() + "' already exists");
}
User owner = userRepo.findById(req.ownerId())
.orElseThrow(() -> new UserNotFoundException(req.ownerId()));
Project project = new Project(
req.name(),
req.description(),
req.status() != null ? req.status() : ProjectStatus.PLANNING,
owner
);
Project saved = projectRepo.save(project);
log.info("Created project {} for user {}", saved.getId(), owner.getId());
return saved;
}
public Project findById(Long id) {
return projectRepo.findById(id)
.orElseThrow(() -> new ProjectNotFoundException(id));
}
public Page<ProjectSummary> list(ProjectStatus status, Pageable pageable) {
return projectRepo.findSummaries(status, pageable);
}
@Transactional
public void delete(Long id) {
Project project = findById(id);
// Cascade ALL + orphanRemoval auto delete tasks + comments
projectRepo.delete(project);
log.info("Deleted project {}", id);
}
}
SecurityAuditor.java:
@Component
public class SecurityAuditor implements AuditorAware<String> {
public Optional<String> getCurrentAuditor() {
// Spring Security course wires this via SecurityContext
return Optional.of("system");
}
}
@Configuration
@EnableJpaAuditing(auditorAwareRef = "securityAuditor")
public class JpaConfig { }
Integration Test với Testcontainers:
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
@ActiveProfiles("test")
class ProjectControllerIT {
@Container
@ServiceConnection // Boot 3.1+ auto-config DataSource
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired MockMvc mockMvc;
@Autowired ProjectRepository repo;
@Autowired UserRepository userRepo;
User testUser;
@BeforeEach
void setUp() {
repo.deleteAll();
userRepo.deleteAll();
testUser = userRepo.save(new User("[email protected]", "Alice"));
}
@Test
void create_validRequest_returns201() throws Exception {
mockMvc.perform(post("/api/v1/projects")
.contentType(MediaType.APPLICATION_JSON)
.content(json.writeValueAsString(new CreateProjectRequest("Mobile App", null, null, testUser.getId()))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Mobile App"));
}
@Test
void list_withStatusFilter_returnsPaged() throws Exception {
Project p1 = repo.save(new Project("Project A", null, ProjectStatus.ACTIVE, testUser));
Project p2 = repo.save(new Project("Project B", null, ProjectStatus.PLANNING, testUser));
mockMvc.perform(get("/api/v1/projects?status=ACTIVE&page=0&size=10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(1))
.andExpect(jsonPath("$.content[0].name").value("Project A"));
}
}
✅ Test workflow
Run Postgres + app:
docker compose up -d # Postgres started
mvn spring-boot:run # Flyway apply migrations + Hibernate validate
Verify migrations applied:
docker exec -it $(docker ps -qf name=postgres) psql -U app -d taskflow
\d # list tables
SELECT * FROM flyway_schema_history; # 2-3 migrations applied
Test endpoints:
# Create user
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Alice"}'
# 201, returns User with id=1
# Create project
curl -X POST http://localhost:8080/api/v1/projects \
-H "Content-Type: application/json" \
-d '{"name":"Mobile App","ownerId":1}'
# 201
# List with summary projection
curl http://localhost:8080/api/v1/projects?status=PLANNING&page=0&size=10
# 200, returns Page<ProjectSummary>
# Verify SQL log:
# 2 SQL: data with COUNT(tasks) + count query for pagination
# No N+1
Run integration tests:
mvn verify
# Testcontainers spin up Postgres ephemeral
# Run all integration tests
# Spin down container
Detect N+1 manually:
Bật log org.hibernate.stat=DEBUG. Send GET /api/v1/projects/1 (with EntityGraph). Verify:
Statistics:
3 nanoseconds spent executing 3 JDBC statements
(1 main query + 1 fetch tasks + 1 fetch owner — KHONG N+1)
🎓 Extension levels
Level 1 — Specification dynamic search:
Replace findSummaries với Specification cho 5+ filter optional (status, owner, dateRange, priority, search text).
Level 2 — Soft delete với @SQLDelete:
@Entity
@SQLDelete(sql = "UPDATE projects SET deleted = true WHERE id = ?")
@SQLRestriction("deleted = false")
public class Project { ... }
Mọi DELETE → UPDATE flag. Mọi SELECT auto filter deleted = false.
Level 3 — Optimistic locking với @Version:
@Entity
public class Project {
@Version
private Long version;
}
Concurrent update detect via version. Throw OptimisticLockException nếu mismatch.
Level 4 — Custom AuditorAware với Spring Security (preview course Spring Security):
@Component
public class SecurityAuditor implements AuditorAware<String> {
public Optional<String> getCurrentAuditor() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
Level 5 — Hibernate Statistics endpoint:
Expose /actuator/metrics/hibernate.statements để monitor query count production.
✨ Điều bạn vừa làm được
Hoàn thành mini-challenge này, bạn đã:
- Migrate full stack từ in-memory → Postgres + JPA. Service layer không thay đổi — chứng minh power Repository abstraction.
- Production-ready persistence: Flyway versioned migration, Hibernate validate, Hikari connection pool, batch insert, audit fields.
- No N+1: detect via log + fix via JOIN FETCH/EntityGraph/DTO projection.
- Pagination tối ưu: DTO projection + custom countQuery cho list endpoint.
- Integration test với real DB: Testcontainers — confidence không có với mock.
- Domain entity correct: equals/hashCode null-safe constant, no Lombok
@Data, helper methods bidirectional, cascade ALL chỉ parent → child.
App này là production foundation. Course Spring Security sẽ thêm JWT auth, role-based access control trên endpoint, AuditorAware từ SecurityContext, và wire User entity với UserDetailsService.
Tự kiểm tra
Q1Migration này thay tầng persistence nhưng Service layer "không đổi". Điều kiện nào từ TaskFlow v1 khiến việc đó khả thi — và nếu v1 thiết kế sai chỗ nào thì migration sẽ đau?▸
Khả thi nhờ v1 đặt Repository là interface và Service chỉ phụ thuộc interface đó — Liskov substitution: JPA repository thay in-memory impl mà caller không biết.
Migration sẽ đau nếu v1 phạm một trong các lỗi: Service ôm thẳng ConcurrentHashMap (phải viết lại Service), entity lộ ra response thay vì DTO (đổi entity là gãy API contract), hoặc exception mang sẵn HTTP status trong domain (mọi thay đổi tầng dưới lan lên tầng web). Cấu trúc đúng ở v1 chính là thứ "trả lương" ở bài này.
Q2Vì sao đề bài bắt ddl-auto: validate + Flyway thay vì để Hibernate ddl-auto: update tự sinh schema cho nhanh?▸
ddl-auto: validate + Flyway thay vì để Hibernate ddl-auto: update tự sinh schema cho nhanh?update để Hibernate đoán schema từ entity — không version control, không review được, không rollback được, và có những thay đổi nó xử lý nguy hiểm (đổi tên field thành thêm cột mới + bỏ rơi cột cũ, mất data âm thầm).
Flyway đảo quyền sở hữu: schema là code có version (V1, V2, V3...), chạy theo thứ tự, có checksum chống sửa lén. validate giữ Hibernate ở vai trò kiểm tra viên — entity lệch schema là app fail ngay lúc boot thay vì lỗi runtime khó đoán. Đây là phân công chuẩn production: Flyway ghi, Hibernate đọc-và-soát.
Q3Test workflow yêu cầu xác nhận list endpoint chỉ phát đúng 2 SQL. Hai SQL đó là gì, và nếu thấy 11 SQL cho 10 project thì chẩn đoán + fix theo thứ tự nào?▸
Hai SQL hợp lệ: (1) data query có phân trang (kèm JOIN hoặc COUNT subquery cho summary), (2) count query cho Page metadata.
Thấy 11 SQL cho 10 row là chữ ký N+1: 1 query load project + 10 query lazy-load collection trong vòng lặp mapping. Chẩn đoán bằng SQL log (pattern giống nhau lặp với param khác). Fix theo thứ tự ưu tiên của bài Cascade & N+1: DTO projection cho list endpoint (tốt nhất — không load entity), hoặc JOIN FETCH/@EntityGraph khi cần entity đầy đủ, @BatchSize là lưới an toàn cuối.
Q4Integration test dùng Testcontainers Postgres thay vì H2 in-memory. Trade-off là gì và vì sao bài này chọn Testcontainers?▸
H2 nhanh và không cần Docker, nhưng nó không phải Postgres: khác dialect, khác kiểu dữ liệu, không có index/constraint behavior thật — test xanh trên H2 vẫn có thể đỏ trên production. Flyway migration viết cho Postgres (ví dụ BIGSERIAL, partial index) thậm chí không chạy nổi trên H2.
Testcontainers trả chi phí vài giây spin-up container để đổi lấy fidelity: migration, query, constraint chạy đúng như production. Với capstone mà giá trị chính là "persistence thật", fidelity thắng tốc độ.
Chúc mừng — bạn đã hoàn thành course Spring REST & Data! TaskFlow đã có persistent storage production-grade. Course Spring Security tiếp theo: thêm authentication + authorization với Spring Security 6 và test pyramid — sau đó là observability, production-ready ở các course nâng cao.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên