Spring Boot/Mini-challenge: TaskFlow REST API v1 — capstone Module 03
~35 phútREST API với Spring MVCMiễn phí

Mini-challenge: TaskFlow REST API v1 — capstone Module 03

Build TaskFlow REST API v1 từ scratch — domain Project + Task. 8 endpoint CRUD, validation, exception handling Problem Details, OpenAPI doc, in-memory storage. Đây là baseline cho Module 04+ (JPA, Security) extend lên.

Module 03 đã bóc 7 layer: DispatcherServlet, @RestController, request binding, response, exception, validation, OpenAPI. Bài cuối này không thêm khái niệm — bạn build TaskFlow REST API v1 từ scratch áp dụng tất cả.

TaskFlow là capstone domain xuyên suốt khoá:

  • Module 03 (đây): REST API CRUD, in-memory.
  • Module 04: thêm JPA + Postgres + transactions.
  • Module 05: thêm JWT auth + role-based.
  • Module 06: thêm test pyramid với Testcontainers.
  • Module 08: observability stack.
  • Module 11-12: tách microservices, event-driven.
  • Module 13-14: K8s, native image, Spring Modulith.
  • Module 15: AI co-pilot.

Khi xong khoá, bạn có 1 production-grade Spring Boot app — không phải toy code.

🎯 Đề bài

Build app taskflow-api với:

Domain

Project (root entity)
├── id: Long
├── name: String (3-100 char, unique)
├── description: String? (max 500 char)
├── status: ProjectStatus (PLANNING / ACTIVE / DONE / ARCHIVED)
├── createdAt: Instant
└── tasks: List<Task>

Task
├── id: Long
├── projectId: Long
├── title: String (3-200 char)
├── description: String? (max 1000 char)
├── status: TaskStatus (TODO / IN_PROGRESS / DONE)
├── priority: TaskPriority (LOW / MEDIUM / HIGH / URGENT)
├── dueDate: LocalDate?
├── createdAt: Instant
└── updatedAt: Instant

8 Endpoints

POST   /api/v1/projects                    Create project
GET    /api/v1/projects                    List projects (page, filter)
GET    /api/v1/projects/{id}               Get project + tasks
PUT    /api/v1/projects/{id}               Update project
DELETE /api/v1/projects/{id}               Delete project (cascade tasks)

POST   /api/v1/projects/{id}/tasks         Create task in project
GET    /api/v1/projects/{id}/tasks         List tasks of project (filter by status)
PATCH  /api/v1/projects/{pid}/tasks/{tid}  Partial update task

Yêu cầu kỹ thuật

  1. Boot 3.4 + Java 21 + Maven.
  2. Storage: in-memory ConcurrentHashMap (Module 04 sẽ migrate JPA).
  3. 3 layer: Controller → Service → Repository (interface + in-memory impl).
  4. DTO: separate *Request (input) và *Dto (output) — record.
  5. Validation: Jakarta Bean Validation đầy đủ + cross-field qua @AssertTrue.
  6. Exception handling: @RestControllerAdvice global + Problem Details RFC 9457 + violations field.
  7. OpenAPI: springdoc-openapi với @Operation, @Schema example.
  8. Status code đúng: 201 Created với Location, 204 No Content cho DELETE, 404/409/400 chuẩn.
  9. MDC correlation ID: filter tự gen X-Request-Id, log mọi request.
  10. Unit test: MockMvc cho 3-5 happy path + edge case.

🔍 Phân tích kiến trúc

flowchart LR
    Client[HTTP Client]
    F["LoggingFilter<br/>(MDC)"]
    DS["DispatcherServlet"]
    Adv["GlobalExceptionHandler<br/>@RestControllerAdvice"]
    PC["ProjectController"]
    TC["TaskController"]
    PS["ProjectService"]
    TS["TaskService"]
    PR["ProjectRepository<br/>(in-memory)"]
    TR["TaskRepository<br/>(in-memory)"]
    Storage[("ConcurrentHashMap")]

    Client --> F --> DS
    DS --> PC
    DS --> TC
    DS -.exception.-> Adv
    PC --> PS
    TC --> TS
    PS --> PR
    TS --> TR
    PR --> Storage
    TR --> Storage

    style F fill:#fef3c7
    style Adv fill:#fee

Layered architecture:

LayerResponsibilityTest scope
ControllerHTTP binding, validation, status codeMockMvc test
ServiceBusiness logic, orchestrationUnit test với mock repo
RepositoryData access (in-memory)Unit test trực tiếp
DomainEntity records (immutable)Pure Java test
DTORequest/Response shapeValidation test
FilterCross-cutting (logging, MDC)Integration test

