Exception trong Java: checked, unchecked và cách thiết kế

1. Tổng quan

Exception là cơ chế Java dùng để báo rằng một việc không diễn ra như mong đợi. Nhiều dự án dùng exception không nhất quán. Có chỗ khai báo throws Exception tràn lan. Có chỗ nuốt lỗi trong một khối catch rỗng.

Trong bài này, bạn sẽ hiểu sự khác nhau giữa checked và unchecked exception. Bạn cũng sẽ biết khi nào nên dùng loại nào và cách thiết kế một hệ thống exception riêng cho ứng dụng. Cuối bài có phần kiểm thử hành vi ném lỗi bằng JUnit 5 và AssertJ.

2. Hệ thống phân cấp Throwable

Mọi thứ có thể ném ra bằng throw đều kế thừa từ java.lang.Throwable. Cây phân cấp rút gọn như sau:

Throwable
├── Error                      (unchecked) – lỗi nghiêm trọng của JVM
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception                  (checked)
    ├── IOException
    ├── SQLException
    └── RuntimeException       (unchecked)
        ├── IllegalArgumentException
        ├── IllegalStateException
        └── NullPointerException

Quy tắc phân loại rất đơn giản:

  • Checked exception là Exception và các lớp con của nó, trừ nhánh RuntimeException. Compiler bắt buộc bạn phải xử lý loại này.
  • Unchecked exception là RuntimeException, Error và các lớp con của chúng. Compiler không bắt buộc bạn xử lý.

Bạn gần như không bao giờ nên bắt Error. Khi JVM hết bộ nhớ, ứng dụng hiếm khi tự phục hồi được.

3. Checked exception

Checked exception là một phần của “hợp đồng” của method. Khi một method khai báo throws, nơi gọi phải chọn một trong hai cách. Cách thứ nhất là bắt lỗi bằng try/catch. Cách thứ hai là khai báo throws tiếp lên trên.

Xét một cổng thanh toán có thể từ chối giao dịch. Tình huống này hoàn toàn có thể xảy ra, và nơi gọi nên quyết định làm gì tiếp theo:

public class PaymentDeclinedException extends Exception {
    private final String reason;

    public PaymentDeclinedException(String reason) {
        super("Thanh toán bị từ chối: " + reason);
        this.reason = reason;
    }

    public String getReason() {
        return reason;
    }
}

public class PaymentGateway {
    public void charge(String cardNumber, long amount) throws PaymentDeclinedException {
        if (amount > 10_000_000) {
            throw new PaymentDeclinedException("vượt hạn mức");
        }
        System.out.println("Đã trừ " + amount + " từ thẻ " + cardNumber);
    }
}

PaymentDeclinedException kế thừa Exception, nên nó là checked exception. Nếu bạn gọi charge(...) mà không xử lý lỗi, compiler sẽ báo:

error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown

Nơi gọi buộc phải nghĩ tới trường hợp thất bại:

public class CheckoutService {
    private final PaymentGateway gateway = new PaymentGateway();

    public String checkout(String card, long amount) {
        try {
            gateway.charge(card, amount);
            return "SUCCESS";
        } catch (PaymentDeclinedException e) {
            System.out.println("Lý do: " + e.getReason());
            return "DECLINED";
        }
    }

    public static void main(String[] args) {
        var service = new CheckoutService();
        System.out.println(service.checkout("4111-xxxx", 50_000));
        System.out.println(service.checkout("4111-xxxx", 20_000_000));
    }
}

Đầu ra:

Đã trừ 50000 từ thẻ 4111-xxxx
SUCCESS
Lý do: vượt hạn mức
DECLINED

Ở đây lỗi được chuyển thành một trạng thái nghiệp vụ (DECLINED). Đây là kiểu tình huống checked exception phục vụ tốt nhất.

4. Unchecked exception

Unchecked exception thường báo hiệu lỗi lập trình hoặc vi phạm điều kiện tiên quyết. Ví dụ là truyền null, truyền số âm hoặc gọi method khi object đang ở sai trạng thái.

