99 Ngày Spring — Ngày 30: Checkpoint — CRUD API hoàn chỉnh
Ba mươi ngày, ba giai đoạn — IoC & bean, REST API, rồi Spring Data JPA — và hôm nay là bài checkpoint gom tất cả vào một: xây CRUD API quản lý công việc hoàn chỉnh từ controller qua service xuống repository. Không annotation mới nào cả; giá trị của bài nằm ở chỗ khác: thấy hai mươi mảnh kiến thức khớp vào nhau thành một dịch vụ chạy được — DTO, validation, ProblemDetail, phân trang, transaction, auditing — và một checklist review để tự chấm điểm mọi CRUD API bạn sẽ viết sau này.

Đề bài checkpoint và cấu trúc
// CHECKPOINT — API quản lý công việc (Task), gom kiến thức Ngày 11–29
//
// Hợp đồng REST:
// GET /api/tasks — danh sách, phân trang + lọc ?status=&q= (Ngày 18)
// GET /api/tasks/{id} — chi tiết, 404 nếu không có (Ngày 15, 16)
// POST /api/tasks — tạo, validate, 201 + Location (Ngày 13, 14, 15)
// PUT /api/tasks/{id} — cập nhật toàn phần
// PATCH /api/tasks/{id}/status — chuyển trạng thái (nghiệp vụ, không phải CRUD thô)
// DELETE /api/tasks/{id} — xóa, 204
//
// Yêu cầu xuyên suốt:
// - DTO tách khỏi entity (Ngày 13, 17) - lỗi chuẩn ProblemDetail (Ngày 16)
// - transaction ở service (Ngày 28) - auditing 4 cột (Ngày 29)
//
// Cấu trúc — mỗi tầng một trách nhiệm, đúng bài Java hôm nay:
// controller/ TaskController → HTTP: nhận request, trả response, KHÔNG nghiệp vụ
// service/ TaskService → nghiệp vụ + ranh giới transaction
// repository/ TaskRepository → truy cập dữ liệu, KHÔNG nghiệp vụ
// dto/ TaskRequest, TaskResponse, TaskMapper
// domain/ Task, TaskStatus, BaseEntity (Ngày 29)Hợp đồng REST 6 endpoint phủ trọn vòng đời tài nguyên — chú ý
PATCH /statustách riêng: chuyển trạng thái là nghiệp vụ có quy tắc, không phải sửa field thô — cùng bài học vớiBook.choMuon()bên series Java hôm nay.Bốn tầng, mỗi tầng một câu trả lời: controller — "HTTP nói gì?", service — "nghiệp vụ là gì, transaction ở đâu?", repository — "dữ liệu lấy thế nào?", domain — "quy tắc nào bất biến?". Lẫn tầng là mầm của mọi codebase khó bảo trì.
Mọi yêu cầu xuyên suốt đều đã học rải rác — bài hôm nay là lần đầu chúng cùng xuất hiện: đó chính là điểm khác giữa "biết từng annotation" và "dựng được một API tử tế".
Domain và repository
// domain — entity kế thừa auditing hôm qua, trạng thái là enum
public enum TaskStatus { TODO, IN_PROGRESS, DONE }
@Entity
@Table(name = "tasks")
public class Task extends BaseEntity { // 4 cột audit tự có (Ngày 29)
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String tieuDe;
@Column(length = 2000)
private String moTa;
@Enumerated(EnumType.STRING) // STRING — thêm hằng số không vỡ dữ liệu
@Column(nullable = false)
private TaskStatus status = TaskStatus.TODO;
private LocalDate hanChot;
protected Task() { } // JPA cần constructor rỗng
public Task(String tieuDe, String moTa, LocalDate hanChot) {
this.tieuDe = tieuDe; this.moTa = moTa; this.hanChot = hanChot;
}
// Chuyển trạng thái là NGHIỆP VỤ — học từ bài Java hôm nay: không setStatus thô
public void chuyenSang(TaskStatus moi) {
if (status == TaskStatus.DONE && moi != TaskStatus.DONE)
throw new IllegalStateException("Task đã DONE không quay lại được");
this.status = moi;
}
// getter + setter cho các field dữ liệu thuần...
}
// repository — derived query (Ngày 23, 24) là đủ cho checkpoint
public interface TaskRepository extends JpaRepository<Task, Long> {
Page<Task> findByStatus(TaskStatus status, Pageable pageable);
Page<Task> findByTieuDeContainingIgnoreCase(String q, Pageable pageable);
}Task extends BaseEntity— bốn cột auditing hôm qua tự theo về, không viết lại dòng nào: phần thưởng đầu tiên của@MappedSuperclass(Ngày 29).@Enumerated(EnumType.STRING)thay vì mặc địnhORDINAL: lưu"TODO"thay vì0— chèn thêm hằng số enum vào giữa không làm sai lệch toàn bộ dữ liệu cũ (Ngày 23); vàchuyenSang()mang quy tắc "DONE không quay lại" — entity không chỉ là túi getter/setter.Repository chỉ cần derived query (Ngày 23–24):
findByStatus,findByTieuDeContainingIgnoreCase— đặt tên đúng là có query, kèmPageableđể phân trang miễn phí; chưa cần@Queryhay Specification cho checkpoint này.
DTO, validation và lỗi chuẩn
// dto — record (bất biến, gọn) + Bean Validation ở BIÊN (Ngày 14)
public record TaskRequest(
@NotBlank @Size(max = 200) String tieuDe,
@Size(max = 2000) String moTa,
@FutureOrPresent LocalDate hanChot) { }
public record TaskResponse(
Long id, String tieuDe, String moTa, TaskStatus status,
LocalDate hanChot, Instant createdAt, Instant updatedAt) { }
// Entity KHÔNG bao giờ ra khỏi service (Ngày 17): đổi schema không vỡ API,
// và không lộ cột nhạy cảm ngoài ý muốn
// Mapper thủ công là đủ (MapStruct khi form phình to — Ngày 17):
public final class TaskMapper {
private TaskMapper() { }
public static Task toEntity(TaskRequest r) {
return new Task(r.tieuDe(), r.moTa(), r.hanChot());
}
public static TaskResponse toResponse(Task t) {
return new TaskResponse(t.getId(), t.getTieuDe(), t.getMoTa(),
t.getStatus(), t.getHanChot(), t.getCreatedAt(), t.getUpdatedAt());
}
}
// Lỗi 404 nghiệp vụ → ProblemDetail chuẩn RFC 7807 (Ngày 16)
public class TaskNotFoundException extends RuntimeException { // Runtime → rollback (Ngày 28)
public TaskNotFoundException(Long id) { super("Không tìm thấy task " + id); }
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
ProblemDetail notFound(TaskNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(IllegalStateException.class)
ProblemDetail conflict(IllegalStateException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
}DTO là record — bất biến (bài Java hôm qua) và gọn; validation ở biên (Ngày 14):
@NotBlank/@Size/@FutureOrPresentchặn dữ liệu xấu ngay cửa, service phía trong được quyền tin input đã sạch.Nguyên tắc sắt: entity không ra khỏi service (Ngày 17) — response là
TaskResponsenên đổi schema không vỡ API, không lộ cột ngoài ý muốn, và vòng lặp serialize quan hệ (Ngày 25) không có cửa xảy ra.Hai exception nghiệp vụ đều là
RuntimeException— vừa để rollback đúng (Ngày 28), vừa để@RestControllerAdvicedịch tập trung sangProblemDetail(Ngày 16): 404 cho không-tìm-thấy, 409 cho chuyển trạng thái sai — controller không try/catch gì cả.
Service — nghiệp vụ và ranh giới transaction
// service — nghiệp vụ + ranh giới transaction (Ngày 28)
@Service
@Transactional(readOnly = true) // mặc định cả lớp: ĐỌC
public class TaskService {
private final TaskRepository repo;
public TaskService(TaskRepository repo) { this.repo = repo; }
public Page<TaskResponse> danhSach(TaskStatus status, String q, Pageable pageable) {
Page<Task> page =
status != null ? repo.findByStatus(status, pageable)
: q != null ? repo.findByTieuDeContainingIgnoreCase(q, pageable)
: repo.findAll(pageable);
return page.map(TaskMapper::toResponse); // Page.map — giữ metadata phân trang
}
public TaskResponse chiTiet(Long id) {
return TaskMapper.toResponse(taskOf(id));
}
@Transactional // method GHI ghi đè readOnly
public TaskResponse tao(TaskRequest req) {
Task saved = repo.save(TaskMapper.toEntity(req));
return TaskMapper.toResponse(saved);
}
@Transactional
public TaskResponse capNhat(Long id, TaskRequest req) {
Task task = taskOf(id); // entity MANAGED trong transaction
task.setTieuDe(req.tieuDe()); // dirty checking tự UPDATE (Ngày 25)
task.setMoTa(req.moTa()); // → không cần gọi save()
task.setHanChot(req.hanChot());
return TaskMapper.toResponse(task);
}
@Transactional
public TaskResponse chuyenTrangThai(Long id, TaskStatus moi) {
Task task = taskOf(id);
task.chuyenSang(moi); // quy tắc nằm trong ENTITY
return TaskMapper.toResponse(task);
}
@Transactional
public void xoa(Long id) {
repo.delete(taskOf(id)); // tìm trước — xóa id ma sẽ ra 404 tử tế
}
private Task taskOf(Long id) {
return repo.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
}
}Mẫu đáng nhớ:
@Transactional(readOnly = true)đặt ở lớp làm mặc định, method ghi ghi đè bằng@Transactionalthường — đọc rẻ hơn (Ngày 28) và quên annotation ở method ghi thì lỗi lộ ra ngay khi flush, thay vì âm thầm sai.capNhatlà màn trình diễn của dirty checking (Ngày 25): entity managed, đổi field trong transaction, không gọisave()— UPDATE tự chạy lúc commit vàupdatedAt(Ngày 29) tự tiến theo.Helper
taskOf(id)gomfindById().orElseThrow()về một chỗ — mọi method cần entity đều đi qua nó nên 404 nhất quán toàn API;xoacũng tìm trước để id ma trả 404 tử tế thay vì nuốt lặng.
Controller — mỏng đúng nghĩa
// controller — mỏng: dịch HTTP ↔ service, không một dòng nghiệp vụ
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) { this.service = service; }
@GetMapping // GET /api/tasks?status=TODO&page=0&size=20
public Page<TaskResponse> danhSach(
@RequestParam(required = false) TaskStatus status,
@RequestParam(required = false) String q,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) { // Ngày 18 — sort theo cột auditing!
return service.danhSach(status, q, pageable);
}
@GetMapping("/{id}")
public TaskResponse chiTiet(@PathVariable Long id) {
return service.chiTiet(id); // không có → exception → 404 (advice lo)
}
@PostMapping // 201 + Location: /api/tasks/42 (Ngày 15)
public ResponseEntity<TaskResponse> tao(@Valid @RequestBody TaskRequest req) {
TaskResponse created = service.tao(req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public TaskResponse capNhat(@PathVariable Long id, @Valid @RequestBody TaskRequest req) {
return service.capNhat(id, req);
}
@PatchMapping("/{id}/status") // nghiệp vụ chuyển trạng thái riêng
public TaskResponse chuyenTrangThai(@PathVariable Long id,
@RequestParam TaskStatus to) {
return service.chuyenTrangThai(id, to);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 — xóa xong không có gì để trả
public void xoa(@PathVariable Long id) {
service.xoa(id);
}
}
// Thử nhanh:
// curl -X POST localhost:8080/api/tasks -H "Content-Type: application/json" \
// -d '{"tieuDe":"Viết bài Ngày 30","hanChot":"2026-08-30"}'
// → 201, Location: /api/tasks/1, body có createdAt tự điền (Ngày 29)Controller chỉ làm việc của HTTP: bind tham số, gọi service, chọn status —
201 + Locationcho POST (Ngày 15),204cho DELETE,Pagetrả thẳng cho GET danh sách (Ngày 18). Không nghiệp vụ, không try/catch, không Repository.@PageableDefaultsort theocreatedAtgiảm dần — cột auditing hôm qua lập tức hữu ích: mặc định API trả "mới nhất trước", đúng kỳ vọng phổ biến nhất của client.Đường đi của một request giờ đọc được thành văn:
@Validchặn ở cửa → service mở transaction → dirty checking sinh UPDATE → advice dịch exception →ProblemDetail— mỗi ngày đã học đứng đúng vị trí của nó trong chuỗi.
Checklist review — tự chấm CRUD API của bạn
Ranh giới: entity có ra khỏi service không? DTO có validate đủ ở biên không? (Ngày 13, 14, 17)
Transaction: mỗi use-case một transaction ở service?
readOnlycho đường đọc? Không gọi repository rời rạc từ controller? (Ngày 28)Lỗi: mọi lỗi ra cùng một định dạng
ProblemDetail? 404/409/400 đúng ngữ nghĩa? Không lộ stack trace? (Ngày 15, 16)Dữ liệu: enum lưu
STRING? Auditing đủ 4 cột? Phân trang có mặc định sort ổn định? (Ngày 18, 23, 29)Hiệu năng: danh sách có nguy cơ N+1 khi thêm quan hệ không — đã biết soi SQL log chưa? (Ngày 27)
Bài tập nhỏ
Dựng toàn bộ API theo bài, chạy đủ 6 endpoint bằng curl/Postman — kiểm tra Location sau POST và
createdAt/updatedAttrong response.Thử
PATCH /status?to=TODOtrên task đã DONE — xác nhận nhận về 409 dạngProblemDetailchứ không phải 500.Gửi POST thiếu
tieuDevàhanChotở quá khứ — đọc kỹ body lỗi validation trả về những field nào.Thêm endpoint
GET /api/tasks/overdue(hanChot < hôm nay, chưa DONE) — quyết định xem query nằm ở repository hay service, và vì sao.Viết test
@WebMvcTestcho controller (mock service) và@DataJpaTestcho repository — đúng tầng nào test tầng đó.
Kết luận
Checkpoint gói trong bốn ý: một CRUD API tử tế là chuỗi trách nhiệm rõ ràng — controller dịch HTTP, service giữ nghiệp vụ và transaction, repository lo dữ liệu, domain giữ quy tắc; DTO + validation ở biên và entity không rời service; lỗi về một định dạng qua advice; và auditing + phân trang + readOnly là những mặc định rẻ mà sang. Giai đoạn 3 khép lại. Ngày 31 mở Giai đoạn 4 — Data nâng cao, bắt đầu bằng pagination nâng cao: Slice vs Page, keyset pagination và cái giá thật của count(*). Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


