Backend

99 Ngày Spring — Ngày 30: Checkpoint — CRUD API hoàn chỉnh

SSite Admin
26 tháng 08, 2026 10 phút đọc 1 lượt xem
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.

Sketchnote Ngày 30: Checkpoint CRUD API — hợp đồng REST của Task API, kiến trúc controller service repository, DTO và validation, transaction và checklist review

Đề 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 /status tá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ới Book.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 định ORDINAL: 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èm Pageable để phân trang miễn phí; chưa cần @Query hay 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/@FutureOrPresent chặ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à TaskResponse nê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 để @RestControllerAdvice dịch tập trung sang ProblemDetail (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 @Transactional thườ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.

  • capNhat là màn trình diễn của dirty checking (Ngày 25): entity managed, đổi field trong transaction, không gọi save() — UPDATE tự chạy lúc commit và updatedAt (Ngày 29) tự tiến theo.

  • Helper taskOf(id) gom findById().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; xoa cũ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 + Location cho POST (Ngày 15), 204 cho DELETE, Page trả thẳng cho GET danh sách (Ngày 18). Không nghiệp vụ, không try/catch, không Repository.

  • @PageableDefault sort theo createdAt giả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: @Valid chặ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? readOnly cho đườ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/updatedAt trong response.

  • Thử PATCH /status?to=TODO trên task đã DONE — xác nhận nhận về 409 dạng ProblemDetail chứ không phải 500.

  • Gửi POST thiếu tieuDehanChot ở 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 @WebMvcTest cho controller (mock service) và @DataJpaTest cho 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ênentity 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!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 30: Mini project OOP — Quản lý thư viện

Bài tổng hợp 20 ngày OOP: phân tích đề bài, tổ chức package model/repository/service/app, áp dụng đủ 4 trụ cột — và kiến trúc phân tầng sẽ gặp lại trong Spring.

26 thg 8, 202612 phút0
99 Ngày Spring — Ngày 29: Auditing với Spring Data JPA

BaseEntity với @CreatedDate/@LastModifiedDate, AuditorAware cho @CreatedBy, những chỗ auditing không chạy (bulk update, JDBC) — và cách test tất định bằng DateTimeProvider.

25 thg 8, 20269 phút7
99 Ngày Java — Ngày 29: Immutable & defensive copy

Công thức lớp bất biến, vì sao final chưa đủ và defensive copy hai chiều, List.copyOf vs unmodifiableList — và món quà thread-safe không cần lock.

25 thg 8, 202611 phút8