Exception: xử lý lỗi thanh toán mà không nuốt mất lỗi

5 câu hỏi~25 phútTừ khóa: checked, unchecked, Error, try-with-resources, custom exception, cause, @ControllerAdvice, rollback, stack trace

Tiếp đi: Exception trong Java.

Trong shop-backend, PaymentService gọi cổng thanh toán, đọc file đối soát, lưu Order. Chỗ nào cũng có thể lỗi. Bài này trả lời: lỗi nào nên ném, ném loại gì, bắt ở đâu.

1. Bài toán: catch xong rồi… thôi

public void pay(Order order) {
    try {
        gateway.charge(order.getId(), order.getTotal());
        order.markPaid();
    } catch (Exception e) {
        e.printStackTrace();          // in ra console rồi đi tiếp
    }
    orderRepo.save(order);            // đơn vẫn được lưu như chưa có gì xảy ra
}

2. Vấn đề

3. Giải pháp: hiểu cây exception rồi chọn đúng loại

                Throwable
               /         \
           Error        Exception
   (OutOfMemoryError,   /        \
    StackOverflowError) IOException  RuntimeException
    → không bắt         SQLException  (NullPointer, IllegalArgument,
                        → CHECKED      IllegalState, ...)
                                       → UNCHECKED

Spring và hầu hết code hiện đại dùng unchecked cho lỗi nghiệp vụ, rồi xử lý tập trung ở một chỗ.

4. Code từng bước

Bước 1: Custom business exception

public abstract class BusinessException extends RuntimeException {
    private final String code;
    protected BusinessException(String code, String message) {
        super(message);
        this.code = code;
    }
    protected BusinessException(String code, String message, Throwable cause) {
        super(message, cause);                 // giữ lỗi gốc
        this.code = code;
    }
    public String getCode() { return code; }
}

public class InsufficientBalanceException extends BusinessException {
    public InsufficientBalanceException(String orderId) {
        super("PAYMENT_INSUFFICIENT", "Not enough balance for order " + orderId);
    }
}

public class PaymentGatewayException extends BusinessException {
    public PaymentGatewayException(String orderId, Throwable cause) {
        super("PAYMENT_GATEWAY_ERROR", "Gateway failed for order " + orderId, cause);
    }
}

Bước 2: Wrap lỗi kỹ thuật, giữ cause

public void pay(Order order) {
    try {
        gateway.charge(order.getId(), order.getTotal());   // ném IOException
    } catch (IOException e) {
        throw new PaymentGatewayException(order.getId(), e);   // ✅ có cause
        // ❌ throw new PaymentGatewayException(order.getId(), null);  mất stack trace gốc
    }
    order.markPaid();
    orderRepo.save(order);
}

Bước 3: try-with-resources khi đọc file đối soát

public List<String> readSettlement(Path file) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(file)) {   // tự close()
        return reader.lines().toList();
    }
}

Mọi class implement AutoCloseable đều dùng được. close() chạy cả khi có exception; nếu close() cũng lỗi thì lỗi đó được gắn vào getSuppressed() của lỗi chính, không đè mất.

Bước 4: Xử lý tập trung bằng @RestControllerAdvice

@RestControllerAdvice
public class ApiExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler(InsufficientBalanceException.class)
    ProblemDetail balance(InsufficientBalanceException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
        pd.setProperty("code", e.getCode());
        return pd;
    }

    @ExceptionHandler(PaymentGatewayException.class)
    ProblemDetail gateway(PaymentGatewayException e) {
        log.error("Gateway error", e);                       // log cả stack trace + cause
        return ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY);
    }
}
curl -X POST localhost:8080/orders/42/pay
# {"status":422,"detail":"Not enough balance for order 42","code":"PAYMENT_INSUFFICIENT"}

5. Cách hoạt động

Controller → OrderService → PaymentService → gateway.charge()
                                                 │ IOException
                                  wrap ←─────────┘
                       PaymentGatewayException(cause=IOException)
   ↑ bay ngược lên call stack, không ai catch
DispatcherServlet → @RestControllerAdvice → 502 + log đầy đủ

6. So sánh

CheckedUnchecked
Kế thừaExceptionRuntimeException
Compiler bắt xử lýCóKhông
Ví dụIOException, SQLExceptionNPE, IllegalArgumentException
Dùng khiCaller thật sự phục hồi được ngayLỗi nghiệp vụ, bug, lỗi xử lý tập trung
Với lambda/streamKhó dùng (phải bọc)Dùng thoải mái

7. Khi nào nên dùng / không nên dùng

Mình để @Transactional trên pay(), ném IOException mà đơn vẫn được lưu vào DB. Sao không rollback?

Đúng rồi, đây là hành vi mặc định của Spring chứ không phải bug: chỉ rollback với unchecked exception và Error. Checked exception thì commit.