Module 04+ sẽ refactor Repository → JPA interface — Service layer không đổi (Liskov substitution).

📦 Concept dùng trong bài

ConceptModuleÁp dụng ở đây
@SpringBootApplicationM02Entry point
Auto-configurationM02Tomcat, Jackson tự setup
Externalized configM02application.yml
Logging + MDCM02Correlation ID per request
DispatcherServletM03Request routing
@RestControllerM03API endpoints
Request bindingM03@PathVariable, @RequestBody
Response handlingM03ResponseEntity, status code chuẩn
Exception handlingM03@ControllerAdvice + Problem Details
ValidationM03@Valid + cross-field
OpenAPIM03springdoc + @Operation

▶️ Cấu trúc project

taskflow-api/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/com/olhub/taskflow/
    │   │   ├── App.java
    │   │   ├── config/
    │   │   │   ├── OpenApiConfig.java
    │   │   │   └── LoggingFilter.java
    │   │   ├── domain/
    │   │   │   ├── Project.java                  (record)
    │   │   │   ├── ProjectStatus.java            (enum)
    │   │   │   ├── Task.java                      (record)
    │   │   │   ├── TaskStatus.java                (enum)
    │   │   │   └── TaskPriority.java              (enum)
    │   │   ├── api/
    │   │   │   ├── ProjectController.java
    │   │   │   ├── TaskController.java
    │   │   │   ├── GlobalExceptionHandler.java
    │   │   │   └── dto/
    │   │   │       ├── CreateProjectRequest.java
    │   │   │       ├── UpdateProjectRequest.java
    │   │   │       ├── ProjectDto.java
    │   │   │       ├── CreateTaskRequest.java
    │   │   │       ├── PatchTaskRequest.java
    │   │   │       ├── TaskDto.java
    │   │   │       └── ErrorViolation.java
    │   │   ├── service/
    │   │   │   ├── ProjectService.java
    │   │   │   └── TaskService.java
    │   │   ├── repository/
    │   │   │   ├── ProjectRepository.java         (interface)
    │   │   │   ├── InMemoryProjectRepository.java
    │   │   │   ├── TaskRepository.java
    │   │   │   └── InMemoryTaskRepository.java
    │   │   └── exception/
    │   │       ├── ProjectNotFoundException.java
    │   │       ├── TaskNotFoundException.java
    │   │       ├── DuplicateProjectNameException.java
    │   │       └── InvalidStateTransitionException.java
    │   └── resources/
    │       └── application.yml
    └── test/
        └── java/com/olhub/taskflow/
            └── api/
                ├── ProjectControllerTest.java
                └── TaskControllerTest.java

Dành 35-45 phút build skeleton. Hint chi tiết ở dưới khi kẹt.

💡 Gợi ý — code chính

💡 Hint — đọc khi kẹt

pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</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.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>
</dependencies>

Domain — Project.java:

public record Project(
    Long id,
    String name,
    String description,
    ProjectStatus status,
    Instant createdAt
) {}

public enum ProjectStatus { PLANNING, ACTIVE, DONE, ARCHIVED }

DTO — CreateProjectRequest.java:

@Schema(description = "Create project request")
public record CreateProjectRequest(

    @Schema(description = "Project name (unique)", example = "Mobile App Redesign", minLength = 3, maxLength = 100)
    @NotBlank @Size(min = 3, max = 100)
    String name,

    @Schema(description = "Project description", example = "Redesign mobile app for v3", maxLength = 500)
    @Size(max = 500)
    String description,

    @Schema(description = "Initial status", example = "PLANNING")
    ProjectStatus status
) {
    public CreateProjectRequest {
        if (status == null) status = ProjectStatus.PLANNING;
    }
}

Service — ProjectService.java:

@Service
@Slf4j
public class ProjectService {

    private final ProjectRepository repo;
    private final TaskRepository taskRepo;

    public ProjectService(ProjectRepository repo, TaskRepository taskRepo) {
        this.repo = repo;
        this.taskRepo = taskRepo;
    }

    public Project create(CreateProjectRequest req) {
        if (repo.existsByName(req.name())) {
            throw new DuplicateProjectNameException(req.name());
        }
        Project project = new Project(
            null,                                  // ID auto-gen
            req.name(),
            req.description(),
            req.status(),
            Instant.now()
        );
        Project saved = repo.save(project);
        log.info("Created project {}", saved.id());
        return saved;
    }