public class Order {
    private final long id;
    private int quantity;
    private boolean shipped;

    public Order(long id, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity phải > 0, nhận được: " + quantity);
        }
        this.id = id;
        this.quantity = quantity;
    }

    public void changeQuantity(int newQuantity) {
        if (shipped) {
            throw new IllegalStateException("Đơn " + id + " đã giao, không thể sửa số lượng");
        }
        this.quantity = newQuantity;
    }

    public void ship() {
        this.shipped = true;
    }
}

Hàm tạo ném IllegalArgumentException khi đầu vào không hợp lệ. Method changeQuantity ném IllegalStateException khi đơn hàng đã giao. Không ai nên “bắt rồi xử lý” các lỗi này ở từng nơi gọi. Cách đúng là sửa code gọi sai.

Bạn nên dùng lại các exception có sẵn của JDK trước khi tự tạo lớp mới:

Tình huống Exception nên dùng
Tham số không hợp lệ IllegalArgumentException
Object ở trạng thái sai IllegalStateException
Tham số bắt buộc bị null NullPointerException (qua Objects.requireNonNull)
Thao tác chưa hỗ trợ UnsupportedOperationException
Chỉ số ngoài phạm vi IndexOutOfBoundsException

5. Chọn checked hay unchecked?

Có một nguyên tắc thực dụng: hỏi xem nơi gọi có thể làm gì hữu ích với lỗi này không.

  • Nơi gọi có thể phục hồi một cách hợp lý, ví dụ thử lại, chọn phương án khác hoặc báo người dùng. Khi đó checked exception là lựa chọn chấp nhận được.
  • Lỗi do bug hoặc do hạ tầng sập mà nơi gọi không làm gì được. Khi đó hãy dùng unchecked exception.

Trong thực tế, các framework hiện đại như Spring và Hibernate gần như chỉ dùng unchecked exception. Ví dụ, Spring bọc SQLException thành DataAccessException. Lý do là checked exception có vài nhược điểm:

  1. Chúng lan dần lên mọi tầng: một throws ở tầng repository kéo theo throws ở service và controller.
  2. Chúng không tương thích với lambda và Stream API (xem mục 8).
  3. Chúng làm lộ chi tiết cài đặt. Interface OrderRepository không nên khai báo throws SQLException.

Lưu ý: Với ứng dụng Spring Boot, mặc định hãy dùng unchecked exception cho lỗi nghiệp vụ. Sau đó xử lý tập trung bằng @RestControllerAdvice. Chỉ dùng checked exception khi bạn thực sự muốn ép nơi gọi xử lý ngay tại chỗ.

6. Thiết kế exception riêng cho ứng dụng

6.1. Tạo một lớp gốc

Bạn nên tạo một exception gốc cho mỗi domain. Cách này giúp bạn bắt toàn bộ lỗi của domain bằng một khối catch khi cần:

public class OrderException extends RuntimeException {
    public OrderException(String message) {
        super(message);
    }

    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

Lớp này phải luôn có constructor nhận cause. Nhờ đó, lỗi gốc không bị mất khi bạn bọc nó lại.

6.2. Lớp con mang ngữ cảnh

Một exception tốt cần trả lời được câu hỏi “chuyện gì xảy ra, với dữ liệu nào?”. Đừng chỉ ghi "Not found":

public class OrderNotFoundException extends OrderException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("Không tìm thấy đơn hàng id=" + orderId);
        this.orderId = orderId;
    }

    public long getOrderId() {
        return orderId;
    }
}

Trường orderId được lưu lại để các tầng trên dùng tiếp. Ví dụ, controller advice có thể đọc nó để trả về JSON lỗi cho client mà không phải phân tích chuỗi message.

6.3. Bọc lỗi tầng dưới (exception translation)