@Transactional
public void pay(Order order) throws IOException {
    orderRepo.save(order);
    gateway.charge(...);            // ném IOException → vẫn COMMIT
}

@Transactional(rollbackFor = Exception.class)     // ✅ cách 1
public void pay(Order order) throws IOException { ... }

// ✅ cách 2 (hay dùng hơn): wrap thành BusinessException (unchecked) như ở trên

Lý do lịch sử: Spring coi checked là "lỗi dự kiến, caller xử lý được", giống quy ước EJB. Một bẫy khác: catch exception bên trong method rồi không ném lại thì proxy không thấy lỗi, cũng commit. Chi tiết về rollbackFor, noRollbackFor và propagation xem Propagation & rollback.

Ném exception có chậm không? Mình thấy có người bảo trả về null hoặc mã lỗi cho nhanh.

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: phần tốn kém chủ yếu là tạo exception (gọi fillInStackTrace() để chụp call stack), không phải throw/catch. Call stack càng sâu (Spring thường 100+ frame) càng tốn.

new RuntimeException()   → chụp stack trace: vài micro giây, sâu thì hơn
throw / catch            → rẻ
lỗi hiếm (1/1000 request) → không đáng kể so với 1 query DB (ms)

Hai câu hỏi nhỏ:

  1. finally có luôn chạy không?
  2. Trong finally mà return thì chuyện gì xảy ra?

Đúng là finally gần như luôn chạy: sau return, sau exception, sau break. Ngoại lệ: System.exit(), JVM crash, thread bị kill.

int f() {
    try { throw new IllegalStateException("payment failed"); }
    finally { return 1; }      // ❌ exception BIẾN MẤT, f() trả về 1
}

return (hoặc throw) trong finally đè lên kết quả của try, kể cả nuốt exception. Quy tắc: finally chỉ để dọn dẹp, không return. Với resource thì dùng try-with-resources thay cho finally { close(); }.

Phỏng vấn hỏi exception handling trong Java thì trả lời sao?

Câu 1: Checked và unchecked exception khác nhau gì?

🇻🇳 Tiếng Việt:

Checked kế thừa Exception, compiler bắt phải catch hoặc khai báo throws, dùng cho lỗi bên ngoài có thể phục hồi như IOException. Unchecked kế thừa RuntimeException, không bắt buộc xử lý, thường là bug hoặc vi phạm nghiệp vụ. Trong dự án Spring em dùng unchecked custom exception cho lỗi nghiệp vụ và xử lý tập trung ở @RestControllerAdvice.

🇬🇧 English:

Checked exceptions extend Exception and the compiler forces you to catch or declare them; they fit recoverable external failures like IOException. Unchecked ones extend RuntimeException and are not enforced; they usually mean bugs or business rule violations. In Spring projects I use unchecked custom exceptions for business errors and handle them centrally in a @RestControllerAdvice.

Câu 2: Best practice khi xử lý exception?

🇻🇳 Tiếng Việt:

Không nuốt lỗi; catch ở nơi thật sự xử lý được; khi wrap thì luôn truyền cause; dùng try-with-resources cho resource; không return trong finally; log một lần ở tầng biên; không dùng exception cho luồng bình thường vì tạo stack trace tốn kém.

🇬🇧 English:

Never swallow exceptions; catch only where you can actually handle them; always pass the cause when wrapping; use try-with-resources; never return from finally; log once at the boundary; and don't use exceptions for normal control flow because capturing the stack trace is expensive.

Câu 3: @Transactional rollback với exception nào?

🇻🇳 Tiếng Việt:

Mặc định chỉ rollback với RuntimeException và Error; checked exception sẽ commit. Muốn khác thì dùng rollbackFor. Nếu catch exception bên trong method mà không ném lại thì proxy không biết, transaction cũng commit.

🇬🇧 English:

By default it rolls back only on RuntimeException and Error; checked exceptions commit. Use rollbackFor to change that. If you catch the exception inside the method and don't rethrow, the proxy never sees it and the transaction commits.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao catch (Exception e) { e.printStackTrace(); } là xấu?

Lỗi bị nuốt, caller tưởng thành công; bắt lẫn cả bug như NPE; log ra stderr không vào hệ thống log.

Wrap exception mà không truyền cause thì mất gì?

Mất stack trace và message của lỗi gốc, debug production chỉ thấy lỗi bọc ngoài.

try-with-resources xử lý lỗi trong close() thế nào?

Nếu thân try đã lỗi, lỗi của close() được gắn vào getSuppressed() của lỗi chính chứ không đè mất.

Method @Transactional ném IOException có rollback không?

Không, checked exception mặc định commit. Cần rollbackFor hoặc wrap sang unchecked.

Phần nào của exception tốn kém nhất?

Tạo exception, cụ thể là fillInStackTrace() chụp call stack.