Spring Production-Ready/Sealed Result — lỗi nghiệp vụ không phải exception
7/26
Bài 7 / 26~13 phútModern Java trong SpringMiễn phí lượt xem

Sealed Result — lỗi nghiệp vụ không phải exception

Sealed interface cộng pattern matching cho phép model kết quả nghiệp vụ thành kiểu dữ liệu, rồi map sang ProblemDetail ở controller thay vì ném exception.

TL;DR: Exception hợp cho sự cố ngoài dự kiến, nhưng lỗi nghiệp vụ dự đoán được (hết hàng, quá hạn mức, không đủ quyền) là kết quả, nên model bằng kiểu dữ liệu: sealed interface với mỗi nhánh là một record mang đúng dữ liệu cần. Consume bằng switch pattern matching — compiler bắt lỗi ngay lúc build khi thêm nhánh mới mà quên xử lý, thứ exception không làm được. Controller map từng nhánh sang ProblemDetail; service không biết gì về HTTP. Cạm bẫy lớn nhất: @Transactional mặc định chỉ rollback khi có unchecked exception — trả về nhánh lỗi của Result là return bình thường, transaction vẫn commit.

Ở bài trước, DTO request/response là record đứng ở ranh giới HTTP. Bài này hỏi một câu khác: khi nghiệp vụ bên trong service có nhiều kết quả có thể xảy ra, chữ ký method nên khai báo gì — ném exception, hay trả về một kiểu dữ liệu mô tả đủ các khả năng?

1. Chữ ký nói dối

public class OrderService {

    public void placeOrder(Order order) {
        if (!inventory.hasStock(order.productId(), order.quantity())) {
            throw new IllegalStateException("khong du hang");
        }
        if (creditLimit.exceeds(order.customerId(), order.total())) {
            throw new IllegalStateException("vuot han muc tin dung");
        }
        orderRepository.save(order);
    }
}

Ba vấn đề nằm ngay trong đoạn code này. Chữ ký void placeOrder(Order order) không nói gì về khả năng thất bại — caller không biết method có thể ném ra lỗi gì. Compiler cũng không giúp được gì: IllegalStateException là unchecked, quên bắt nó code vẫn biên dịch và deploy bình thường, chỉ lộ ra khi đúng request đi qua nhánh chưa test.

Vấn đề thứ ba mới là gốc rễ: hết hàng và vượt hạn mức không phải "sự cố" — đó là hai kết quả nghiệp vụ bình thường, nằm ngay trong đặc tả của placeOrder. Dùng cơ chế dành cho tình huống ngoại lệ cho một luồng xảy ra hàng trăm lần mỗi ngày là dùng sai công cụ. Bài này model tường minh các kết quả có thể có, thay vì giấu chúng sau một exception chung chung.

2. Trục phân biệt — kết quả dự đoán được vs sự cố ngoài dự kiến

Trước khi dựng kiểu Result, cần tách rạch một trục: không phải mọi thứ có thể sai đều nên rời khỏi exception. Nộp đơn xin nghỉ phép, HR có thể trả lời "duyệt", "từ chối vì hết quota", hoặc "thiếu chữ ký quản lý" — cả ba đều hợp lệ, nằm trong quy trình. Nhưng nếu hệ thống HR sập giữa lúc xử lý đơn, đó là hệ thống ngừng hoạt động, không còn là một câu trả lời trong quy trình nữa.

TrụcLỗi dự đoán được (kết quả nghiệp vụ)Sự cố ngoài dự kiến (hỏng hóc)
Nằm trong đặc tả?Có — người viết use case đã liệt kê raKhông — nếu liệt kê được thì đã có kế hoạch né
Tần suấtThường xuyên, là một phần vận hành bình thườngHiếm, bất thường
Ví dụHết hàng, quá hạn mức, không tìm thấy member, không đủ quyềnMất kết nối DB, NullPointerException do bug, timeout mạng
Công cụ đúngKiểu dữ liệu — sealed ResultException vẫn đúng — throw, để framework xử lý (advice, retry, alert)
💡 Cách nhớ