    public Project findById(Long id) {
        return repo.findById(id)
            .orElseThrow(() -> new ProjectNotFoundException(id));
    }

    public Page<Project> list(ProjectStatus status, Pageable pageable) {
        return repo.findByStatus(status, pageable);
    }

    public Project update(Long id, UpdateProjectRequest req) {
        Project existing = findById(id);
        if (!existing.name().equals(req.name()) && repo.existsByName(req.name())) {
            throw new DuplicateProjectNameException(req.name());
        }
        Project updated = new Project(
            id,
            req.name(),
            req.description(),
            req.status(),
            existing.createdAt()
        );
        return repo.save(updated);
    }

    @Transactional
    public void delete(Long id) {
        Project project = findById(id);
        taskRepo.deleteByProjectId(id);          // cascade
        repo.delete(id);
        log.info("Deleted project {} with {} tasks", id, taskRepo.countByProjectId(id));
    }
}

Controller — ProjectController.java:

@RestController
@RequestMapping("/api/v1/projects")
@Tag(name = "Projects", description = "Project management")
@Validated
public class ProjectController {

    private final ProjectService service;

    public ProjectController(ProjectService service) {
        this.service = service;
    }

    @Operation(summary = "Create project")
    @PostMapping
    public ResponseEntity<ProjectDto> create(@Valid @RequestBody CreateProjectRequest req) {
        Project created = service.create(req);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(ProjectDto.from(created));
    }

    @Operation(summary = "List projects")
    @GetMapping
    public Page<ProjectDto> list(
        @Parameter(example = "ACTIVE")
        @RequestParam(required = false) ProjectStatus status,
        @Parameter(example = "0") @Min(0)
        @RequestParam(defaultValue = "0") int page,
        @Parameter(example = "20") @Min(1) @Max(100)
        @RequestParam(defaultValue = "20") int size
    ) {
        return service.list(status, PageRequest.of(page, size))
            .map(ProjectDto::from);
    }

    @Operation(summary = "Get project by ID")
    @GetMapping("/{id}")
    public ProjectDto get(@PathVariable Long id) {
        return ProjectDto.from(service.findById(id));
    }

    @Operation(summary = "Update project")
    @PutMapping("/{id}")
    public ProjectDto update(@PathVariable Long id,
                              @Valid @RequestBody UpdateProjectRequest req) {
        return ProjectDto.from(service.update(id, req));
    }

    @Operation(summary = "Delete project (cascade tasks)")
    @ApiResponse(responseCode = "204", description = "Deleted successfully")
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

Exception Handler — GlobalExceptionHandler.java:

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(ProjectNotFoundException.class)
    public ProblemDetail handleProjectNotFound(ProjectNotFoundException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        pd.setTitle("Project not found");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        pd.setProperty("projectId", ex.getProjectId());
        return pd;
    }

    @ExceptionHandler(TaskNotFoundException.class)
    public ProblemDetail handleTaskNotFound(TaskNotFoundException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        pd.setTitle("Task not found");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        pd.setProperty("taskId", ex.getTaskId());
        return pd;
    }

    @ExceptionHandler(DuplicateProjectNameException.class)
    public ProblemDetail handleDuplicate(DuplicateProjectNameException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        pd.setTitle("Project name already exists");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        return pd;
    }

    @ExceptionHandler(InvalidStateTransitionException.class)
    public ProblemDetail handleInvalidTransition(InvalidStateTransitionException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
        pd.setTitle("Invalid state transition");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        return pd;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setDetail("Request has " + ex.getBindingResult().getErrorCount() + " invalid field(s)");
        pd.setInstance(URI.create(req.getRequestURI()));

        List<Map<String, Object>> violations = ex.getBindingResult().getFieldErrors().stream()
            .map(err -> Map.<String, Object>of(
                "field", err.getField(),
                "message", err.getDefaultMessage(),
                "rejectedValue", String.valueOf(err.getRejectedValue())
            ))
            .toList();
        pd.setProperty("violations", violations);

        return pd;
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setDetail("Request parameter validation failed");
        pd.setInstance(URI.create(req.getRequestURI()));

        List<Map<String, Object>> violations = ex.getConstraintViolations().stream()
            .map(v -> Map.<String, Object>of(
                "path", v.getPropertyPath().toString(),
                "message", v.getMessage(),
                "rejectedValue", String.valueOf(v.getInvalidValue())
            ))
            .toList();
        pd.setProperty("violations", violations);

        return pd;
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleAll(Exception ex, HttpServletRequest req) {
        String requestId = MDC.get("requestId");
        log.error("Unhandled exception, requestId={}, path={}", requestId, req.getRequestURI(), ex);

        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        pd.setTitle("Internal server error");
        pd.setDetail("An unexpected error occurred. Contact support with request ID.");
        pd.setInstance(URI.create(req.getRequestURI()));
        pd.setProperty("requestId", requestId);
        return pd;
    }
}

Filter — LoggingFilter.java:

@Component
@Slf4j
public class LoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String requestId = req.getHeader("X-Request-Id");
        if (requestId == null) {
            requestId = UUID.randomUUID().toString().substring(0, 8);
        }

