Spring Boot/Mini-challenge: TaskFlow v2 — Postgres + JPA capstone
~40 phútSpring Data JPAMiễn phí

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

  1. Spring Boot 3.4 + Postgres 16 + Java 21.
  2. Flyway migration: V1 init schema, V2 indexes, V3 seed dev data (profile dev only).
  3. Entities với JPA: class với getter/setter (no record), no-arg ctor, equals/hashCode null-safe constant.
  4. Repository: extend JpaRepository. Mix derived query + @Query JPQL + DTO projection.
  5. Relationships đúng: LAZY default, helper methods bidirectional, cascade ALL + orphanRemoval cho parent-child.
  6. Service @Transactional: class level readOnly = true, override write methods.
  7. No N+1: detect via SQL log + fix via JOIN FETCH/EntityGraph/DTO projection.
  8. Pagination: Page<ProjectDto> cho list endpoints.
  9. Audit fields: @CreatedDate, @LastModifiedDate qua AuditingEntityListener.
  10. Integration test: Testcontainers Postgres + @SpringBootTest cho Repository + Service.
  11. 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:#d1fae5

Service 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

ConceptModule
@Entity, @Id, @GeneratedValueM04 bài 02
@OneToMany, @ManyToOne, LAZY fetchM04 bài 04
Repository abstraction, DTO projectionM04 bài 03
@Transactional, propagationM04 bài 05
Flyway migration, V/R scriptsM04 bài 06
Pagination, SortM04 bài 07
Auditing @CreatedDateM04 bài 03
@RestControllerAdvice Problem DetailsM03 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

💡 Hint cho từng layer

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

✅ Sau khi build xong

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...