Khi tầng service gọi tới hạ tầng, bạn nên dịch lỗi kỹ thuật thành lỗi của domain và giữ lại cause:

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class OrderExportService {

    public void export(long orderId, Path target) {
        try {
            Files.writeString(target, "order=" + orderId);
        } catch (IOException e) {
            throw new OrderException("Xuất đơn " + orderId + " ra " + target + " thất bại", e);
        }
    }
}

Tham số thứ hai e là cause. Khi in stack trace, bạn sẽ thấy cả hai tầng:

OrderException: Xuất đơn 42 ra /readonly/order.txt thất bại
    at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
    ...

Nếu bạn viết throw new OrderException(e.getMessage()) mà không truyền e, phần Caused by sẽ biến mất. Việc debug lúc đó trở nên rất khó.

7. Các anti-pattern hay gặp

Nuốt exception. Đây là lỗi nguy hiểm nhất, vì ứng dụng chạy tiếp như không có gì xảy ra:

try {
    gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
    // TODO
}

Nếu thực sự muốn bỏ qua một lỗi, bạn hãy ghi rõ lý do trong comment. Tối thiểu cũng phải log lỗi đó.

Vừa log vừa ném lại. Viết log.error(...) rồi throw e; khiến cùng một lỗi xuất hiện nhiều lần trong log. Bạn chỉ nên chọn một: xử lý (và log) tại chỗ, hoặc ném lên cho tầng trên xử lý.

Bắt Exception hoặc Throwable quá rộng. Làm vậy, bạn vô tình bắt luôn cả NullPointerException và các bug khác. Hãy bắt đúng loại bạn có thể xử lý. Ngoại lệ hợp lý duy nhất là ở biên ngoài cùng của ứng dụng, ví dụ global handler.

Dùng exception để điều khiển luồng. Tạo exception tốn chi phí vì phải thu thập stack trace. Code kiểu này cũng khó đọc:

// Không nên
try {
    return Integer.parseInt(input);
} catch (NumberFormatException e) {
    return 0;
}

Với dữ liệu bạn dự kiến sẽ thường xuyên sai, hãy kiểm tra trước. Bạn cũng có thể trả về Optional. Exception nên dành cho tình huống bất thường.

Khai báo throws Exception. Cách này xóa sạch thông tin về loại lỗi có thể xảy ra. Nó còn buộc mọi nơi gọi phải bắt một kiểu rất rộng.

8. Checked exception trong lambda

Interface Function không khai báo throws, vì vậy đoạn sau sẽ không biên dịch được:

List<String> contents = paths.stream()
        .map(Files::readString) // lỗi: unreported exception IOException
        .toList();

Cách xử lý phổ biến là bọc lỗi thành UncheckedIOException. Lớp này có sẵn trong JDK cho đúng mục đích này:

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class OrderFileReader {

    public List<String> readAll(List<Path> paths) {
        return paths.stream()
                .map(OrderFileReader::readQuietly)
                .toList();
    }

    private static String readQuietly(Path path) {
        try {
            return Files.readString(path);
        } catch (IOException e) {
            throw new UncheckedIOException("Không đọc được " + path, e);
        }
    }
}

Method readQuietly cô lập khối try/catch khỏi pipeline, nên stream vẫn gọn. Nơi gọi readAll có thể bắt UncheckedIOException và lấy IOException gốc qua getCause().

9. try-with-resources và suppressed exception

Khi làm việc với tài nguyên cần đóng, bạn hãy dùng try-with-resources. Có một điểm ít người để ý: nếu cả thân try lẫn close() đều ném lỗi, lỗi ở close() không ghi đè lỗi chính. Nó được gắn vào lỗi chính dưới dạng suppressed:

public class SuppressedDemo {

    static class ReportConnection implements AutoCloseable {
        void query() {
            throw new IllegalStateException("Query lỗi");
        }

        @Override
        public void close() {
            throw new IllegalStateException("Close lỗi");
        }
    }

    public static void main(String[] args) {
        try (var connection = new ReportConnection()) {
            connection.query();
        } catch (IllegalStateException e) {
            System.out.println("Chính: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                System.out.println("Suppressed: " + s.getMessage());
            }
        }
    }
}