Nếu bạn viết ra được hết danh sách những gì có thể xảy ra ngay trong tài liệu đặc tả, đó là kết quả — không phải sự cố.

Trục này không có nghĩa "đừng bao giờ dùng exception" — mất kết nối DB, bug gây NullPointerException vẫn nên ném exception, để @RestControllerAdvice bắt chung một chỗ. Phần còn lại của bài chỉ nói về nửa bên trái của bảng trên.

3. Dựng Result bằng sealed interface

Bạn đã biết cú pháp sealed/permits, record và pattern matching cho switch từ bài Record — bài này không nhắc lại cú pháp, chỉ tập trung vào lúc nào và tại sao dùng chúng để model kết quả nghiệp vụ. Đây cũng là công cụ record ở bài 01, chỉ khác tầng: bài 01 dùng record làm DTO ở ranh giới HTTP, còn ở đây mỗi nhánh kết quả là một record đứng ở tầng service.

Từ đây, bài dùng một domain khác xuyên suốt: TaskFlow, module giao việc cho thành viên nhóm. Method assign(taskId, memberId) có 4 kết quả: giao thành công, không tìm thấy thành viên, vượt số task đang nhận đồng thời (quá hạn mức), hoặc không có quyền giao việc.

Tự làm trước khi xem đáp án

Trước khi đọc phần dưới, tự viết chữ ký sealed interface AssignResult với 4 nhánh trên. Với mỗi nhánh, tự quyết field nào nó cần mang — nhánh thành công cần dữ liệu gì khác nhánh "quá hạn mức"? Viết ra trước khi xem đáp án tham khảo.

Một cách dựng:

public sealed interface AssignResult
        permits AssignResult.Success,
                AssignResult.MemberNotFound,
                AssignResult.QuotaExceeded,
                AssignResult.Forbidden {

    record Success(Task task) implements AssignResult {}

    record MemberNotFound(String memberId) implements AssignResult {}

    record QuotaExceeded(String memberId, int currentCount, int maxAllowed) implements AssignResult {}

    record Forbidden(String memberId, String reason) implements AssignResult {}
}

Mỗi nhánh chỉ mang đúng field nó cần: Success cần Task vừa gán; QuotaExceeded cần đủ số liệu để dựng message ý nghĩa (currentCount/maxAllowed), không phải chuỗi văn bản build sẵn. Giữ dữ liệu có cấu trúc ở tầng service, để tầng trên tự quyết định cách hiển thị.

flowchart LR
    AR[AssignResult sealed interface] --> S[Success]
    AR --> MNF[MemberNotFound]
    AR --> QE[QuotaExceeded]
    AR --> FB[Forbidden]

Service dùng kiểu này thay vì exception:

@Service
public class TaskAssignmentService {

    public AssignResult assign(String taskId, String memberId) {
        Member member = memberRepository.findById(memberId);
        if (member == null) {
            return new AssignResult.MemberNotFound(memberId);
        }
        if (!member.hasPermission(Permission.ASSIGN_TASK)) {
            return new AssignResult.Forbidden(memberId, "khong co quyen giao viec");
        }
        int current = taskRepository.countAssignedTo(memberId);
        if (current >= member.maxConcurrentTasks()) {
            return new AssignResult.QuotaExceeded(memberId, current, member.maxConcurrentTasks());
        }
        Task task = taskRepository.assign(taskId, memberId);
        return new AssignResult.Success(task);
    }
}

Để ý: không có throw nào trong 4 nhánh trên — mọi kết quả, kể cả kết quả xấu, đều là return bình thường. Giữ ý này lại, vì đây là chỗ pitfall ở mục Pitfall bên dưới xảy ra.

4. Vì sao switch trên sealed type bắt lỗi ngay lúc build?