        long start = System.currentTimeMillis();

        try (MDC.MDCCloseable mdc = MDC.putCloseable("requestId", requestId)) {
            log.info(">>> {} {}", req.getMethod(), req.getRequestURI());
            res.setHeader("X-Request-Id", requestId);

            chain.doFilter(req, res);

            long elapsed = System.currentTimeMillis() - start;
            log.info("<<< {} in {}ms", res.getStatus(), elapsed);
        }
    }
}

application.yml:

server:
  port: 8080
  shutdown: graceful

spring:
  application:
    name: taskflow-api
  mvc:
    problemdetails:
      enabled: true
  lifecycle:
    timeout-per-shutdown-phase: 30s

logging:
  level:
    com.olhub.taskflow: DEBUG
  pattern:
    console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{requestId:-no-req}] %logger{30} : %msg%n"

springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html
    try-it-out-enabled: true

management:
  endpoints:
    web:
      exposure:
        include: health, info, conditions, mappings, loggers

✅ Test workflow

✅ Sau khi build xong, chạy + test

Run:

mvn spring-boot:run

Open Swagger UI:

http://localhost:8080/swagger-ui.html

Test endpoint qua "Try it out".

Curl mẫu:

# 1. Create project
curl -X POST http://localhost:8080/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"Mobile Redesign","description":"v3 redesign","status":"PLANNING"}'
# 201 Created
# Location: /api/v1/projects/1

# 2. List projects
curl http://localhost:8080/api/v1/projects?status=PLANNING

# 3. Get project
curl http://localhost:8080/api/v1/projects/1

# 4. Create task
curl -X POST http://localhost:8080/api/v1/projects/1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Design home screen","priority":"HIGH","dueDate":"2026-05-01"}'
# 201 Created

# 5. Validation error
curl -X POST http://localhost:8080/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"X"}'
# 400 Bad Request, Problem Details với violations

# 6. Not found
curl http://localhost:8080/api/v1/projects/999
# 404 Not Found, Problem Details

# 7. Conflict
curl -X POST http://localhost:8080/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"Mobile Redesign"}'
# 409 Conflict (duplicate name)

# 8. Delete
curl -X DELETE http://localhost:8080/api/v1/projects/1
# 204 No Content

Expected Problem Details cho 400:

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "Request has 1 invalid field(s)",
  "instance": "/api/v1/projects",
  "violations": [
    {"field": "name", "message": "size must be between 3 and 100", "rejectedValue": "X"}
  ]
}

Expected log output:

10:00:00.123 [http-nio-8080-exec-1] INFO  [a1b2c3d4] LoggingFilter : >>> POST /api/v1/projects
10:00:00.130 [http-nio-8080-exec-1] INFO  [a1b2c3d4] ProjectService : Created project 1
10:00:00.135 [http-nio-8080-exec-1] INFO  [a1b2c3d4] LoggingFilter : <<< 201 in 12ms

🎓 Mở rộng

Mức 1 — Test slice với MockMvc:

