Mini-challenge: TaskFlow v2 — Postgres + JPA capstone
Migrate TaskFlow Module 03 từ 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.
Module 04 đã bóc 7 layer JPA. Bài cuối này không thêm khái niệm — bạn migrate TaskFlow từ in-memory sang Postgres. Đây là production transition lớn — app trở thành persistent, data survive restart, scale tới million record.
Sau bài này, TaskFlow đã có infrastructure stack production-grade: Postgres + Hibernate + Flyway + Testcontainers. Module 05+ tiếp tục build trên foundation này.
🎯 Đề bài
Migrate TaskFlow Module 03 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ừ Module 03)
├── id: Long
├── name: String (unique)
├── description: String?
├── status: ProjectStatus
├── ownerId → User
├── createdAt, updatedAt: Instant
└── tasks: List<Task> (@OneToMany)
Task (rebuild từ Module 03)
├── 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 Module 03: 8+ endpoints, validation, Problem Details RFC 9457.
🔍 Kiến trúc mới
flowchart LR
Client[HTTP Client]
Filter[LoggingFilter MDC]
Ctrl["Controllers (Module 03 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);
@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() {
// Module 05 sẽ wire qua Spring Security
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 Module 05):
@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à Tier 1 production foundation. Module 05 sẽ thêm:
- Spring Security + JWT auth.
- Role-based access control on endpoints.
- AuditorAware từ SecurityContext.
- Wire User entity với UserDetailsService.
Chúc mừng — bạn đã hoàn thành Module 04! TaskFlow đã có persistent storage production-grade. Module 05 tiếp theo: thêm authentication + authorization với Spring Security 6. Đây là layer security cuối cùng cho Tier 1 — sau đó vào Tier 2 (testing, observability, production-ready).
Bài này có giúp bạn hiểu bản chất không?
Bình luận (0)
Đang tải...