Đầu ra:

Chính: Query lỗi
Suppressed: Close lỗi

Nếu bạn tự viết try/finally và gọi close() trong finally, lỗi “Close lỗi” sẽ đè lên “Query lỗi”. Khi đó bạn mất nguyên nhân thật.

10. Kiểm thử hành vi ném exception

Nếu dùng Spring Boot, spring-boot-starter-test đã có sẵn JUnit 5 và AssertJ. Với dự án Java thuần, bạn thêm dependency sau:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

Bạn nên kiểm tra cả loại exception, message, dữ liệu ngữ cảnh và cause:

import org.junit.jupiter.api.Test;

import java.nio.file.Path;

import static org.assertj.core.api.Assertions.*;

class OrderExceptionTest {

    @Test
    void whenQuantityIsZero_thenThrowIllegalArgument() {
        assertThatThrownBy(() -> new Order(1L, 0))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("quantity phải > 0");
    }

    @Test
    void whenOrderShipped_thenCannotChangeQuantity() {
        var order = new Order(7L, 2);
        order.ship();

        assertThatIllegalStateException()
                .isThrownBy(() -> order.changeQuantity(5))
                .withMessage("Đơn 7 đã giao, không thể sửa số lượng");
    }

    @Test
    void notFoundException_carriesOrderId() {
        assertThatThrownBy(() -> { throw new OrderNotFoundException(42L); })
                .isInstanceOf(OrderException.class)
                .hasFieldOrPropertyWithValue("orderId", 42L);
    }

    @Test
    void whenPaymentOverLimit_thenThrowCheckedException() {
        var gateway = new PaymentGateway();

        assertThatExceptionOfType(PaymentDeclinedException.class)
                .isThrownBy(() -> gateway.charge("4111-xxxx", 20_000_000))
                .satisfies(e -> assertThat(e.getReason()).isEqualTo("vượt hạn mức"));
    }

    @Test
    void whenExportFails_thenCauseIsPreserved() {
        var service = new OrderExportService();
        Path invalid = Path.of("/thu-muc-khong-ton-tai/order.txt");

        assertThatThrownBy(() -> service.export(42L, invalid))
                .isInstanceOf(OrderException.class)
                .hasMessageContaining("Xuất đơn 42")
                .hasCauseInstanceOf(java.io.IOException.class);
    }
}

Một vài điểm cần chú ý:

  • assertThatThrownBy nhận một lambda có thể ném bất kỳ Throwable nào, kể cả checked exception. Vì vậy bạn không cần try/catch trong test.
  • hasFieldOrPropertyWithValue đọc getter getOrderId(). Nhờ đó bạn chắc chắn ngữ cảnh được truyền đúng.
  • hasCauseInstanceOf khẳng định cause không bị đánh rơi khi bọc lỗi. Lỗi bỏ quên cause rất dễ lọt qua nếu thiếu test này. Trong test, thư mục cha không tồn tại nên Files.writeString ném NoSuchFileException, một lớp con của IOException.

11. Kết luận

Trong bài này, bạn đã thấy checked exception dành cho tình huống nơi gọi có thể phục hồi. Unchecked exception dành cho lỗi lập trình, vi phạm điều kiện hoặc lỗi hạ tầng mà nơi gọi không làm gì được. Với ứng dụng hiện đại, đặc biệt là Spring Boot, unchecked exception là lựa chọn mặc định hợp lý.

Khi thiết kế exception riêng, bạn hãy làm theo các nguyên tắc sau:

  • Tạo một lớp gốc cho từng domain.
  • Đưa dữ liệu ngữ cảnh vào lớp con.
  • Luôn giữ cause khi dịch lỗi giữa các tầng.

Cuối cùng, bạn nên tránh các anti-pattern như nuốt lỗi, vừa log vừa ném lại, hay bắt Exception quá rộng. Đồng thời, hãy viết test để khóa chặt hành vi ném lỗi của code.

javaexceptionclean-codejunit5