@WebMvcTest(ProjectController.class)
class ProjectControllerTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean ProjectService service;
    @Autowired ObjectMapper json;

    @Test
    void create_validRequest_returns201WithLocation() throws Exception {
        Project created = new Project(1L, "Mobile App", null, ProjectStatus.PLANNING, Instant.now());
        when(service.create(any())).thenReturn(created);

        mockMvc.perform(post("/api/v1/projects")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json.writeValueAsString(new CreateProjectRequest("Mobile App", null, null))))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", endsWith("/api/v1/projects/1")))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Mobile App"));
    }

    @Test
    void create_invalidName_returns400() throws Exception {
        mockMvc.perform(post("/api/v1/projects")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"\"}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.title").value("Validation failed"))
            .andExpect(jsonPath("$.violations[0].field").value("name"));
    }

    @Test
    void getNonExistent_returns404() throws Exception {
        when(service.findById(99L)).thenThrow(new ProjectNotFoundException(99L));

        mockMvc.perform(get("/api/v1/projects/99"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.title").value("Project not found"))
            .andExpect(jsonPath("$.projectId").value(99));
    }
}

Mức 2 — State transition validation:

Project chuyển status theo rule: PLANNING → ACTIVE → DONE → ARCHIVED. Không skip, không reverse:

public Project update(Long id, UpdateProjectRequest req) {
    Project existing = findById(id);
    validateStateTransition(existing.status(), req.status());
    // ...
}

private void validateStateTransition(ProjectStatus from, ProjectStatus to) {
    if (from == to) return;
    Map<ProjectStatus, Set<ProjectStatus>> allowed = Map.of(
        ProjectStatus.PLANNING, Set.of(ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED),
        ProjectStatus.ACTIVE, Set.of(ProjectStatus.DONE, ProjectStatus.ARCHIVED),
        ProjectStatus.DONE, Set.of(ProjectStatus.ARCHIVED),
        ProjectStatus.ARCHIVED, Set.of()
    );
    if (!allowed.getOrDefault(from, Set.of()).contains(to)) {
        throw new InvalidStateTransitionException(from, to);
    }
}

Test:

curl -X PUT http://localhost:8080/api/v1/projects/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Mobile","status":"DONE"}'  # PLANNING → DONE skip
# 422 Unprocessable Entity

Mức 3 — Pagination với metadata header:

@GetMapping
public ResponseEntity<List<ProjectDto>> list(...) {
    Page<ProjectDto> page = service.list(status, PageRequest.of(pageNum, size)).map(ProjectDto::from);

    return ResponseEntity.ok()
        .header("X-Total-Count", String.valueOf(page.getTotalElements()))
        .header("X-Total-Pages", String.valueOf(page.getTotalPages()))
        .header("X-Page-Number", String.valueOf(page.getNumber()))
        .body(page.getContent());
}

Mức 4 — ETag cho conditional GET:

@GetMapping("/{id}")
public ResponseEntity<ProjectDto> get(
    @PathVariable Long id,
    @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
    Project project = service.findById(id);
    String etag = "\"" + project.id() + "-" + project.createdAt().toEpochMilli() + "\"";

    if (etag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
    }

    return ResponseEntity.ok()
        .eTag(etag)
        .body(ProjectDto.from(project));
}

Mức 5 — JSON Patch RFC 6902:

curl -X PATCH http://localhost:8080/api/v1/projects/1/tasks/5 \
  -H "Content-Type: application/json-patch+json" \
  -d '[
    { "op": "replace", "path": "/status", "value": "DONE" },
    { "op": "replace", "path": "/priority", "value": "LOW" }
  ]'

Lib: com.github.java-json-tools:json-patch. Apply patch document → entity → save.

✨ Điều bạn vừa làm được

Hoàn thành mini-challenge này, bạn đã:

  • Build production-grade Spring Boot REST API: 8 endpoint, 3 layer architecture (Controller / Service / Repository), separation of concerns rõ ràng.
  • Apply tất cả 7 concept Module 03: DispatcherServlet (auto), @RestController, request binding, response handling, exception handling RFC 9457, validation Jakarta, OpenAPI doc.
  • Layered architecture testable: Repository interface cho phép Module 04 swap in-memory → JPA mà không sửa Service.
  • Production patterns: Problem Details RFC 9457 cho error, MDC correlation ID, Swagger UI dev workflow, status code chuẩn (201/204/404/409/422/400).
  • Domain-driven exception handling: Domain exception (ProjectNotFoundException) pure POJO, mapping HTTP qua @RestControllerAdvice — separation clean.
  • Testable code: separation cho phép MockMvc test controller, JUnit test service với mock repo, no Spring context needed cho unit test.

App này là starter template cho mọi Module sau:

  • Module 04: thêm @Entity JPA annotation lên Project/Task, swap InMemoryProjectRepository → JPA ProjectRepository extends JpaRepository. Service layer không đổi.
  • Module 05: thêm Spring Security, JWT filter, role-based access control on endpoint.
  • Module 06: thêm Testcontainers integration test với real Postgres.
  • ...

Production-grade architecture từ Module 03 trả lương cho mọi module sau.

Chúc mừng — bạn đã hoàn thành Module 03! TaskFlow REST API v1 đã ready. Module 04 tiếp theo: thay in-memory bằng Spring Data JPA + PostgreSQL + transactions — tăng app từ "demo" thành "production data persistence".

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