Mini-challenge: TaskFlow REST API v1 — capstone phần REST API
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 phần JPA và course Spring Security extend lên.
TL;DR: Bài này không dạy khái niệm mới — bạn build TaskFlow REST API v1 từ scratch, áp dụng toàn bộ 7 concept phần REST API: @RestController, request binding, response, exception handling Problem Details, validation, OpenAPI. Sản phẩm là app taskflow-api 8 endpoint CRUD với in-memory storage, kiến trúc 3 layer testable. Đây là baseline mà phần JPA (TaskFlow v2) và các course sau (Security, Testing, Production) extend lên — đầu tư cấu trúc đúng ở đây trả lương cho mọi phần sau.
TaskFlow là capstone domain xuyên suốt track Spring: phần REST API build CRUD in-memory (bài này), phần JPA thêm Postgres + transactions, các course sau lần lượt thêm JWT auth, test pyramid Testcontainers, observability, rồi microservices. 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
- Boot 3.4 + Java 21 + Maven.
- Storage: in-memory
ConcurrentHashMap(phần JPA sẽ migrate sang JPA). - 3 layer: Controller → Service → Repository (interface + in-memory impl).
- DTO: separate
*Request(input) và*Dto(output) — record. - Validation: Jakarta Bean Validation đầy đủ + cross-field qua
@AssertTrue. - Exception handling:
@RestControllerAdviceglobal + Problem Details RFC 9457 +violationsfield. - OpenAPI: springdoc-openapi với
@Operation,@Schemaexample. - Status code đúng: 201 Created với Location, 204 No Content cho DELETE, 404/409/400 chuẩn.
- MDC correlation ID: filter tự gen
X-Request-Id, log mọi request. - Unit test: MockMvc cho 3-5 happy path + edge case.
✅ Checklist hoàn thành
Đánh dấu từng mục trước khi coi challenge là xong. [Bắt buộc] là tối thiểu để qua bài; [Bonus] dành cho người muốn đẩy xa hơn.
- [Bắt buộc] 8 endpoint chạy đúng HTTP method + status code chuẩn (201 +
Location, 204 cho DELETE, 404/409/400). - [Bắt buộc] 3 layer tách bạch: Controller không chứa business logic, Repository là interface + in-memory impl.
- [Bắt buộc] DTO record tách
*Request/*Dto— entity không bao giờ lộ ra response. - [Bắt buộc] Validation Jakarta đầy đủ; lỗi trả Problem Details RFC 9457 kèm field
violations. - [Bắt buộc]
@RestControllerAdvicemap domain exception (404/409) — domain exception là POJO thuần. - [Bắt buộc] Test workflow 8 bước bằng curl (section ✅ Test workflow) pass toàn bộ.
- [Bonus] Swagger UI mô tả đủ 8 endpoint với
@Operation+@Schemaexample. - [Bonus] MDC correlation ID filter + MockMvc test slice (mức 1 phần Mở rộng).
🔍 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:#feeLayered architecture:
| Layer | Responsibility | Test scope |
|---|---|---|
| Controller | HTTP binding, validation, status code | MockMvc test |
| Service | Business logic, orchestration | Unit test với mock repo |
| Repository | Data access (in-memory) | Unit test trực tiếp |
| Domain | Entity records (immutable) | Pure Java test |
| DTO | Request/Response shape | Validation test |
| Filter | Cross-cutting (logging, MDC) | Integration test |
Phần JPA sẽ refactor Repository → JPA interface — Service layer không đổi (Liskov substitution).
📦 Concept dùng trong bài
| Concept | Module | Áp dụng ở đây |
|---|---|---|
@SpringBootApplication | M02 | Entry point |
| Auto-configuration | M02 | Tomcat, Jackson tự setup |
| Externalized config | M02 | application.yml |
| Logging + MDC | M02 | Correlation ID per request |
DispatcherServlet | M03 | Request routing |
@RestController | M03 | API endpoints |
| Request binding | M03 | @PathVariable, @RequestBody |
| Response handling | M03 | ResponseEntity, status code chuẩn |
| Exception handling | M03 | @ControllerAdvice + Problem Details |
| Validation | M03 | @Valid + cross-field |
| OpenAPI | M03 | springdoc + @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
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) {
findById(id); // validate exists (throws 404 if not)
long taskCount = taskRepo.countByProjectId(id); // count BEFORE delete
taskRepo.deleteByProjectId(id); // cascade
repo.delete(id);
log.info("Deleted project {} with {} tasks", id, taskCount);
}
}
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
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 phần REST API: DispatcherServlet (auto), @RestController, request binding, response handling, exception handling RFC 9457, validation Jakarta, OpenAPI doc.
- Layered architecture testable: Repository interface cho phép phần JPA 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 phần sau: phần JPA thêm @Entity lên Project/Task và swap InMemoryProjectRepository sang JpaRepository (Service layer không đổi); course Security thêm JWT + role-based access; module Testing thêm Testcontainers với real Postgres.
Tự kiểm tra
Q1Vì sao đề bài bắt Repository là interface + in-memory impl thay vì viết thẳng ConcurrentHashMap trong Service? Lợi ích cụ thể khi sang phần JPA là gì?▸
ConcurrentHashMap trong Service? Lợi ích cụ thể khi sang phần JPA là gì?Interface tách contract (Service cần lưu/tìm Project) khỏi implementation (lưu ở đâu, bằng gì). Service chỉ phụ thuộc interface — không biết và không quan tâm data nằm trong map hay Postgres.
Khi sang phần JPA (TaskFlow v2), bạn chỉ cần thay impl: InMemoryProjectRepository nghỉ việc, ProjectRepository extends JpaRepository vào thay. Service, Controller, DTO, exception handling — toàn bộ không sửa một dòng. Nếu Service ôm thẳng ConcurrentHashMap, migration nghĩa là viết lại Service + sửa toàn bộ test của nó.
Q2Domain exception như ProjectNotFoundException được yêu cầu là POJO thuần — không import gì từ org.springframework.http. Thiết kế này mua được gì?▸
ProjectNotFoundException được yêu cầu là POJO thuần — không import gì từ org.springframework.http. Thiết kế này mua được gì?Domain exception nói bằng ngôn ngữ business ("project không tồn tại"), không phải ngôn ngữ HTTP ("404"). Việc map sang status code là quyết định của tầng web, tập trung một chỗ trong @RestControllerAdvice.
Lợi ích: (1) service test được ngoài Spring context — không cần MockMvc chỉ để assert exception; (2) cùng domain exception tái dùng được nếu sau này expose qua gRPC/messaging với error mapping khác; (3) audit "exception nào ra status nào" chỉ cần đọc một file advice.
Q3Vì sao POST trả 201 kèm header Location thay vì 200 kèm body chứa id? Client chuẩn dùng Location như thế nào?▸
Location thay vì 200 kèm body chứa id? Client chuẩn dùng Location như thế nào?201 Created là semantics HTTP chuẩn cho "resource mới đã được tạo", và Location header cho client biết URL canonical của resource đó — client không cần tự build URL từ id (tránh hard-code URL structure).
Client chuẩn đọc Location rồi GET thẳng URL đó khi cần resource đầy đủ. Đây cũng là nền cho HATEOAS sau này: server làm chủ URL space, client chỉ follow link server đưa.
Q4Validation lỗi trả 400 với violations, conflict tên project trả 409, project không tồn tại trả 404. Nếu gộp cả ba thành 400 "Bad Request" chung thì client mất khả năng gì?▸
violations, conflict tên project trả 409, project không tồn tại trả 404. Nếu gộp cả ba thành 400 "Bad Request" chung thì client mất khả năng gì?Status code là contract máy-đọc-được. Client phân nhánh hành vi theo code: 400 + violations nghĩa là highlight field sai trên form; 409 nghĩa là gợi ý user đổi tên (retry với data khác sẽ thành công); 404 nghĩa là resource biến mất — quay về list, đừng retry.
Gộp tất cả thành 400, client buộc phải parse message text để đoán nguyên nhân — message đổi là client gãy. Đây chính là lý do RFC 9457 chuẩn hóa type/status/detail: phân loại lỗi nằm ở field cấu trúc, không nằm trong câu văn.
Chúc mừng — bạn đã hoàn thành phần REST API! TaskFlow REST API v1 đã ready. Phần JPA 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?
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