static String describe(AssignResult result) {
    return switch (result) {
        case AssignResult.Success s -> "Da giao task " + s.task().id();
        case AssignResult.MemberNotFound r -> "Khong tim thay thanh vien " + r.memberId();
        case AssignResult.QuotaExceeded r -> "Thanh vien " + r.memberId() + " da nhan "
                + r.currentCount() + "/" + r.maxAllowed() + " task";
        case AssignResult.Forbidden r -> "Tu choi: " + r.reason();
    };
}
Thử đoán

Nếu bạn thêm một nhánh mới vào AssignResult — ví dụ TaskAlreadyAssigned — nhưng quên sửa hàm describe ở trên, chuyện gì xảy ra khi build? Viết ra dự đoán của bạn trước khi đọc tiếp.

Build gãy, ngay tại đúng chỗ cần sửa:

error: the switch expression does not cover all possible input values
    return switch (result) {
                   ^

Với sealed interface cộng switch không có nhánh default, compiler biết đủ tập nhánh hợp lệ nhờ permits, nên chứng minh được switch đã phủ hết hay chưa — gọi là exhaustiveness checking. Thêm một nhánh Result mới mà quên chỗ nào xử lý nó, build gãy ngay lúc biên dịch, không phải ở production lúc 2 giờ sáng.

So với exception, không có cơ chế tương đương: thêm một throw new SomeNewException() ở bất kỳ đâu trong service, không caller nào bị buộc sửa gì — nó chỉ vỡ khi có request đi đúng qua nhánh đó.

5. Map sang HTTP ở controller

Tầng service không import gì thuộc spring-webAssignResult là kiểu dữ liệu thuần Java. Dịch từng nhánh sang mã trạng thái HTTP và ProblemDetail (đã học ở bài ProblemDetail) là việc của controller.

@RestController
@RequestMapping("/api/tasks")
class TaskAssignmentController {

    private final TaskAssignmentService taskAssignmentService;

    TaskAssignmentController(TaskAssignmentService taskAssignmentService) {
        this.taskAssignmentService = taskAssignmentService;
    }

    @PostMapping("/{taskId}/assign")
    ResponseEntity<Object> assign(@PathVariable String taskId, @RequestBody AssignRequest request) {
        AssignResult result = taskAssignmentService.assign(taskId, request.memberId());
        return switch (result) {
            case AssignResult.Success s -> ResponseEntity.ok(TaskResponse.from(s.task()));
            case AssignResult.MemberNotFound r -> problem(HttpStatus.NOT_FOUND,
                    "member-not-found", "Khong tim thay thanh vien " + r.memberId());
            case AssignResult.QuotaExceeded r -> problem(HttpStatus.CONFLICT, "quota-exceeded",
                    "Da nhan " + r.currentCount() + "/" + r.maxAllowed() + " task");
            case AssignResult.Forbidden r -> problem(HttpStatus.FORBIDDEN, "forbidden", r.reason());
        };
    }

    private ResponseEntity<Object> problem(HttpStatus status, String type, String detail) {
        var problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setType(URI.create("https://olhub.org/problems/" + type));
        return ResponseEntity.status(status).body(problem);
    }
}

Mỗi nhánh chọn type URI và status HTTP riêng — đúng tinh thần ProblemDetail đã học: type phân biệt loại lỗi bằng URI, không chỉ status code. AssignResult không biết HTTP tồn tại; controller không biết cách tính quota — nó chỉ đọc field record có sẵn rồi map sang định dạng đúng chuẩn.

6. Cân bằng — khi nào đừng dùng Result

Result không miễn phí. So với ném exception rồi để framework tự bắt, method trả AssignResult buộc mọi caller — kể cả test, kể cả service khác gọi vào — phải switch tường minh đủ 4 nhánh mỗi lần gọi.

Result cũng không hợp khi lỗi cần lan qua nhiều tầng gọi nhau. Exception tự bubble lên bao nhiêu tầng cũng được, không tầng nào cần biết tới khi có nơi catch. Result thì khác: nếu TaskAssignmentService gọi sang service khác cũng trả Result riêng, mọi tầng trung gian phải tự switch rồi map Result này sang Result kia — dễ thành chuỗi switch lồng nhau.

Chọn Result khi outcome nằm ở đúng một ranh giới rõ ràng (như service gọi trực tiếp bởi controller), tập kết quả nhỏ ổn định, và caller cần hành động khác nhau theo từng nhánh. Chọn exception khi lỗi hiếm hoặc phải lan qua nhiều tầng mà không tầng nào quan tâm chi tiết.

Pitfall thường gặp

Đây là chỗ nguy hiểm nhất khi đổi từ exception sang Result. Bài Transactional & proxy đã dạy: @Transactional chạy qua AOP proxy, và mặc định proxy chỉ rollback khi phương thức ném ra unchecked exception (RuntimeException/Error). Trả về một nhánh lỗi của Result là return bình thường — với proxy, đó là một lần gọi thành công.

@Transactional
public AssignResult assign(String taskId, String memberId) {
    Task task = taskRepository.lockForUpdate(taskId);
    task.markAssigning(memberId);              // WRITE 1
    taskRepository.save(task);                 // WRITE 1 flush xuong DB

    if (!hasPermission(memberId)) {
        return new AssignResult.Forbidden(memberId, "khong co quyen giao viec");
        // return binh thuong -> proxy COMMIT -> WRITE 1 van duoc luu
    }
    // ...
}

task giờ kẹt ở trạng thái "assigning" cho một lần gán chưa từng thực sự xảy ra vì người gọi không đủ quyền — nhưng transaction đã commit, không có exception nào để proxy rollback theo.

flowchart TB
    subgraph EXC[Duong Exception]
        E1[Service throw IllegalStateException] --> E2[Proxy bat unchecked exception]
        E2 --> E3[ROLLBACK tu dong]
        E3 --> E4[RestControllerAdvice] --> E5[ProblemDetail]
    end
    subgraph RES[Duong Result]
        R1[Service return Result.Failure] --> R2[Method return binh thuong]
        R2 --> R3[Proxy COMMIT neu khong xu ly them]
        R3 --> R4[Controller switch tren Result] --> R5[ProblemDetail]
    end
    EXC -. so sanh voi .-> RES

Cách xử lý ưu tiên: sắp lại thứ tự — validate trước khi ghi, giữ ranh giới transaction ở đúng nơi biết quyết định:

@Transactional
public AssignResult assign(String taskId, String memberId) {
    if (!hasPermission(memberId)) {
        return new AssignResult.Forbidden(memberId, "khong co quyen giao viec");
        // chua ghi gi ca - khong can rollback
    }
    Task task = taskRepository.lockForUpdate(taskId);
    task.markAssigning(memberId);
    taskRepository.save(task);
    // ...
    return new AssignResult.Success(task);
}

Khi write bắt buộc phải xảy ra trước (ví dụ cần lock pessimistic sớm để tránh hai request cùng gán một task), reorder không giải quyết được — thay vào đó chủ động gọi TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() ngay trước return new AssignResult.Forbidden(...). Reorder vẫn nên ưu tiên khi đổi được vì đó là cách rẻ nhất; setRollbackOnly chỉ cần khi write bắt buộc xảy ra trước, và lúc đó service, chứ không phải exception, là nơi quyết định commit hay rollback.

Đào sâu

📚 Đào sâu

Ghi chú: tài liệu Spring nêu đúng câu quyết định pitfall ở mục trên — transaction chỉ rollback tự động với runtime exception.

Liên hệ các bài khác

Tóm tắt

  • Lỗi nghiệp vụ dự đoán được là kết quả nằm trong đặc tả — model bằng sealed interface, mỗi nhánh một record mang đúng dữ liệu cần.
  • switch trên sealed type có exhaustiveness — compiler chặn ngay lúc build khi thêm nhánh Result mà quên xử lý, thứ exception không làm được.
  • Controller, không phải service, là nơi map từng nhánh Result sang ProblemDetail với status và type riêng.
  • @Transactional mặc định không rollback khi trả về nhánh lỗi của Result — đó là return bình thường; sửa bằng cách validate trước khi ghi, hoặc chủ động setRollbackOnly.
  • Result không miễn phí — chữ ký ồn hơn, không hợp khi lỗi phải lan qua nhiều tầng gọi nhau; dùng đúng ranh giới, đừng thay hết exception.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao IllegalStateException trong OrderService.placeOrder ở đầu bài là một "chữ ký nói dối"? Nêu đúng 3 vấn đề bài đã chỉ ra.

Ba vấn đề: (1) chữ ký void placeOrder(Order order) không nói gì về khả năng thất bại; (2) exception unchecked nên compiler không ép ai catch — quên xử lý vẫn biên dịch và deploy được; (3) hết hàng hay vượt hạn mức là kết quả nghiệp vụ bình thường, nằm trong đặc tả — dùng exception cho luồng xảy ra thường xuyên là sai công cụ.

Q2
Cho hai tình huống: (1) hết hàng khi đặt đơn, (2) kết nối tới database bị đứt giữa transaction. Tình huống nào nên là một nhánh của sealed Result, tình huống nào vẫn nên ném exception? Giải thích theo trục dự đoán được / ngoài dự kiến.

Hết hàng nên là một nhánh Result: nằm trong đặc tả, xảy ra thường xuyên, caller cần biết lý do để trả đúng mã lỗi. Mất kết nối DB vẫn nên ném exception: là sự cố hạ tầng ngoài dự kiến, để framework bắt chung qua advice thay vì bắt caller xử lý riêng từng lần.

Q3
Nếu bạn thêm nhánh mới AssignResult.TaskAlreadyAssigned nhưng quên sửa hàm describe ở mục 4, build có qua không? Vì sao chính exhaustiveness checking ngăn được lỗi mà cách dùng exception không ngăn được?

Build không qua — compiler báo lỗi tại dòng switch (result): switch expression không phủ hết mọi giá trị đầu vào. Vì AssignResult sealed với permits khai đủ các nhánh, compiler chứng minh được switch có thiếu hay không.

Với exception, không có cơ chế tương đương: thêm một throw mới ở bất kỳ đâu, không caller nào bị buộc sửa gì — lỗi chỉ lộ ra khi có request thật đi đúng qua nhánh đó, thường ở production chứ không phải lúc build.

Q4
Vì sao TaskAssignmentService không nên import bất kỳ class nào của spring-web (kể cả ProblemDetail)? Ai chịu trách nhiệm dịch AssignResult.QuotaExceeded thành mã trạng thái 409?

Service chỉ nên biết về nghiệp vụ, không biết giao thức truyền tải nó ra ngoài — AssignResult là kiểu dữ liệu Java thuần, tái dùng được cho kênh khác (message queue) mà không sửa gì ở service. Trách nhiệm dịch mỗi nhánh Result sang status HTTP và ProblemDetail thuộc về controller, đọc thẳng field record có sẵn.

Q5
Trong đoạn code pitfall ở mục Pitfall thường gặp, vì sao task vẫn bị lưu ở trạng thái "assigning" dù request bị từ chối vì thiếu quyền? Nêu một cách sửa.

Cơ chế lỗi: mặc định proxy @Transactional chỉ rollback khi method ném unchecked exception. Nhánh Forbidden trả về bằng return bình thường — với proxy đó là gọi thành công, nên transaction commit, kể cả write markAssigning đã chạy trước đó.

Cách sửa: kiểm tra quyền trước khi ghi, để nhánh Forbidden trả về lúc chưa có write nào cần rollback. Nếu write bắt buộc xảy ra trước, gọi chủ động setRollbackOnly() trước khi trả nhánh lỗi.

Bài tiếp theo: Mini-challenge — TaskFlow v4

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Đặt 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

Bài tiếp theo

Mini-challenge: TaskFlow v4 — hiện đại hoá tầng Java