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
- Boot 3.4 + Java 21 + Maven.
- Storage: in-memory
ConcurrentHashMap(Module 04 sẽ migrate 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.
🔍 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 |
Module 04+ 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) {
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
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
@EntityJPA annotation lên Project/Task, swapInMemoryProjectRepository→ JPAProjectRepository 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...