บทนำ: เหตุใดต้อง “ลำดับชั้น” ของ Exceptions?
ในบทความที่แล้ว เราเรียนรู้ try-catch-finally และ custom exceptions แบบพื้นฐาน แต่ในโลก production code ที่มีความซับซ้อน เราต้องเข้าใจ:
- ทำไม Java ออกแบบ exception เป็นลำดับชั้น?
- ปัญหาอะไรที่เกิดถ้าจัดการ exception ผิด?
- Best practices อะไรที่ professionals ใช้?
- เมื่อไหร่ต้องสร้าง custom exception เอง?
Exception Hierarchy & Best Practice = การออกแบบ exception handling ให้ ปลอดภัย, ชัดเจน, และ maintainable
Exception Hierarchy ใน Java: เข้าใจลึกขึ้น
ลำดับชั้นแบบคร่าว
textThrowable (superclass ของ exceptions ทั้งหมด)
├─ Error (ร้ายแรง, ไม่ควร catch)
│ ├─ OutOfMemoryError
│ ├─ StackOverflowError
│ ├─ VirtualMachineError
│ └─ ...
│
└─ Exception (ปกติ, ควร catch/handle)
├─ Checked Exception (บังคับต้อง handle)
│ ├─ IOException
│ │ ├─ FileNotFoundException
│ │ ├─ SocketException
│ │ └─ ...
│ ├─ SQLException
│ ├─ ClassNotFoundException
│ └─ ...
│
└─ Unchecked Exception (ไม่บังคับแต่ควร)
├─ RuntimeException
│ ├─ NullPointerException
│ ├─ ArrayIndexOutOfBoundsException
│ ├─ ArithmeticException
│ ├─ IllegalArgumentException
│ ├─ ClassCastException
│ └─ ...
└─ ...
ความแตกต่างที่สำคัญ
| Checked | Unchecked | |
|---|---|---|
| Inheritance | extends Exception | extends RuntimeException |
| Compile Check | Compiler บังคับต้อง handle | Compiler ไม่บังคับ |
| Runtime | I/O, Database, Network | Logic errors, bad data |
| ตัวอย่าง | IOException, SQLException | NullPointerException, IllegalArgumentException |
| ควรเมื่อ | Recoverable situation | Programming error |
ตัวอย่างที่ 1: Exception Hierarchy ในการใช้งาน
❌ ไม่ดี: เข้าใจผิดเกี่ยวกับ Exception
java// ❌ MISTAKE 1: Catching general Exception
try {
// complex operation
int result = calculateTotal(items);
saveToDatabase(result);
} catch (Exception e) { // ← Too broad!
System.out.println("Error: " + e.getMessage());
}
// ปัญหา: ถ้า NullPointerException เกิด มันจะถูก catch
// ซึ่งอาจเป็น bug ที่ไม่ควรถูก suppress
// ❌ MISTAKE 2: Catching and ignoring
try {
connectToDatabase();
} catch (SQLException e) {
// ← empty! ไม่ทำอะไรเลย
}
// ปัญหา: Database connection ล้มเหลว แต่ code ยังทำงานต่อ
// เหมือน "ปิดหู" ละเลยปัญหา
// ❌ MISTAKE 3: Catching Checked Exception ไม่จำเป็น
try {
String data = "12345";
int number = Integer.parseInt(data); // ← Unchecked exception
} catch (NumberFormatException e) { // ← ไม่จำเป็น
System.out.println("Invalid number");
}
// ไม่ผิด แต่ Unchecked exceptions มักมาจาก bugs
// ดีกว่า validate ข้อมูลก่อนแทน
// ❌ MISTAKE 4: Creating exception ใหม่โดยไม่ต้อง
public void processOrder() throws Exception { // ← Generic
// ← ไม่รู้ว่า Exception ไหน
}
// ปัญหา: Caller ไม่รู้ว่าต้อง handle อะไร
✓ ดี: Best Practices
java// ✓ PRACTICE 1: Catch specific exceptions
try {
saveToDatabase(data);
} catch (SQLException e) {
// Handle database error specifically
System.err.println("Database error: " + e.getMessage());
notifyDatabaseTeam(e);
} catch (IOException e) {
// Handle I/O error
System.err.println("File I/O error: " + e.getMessage());
retryOperation();
}
// ดี: แต่ละ exception ได้ handling ที่เหมาะสม
// ✓ PRACTICE 2: Let exceptions propagate if not recoverable
public void criticalOperation() throws SQLException {
// If we can't handle it, let caller handle
// ← อย่า catch ถ้าไม่มี recovery strategy
executeSQL("DELETE FROM users");
}
// ✓ PRACTICE 3: Validate data to prevent unchecked exceptions
String input = getUserInput();
if (input == null || input.isEmpty()) {
throw new IllegalArgumentException("Input cannot be empty");
}
// ดีกว่า รอ NullPointerException เกิดแล้ว catch
// ✓ PRACTICE 4: Throw specific custom exceptions
public void createUser(String email) throws InvalidEmailException {
if (!isValidEmail(email)) {
throw new InvalidEmailException(
"Email format invalid: " + email
);
}
// ← Caller รู้ว่า method นี้อาจ throw InvalidEmailException
}
// ✓ PRACTICE 5: Preserve the cause chain
try {
connectToDatabase();
} catch (SQLException e) {
// Don't lose original exception!
throw new DataAccessException(
"Failed to access database",
e // ← chain the cause
);
}
ตัวอย่างที่ 2: Designing Custom Exception Hierarchy
ปัญหา: เมื่อไหร่ต้องสร้าง Custom Exception?
textใช้ custom exception เมื่อ:
1. Exception นี้เป็น business logic (ไม่ใช่ technical)
2. Caller ต้องรู้ว่าข้อผิดพลาด business นี้เกิดขึ้น
3. ต้องมี specific handling สำหรับสถานการณ์นี้
ไม่ต้อง custom exception เมื่อ:
- อาจใช้ built-in exception ได้
- Exception นี้ไม่ได้เป็น business requirement
ตัวอย่าง: Banking System Exception Hierarchy
java// ==== Root Custom Exception ====
public abstract class BankingException extends Exception {
private String errorCode;
public BankingException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
// ==== Checked Custom Exceptions (Business errors) ====
// 1. Account-related errors
public class AccountNotFoundException extends BankingException {
public AccountNotFoundException(String accountNumber) {
super(
"Account not found: " + accountNumber,
"ACCOUNT_NOT_FOUND"
);
}
}
public class AccountFrozenException extends BankingException {
public AccountFrozenException(String reason) {
super(
"Account is frozen: " + reason,
"ACCOUNT_FROZEN"
);
}
}
// 2. Transaction-related errors
public class InsufficientBalanceException extends BankingException {
private double requested;
private double available;
public InsufficientBalanceException(
double requested,
double available
) {
super(
String.format(
"Insufficient balance. Requested: %.2f, Available: %.2f",
requested, available
),
"INSUFFICIENT_BALANCE"
);
this.requested = requested;
this.available = available;
}
public double getShortage() {
return requested - available;
}
}
public class DailyLimitExceededException extends BankingException {
public DailyLimitExceededException(double limit, double attempted) {
super(
String.format(
"Daily limit exceeded. Limit: %.2f, Attempted: %.2f",
limit, attempted
),
"DAILY_LIMIT_EXCEEDED"
);
}
}
// 3. Security-related errors
public class InvalidPINException extends BankingException {
private int attemptsLeft;
public InvalidPINException(int attemptsLeft) {
super(
String.format(
"Invalid PIN. Attempts left: %d",
attemptsLeft
),
"INVALID_PIN"
);
this.attemptsLeft = attemptsLeft;
}
public int getAttemptsLeft() {
return attemptsLeft;
}
}
// ==== Unchecked Custom Exceptions (Programming errors) ====
public class InvalidTransactionException extends RuntimeException {
public InvalidTransactionException(String reason) {
super("Invalid transaction: " + reason);
}
}
public class InvalidAccountStateException extends RuntimeException {
public InvalidAccountStateException(String currentState) {
super("Invalid account state: " + currentState);
}
}
การใช้ Exception Hierarchy
javapublic class BankAccount {
private double balance;
private boolean frozen;
private double dailyLimit;
private double dailyWithdrawn;
// ==== Checked Exception (must be caught) ====
public void withdraw(double amount)
throws AccountFrozenException,
InsufficientBalanceException,
DailyLimitExceededException {
// Business validation
if (frozen) {
throw new AccountFrozenException("Suspect fraud");
}
if (amount > balance) {
throw new InsufficientBalanceException(amount, balance);
}
if (dailyWithdrawn + amount > dailyLimit) {
throw new DailyLimitExceededException(
dailyLimit,
dailyWithdrawn + amount
);
}
// Safe to proceed
balance -= amount;
dailyWithdrawn += amount;
}
// ==== Unchecked Exception (programming error) ====
public void setDailyLimit(double limit) {
if (limit < 0) {
throw new InvalidTransactionException(
"Limit cannot be negative"
);
}
this.dailyLimit = limit;
}
}
// ==== Client Code: Specific Handling ====
public class ATMService {
public void processWithdrawal(String accountNumber, double amount) {
try {
BankAccount account = getAccount(accountNumber);
account.withdraw(amount);
System.out.println("✓ Withdrawal successful");
} catch (AccountFrozenException e) {
// ← Business logic: Account is frozen
System.err.println("❌ " + e.getMessage());
contactFraudTeam(accountNumber);
} catch (InsufficientBalanceException e) {
// ← Business logic: Not enough money
System.err.println("❌ " + e.getMessage());
System.out.println(" Shortage: $" + e.getShortage());
offerLoanOption();
} catch (DailyLimitExceededException e) {
// ← Business logic: Daily limit exceeded
System.err.println("❌ " + e.getMessage());
System.out.println(" Try again tomorrow");
} catch (BankingException e) {
// ← Catch-all for any other banking exception
System.err.println("❌ Banking error: " + e.getErrorCode());
System.err.println(" " + e.getMessage());
notifyBankingTeam(e);
} catch (RuntimeException e) {
// ← Programming error (shouldn't happen)
System.err.println("❌ CRITICAL ERROR: " + e.getMessage());
e.printStackTrace();
shutdownATM();
}
}
}
ตัวอย่างที่ 3: Exception Translation & Wrapping
ปัญหา: Multiple Layers ของ Exception
textLayer 1: Database Layer
└─ throws SQLException
Layer 2: Business Logic Layer
└─ should throw business exception
Layer 3: API Layer
└─ should return appropriate HTTP status
ปัญหา: SQLException ขึ้นมา ไปถึง API client
แต่ client ไม่รู้ว่าจะทำอะไร
วิธีแก้: Exception Translation
java// ==== Layer 1: Database Layer ====
public class UserRepository {
public User getUserById(int id) throws SQLException {
// Direct database operation
// Throws SQLException if database fails
String sql = "SELECT * FROM users WHERE id = ?";
// ... execute query
// SQLException อาจเกิด here
}
}
// ==== Layer 2: Business Logic Layer ====
public class UserService {
private UserRepository userRepository;
// Translate SQLException → Business Exception
public User getUser(int id) throws UserNotFoundException {
try {
User user = userRepository.getUserById(id);
if (user == null) {
throw new UserNotFoundException(
"User with ID " + id + " not found"
);
}
return user;
} catch (SQLException e) {
// ← Translate technical exception to business exception
throw new UserNotFoundException(
"Failed to retrieve user",
e // ← preserve the cause
);
}
}
}
// ==== Layer 3: API Layer ====
@RestController
public class UserAPI {
private UserService userService;
@GetMapping("/users/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable int id) {
try {
User user = userService.getUser(id);
return ResponseEntity.ok(mapToDTO(user));
} catch (UserNotFoundException e) {
// ← Handle business exception, return 404
return ResponseEntity.notFound().build();
} catch (Exception e) {
// ← Unexpected error, return 500
System.err.println("Unexpected error: " + e.getMessage());
return ResponseEntity.status(500).build();
}
}
}
ตัวอย่างที่ 4: Common Patterns & Anti-Patterns
Pattern 1: Fail-Fast Validation
java// ✓ GOOD: Validate early and throw specific exceptions
public class OrderService {
public Order placeOrder(OrderRequest request)
throws InvalidOrderException {
// Validate input immediately
if (request == null) {
throw new IllegalArgumentException("OrderRequest cannot be null");
}
if (request.getItems().isEmpty()) {
throw new InvalidOrderException("Order must have at least one item");
}
if (request.getCustomerId() <= 0) {
throw new InvalidOrderException("Invalid customer ID");
}
if (request.getTotalAmount() < 0) {
throw new InvalidOrderException("Total amount cannot be negative");
}
// Now we know input is valid, proceed
return createOrder(request);
}
}
// ❌ BAD: Trying to catch NullPointerException instead of validating
public class OrderService {
public Order placeOrder(OrderRequest request) {
try {
request.getItems().stream() // ← NPE here if request is null
.forEach(item -> process(item));
return new Order();
} catch (NullPointerException e) {
// ← This is bad practice!
return null;
}
}
}
Pattern 2: Wrapping with Context
java// ✓ GOOD: Add context to exception
public void processFile(String filename) throws FileProcessingException {
try {
File file = new File(filename);
if (!file.exists()) {
throw new FileNotFoundException(filename);
}
readAndProcess(file);
} catch (FileNotFoundException e) {
throw new FileProcessingException(
"Could not find file: " + filename,
e
);
} catch (IOException e) {
throw new FileProcessingException(
"Error processing file: " + filename,
e
);
}
}
// ❌ BAD: Lose the original exception context
public void processFile(String filename) throws FileProcessingException {
try {
readAndProcess(new File(filename));
} catch (Exception e) {
throw new FileProcessingException("File processing failed");
// ← Original exception lost! ไม่รู้ว่ามาจากไหน
}
}
Pattern 3: Resource Management
java// ✓ GOOD: Try-with-resources (auto-close)
public void readConfiguration(String filename)
throws IOException {
try (FileReader reader = new FileReader(filename)) {
// reader will be auto-closed
String config = readContent(reader);
parseConfiguration(config);
}
// reader auto-closed here, even if exception occurs
}
// ❌ BAD: Manual resource management (easy to forget)
public void readConfiguration(String filename)
throws IOException {
FileReader reader = new FileReader(filename);
try {
String config = readContent(reader);
parseConfiguration(config);
} finally {
reader.close(); // ← Easy to forget or mess up
}
}
ตัวอย่างที่ 5: Real-World Scenario
E-Commerce Order Processing with Exception Hierarchy
java// ==== Custom Exception Hierarchy ====
public abstract class OrderException extends Exception {
public OrderException(String message) {
super(message);
}
public OrderException(String message, Throwable cause) {
super(message, cause);
}
}
public class InsufficientInventoryException extends OrderException {
private String productId;
private int requested;
private int available;
public InsufficientInventoryException(
String productId, int requested, int available
) {
super(
String.format(
"Product %s: requested %d, available %d",
productId, requested, available
)
);
this.productId = productId;
this.requested = requested;
this.available = available;
}
}
public class PaymentFailedException extends OrderException {
public enum Reason {
CARD_DECLINED("Card was declined"),
INSUFFICIENT_FUNDS("Insufficient funds"),
TIMEOUT("Payment gateway timeout"),
FRAUD_DETECTED("Fraud detected");
public final String description;
Reason(String description) {
this.description = description;
}
}
private Reason reason;
public PaymentFailedException(Reason reason) {
super("Payment failed: " + reason.description);
this.reason = reason;
}
}
// ==== Service Layer ====
public class OrderService {
private InventoryService inventoryService;
private PaymentService paymentService;
public Order createOrder(OrderRequest request)
throws InsufficientInventoryException,
PaymentFailedException {
// Step 1: Validate and reserve inventory
for (OrderItem item : request.getItems()) {
try {
inventoryService.reserve(
item.getProductId(),
item.getQuantity()
);
} catch (InventoryException e) {
// Translate to business exception
throw new InsufficientInventoryException(
item.getProductId(),
item.getQuantity(),
e.getAvailable()
);
}
}
// Step 2: Process payment
try {
paymentService.charge(
request.getPaymentMethod(),
request.getTotalAmount()
);
} catch (PaymentGatewayException e) {
// Translate to business exception
if (e.getErrorCode().equals("DECLINED")) {
throw new PaymentFailedException(
PaymentFailedException.Reason.CARD_DECLINED
);
} else if (e.getErrorCode().equals("TIMEOUT")) {
throw new PaymentFailedException(
PaymentFailedException.Reason.TIMEOUT
);
}
// ... handle other cases
throw new PaymentFailedException(
PaymentFailedException.Reason.CARD_DECLINED
);
}
// Step 3: Create order (if we reach here, all is well)
Order order = new Order(request);
saveOrder(order);
return order;
}
}
// ==== API Layer ====
@RestController
public class OrderAPI {
private OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) {
try {
Order order = orderService.createOrder(request);
return ResponseEntity.ok(mapToDTO(order));
} catch (InsufficientInventoryException e) {
// Business error: insufficient inventory
Map<String, Object> response = new HashMap<>();
response.put("error", "INSUFFICIENT_INVENTORY");
response.put("message", e.getMessage());
return ResponseEntity.status(400).body(response);
} catch (PaymentFailedException e) {
// Business error: payment failed
Map<String, Object> response = new HashMap<>();
response.put("error", "PAYMENT_FAILED");
response.put("message", e.getMessage());
return ResponseEntity.status(402).body(response);
// ← HTTP 402 Payment Required
} catch (OrderException e) {
// Other order-related errors
System.err.println("Order error: " + e.getMessage());
return ResponseEntity.status(400)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
// Unexpected error
System.err.println("CRITICAL: " + e.getMessage());
e.printStackTrace();
return ResponseEntity.status(500)
.body(Map.of("error", "Internal server error"));
}
}
}
Best Practices Checklist
text✓ DO:
☑ Use specific exception types
☑ Preserve exception cause chain
☑ Validate input early (fail-fast)
☑ Provide context in exception message
☑ Use custom exceptions for business errors
☑ Let unchecked exceptions propagate (if it's a bug)
☑ Use try-with-resources for resource management
☑ Handle exceptions at appropriate layer
✗ DON'T:
☐ Catch generic Exception
☐ Catch and ignore silently
☐ Lose original exception cause
☐ Catch unchecked exceptions (catch logic errors)
☐ Throw generic Exception
☐ Use exceptions for control flow
☐ Create exception per error message
☐ Catch Exception, then throw new Exception()
สรุป
Exception Hierarchy & Best Practice ไม่ได้เป็นแค่การ “catch errors” แต่เป็นการออกแบบ robust error handling:
ทำไมต้องลำดับชั้น:
- Checked vs Unchecked ให้เรารู้ว่าควร handle ตรงไหน
- Custom exceptions ให้เรารู้ว่า error นี้มาจากธุรกิจ หรือ technical
- Exception chains ให้เราจำการ “เส้นสำยของสาเหตุ”
Best Practices ที่ดี:
- Specific – catch ที่พอดี ไม่กว้างเกิน
- Context – exception message ต้องมีข้อมูลเพียงพอ
- Chainable – preserve original exception
- Translatable – แปล technical exception เป็น business exception
เมื่อใช้ Exception Hierarchy อย่างถูกต้อง code ของเรา จะ:
- เข้าใจง่าย – รู้ว่า error มาจากไหน
- ปลอดภัยกว่า – bug ทำให้ crash ไม่ได้ suppress
- ดูแลรักษาง่าย – ปัญหาจดจำได้ง่าย และแก้ได้เร็ว
- Professional – ทำให้ดูเหมือน production code จริง
Exception handling ที่ดีคือ แลกหัวใจเพื่อความเชื่อถือได้ของระบบ
