8.3 Custom Exceptions
Java provides a large number of built-in exception classes (NullPointerException, FileNotFoundException, etc.) that cover most common error situations.
However, when developing real-world web services or applications, you will encounter situations where you need to express error conditions that exist only in your business logic— such as an "Insufficient Balance Exception," an "Invalid Input Word Exception," or a "Member Tier Too Low Exception." When a developer creates their own class for these situations, it is called a Custom Exception.
1. Creating a Custom Exception
Creating a custom exception is very straightforward. You simply extend (extends) Java's built-in Exception class or RuntimeException class.
| Base Class | Type | Characteristic | Recommended When |
|---|---|---|---|
Exception | Checked Exception | Forces try-catch handling | Recoverable external resource errors |
RuntimeException | Unchecked Exception | Handling is optional (no compiler enforcement) | Business logic errors, programmer mistakes |
In modern practice, the Unchecked Exception approach extending RuntimeException is preferred. Modern Java frameworks like Spring Framework and JPA are mostly designed around Unchecked Exceptions. Checked Exceptions can force unnecessary throws declarations that clutter your code.
Writing a Basic Custom Exception
// Unchecked Exception: Insufficient balance exception
public class InsufficientBalanceException extends RuntimeException {
// 1. Default constructor
public InsufficientBalanceException() {
super("Insufficient balance.");
}
// 2. Constructor accepting a custom message
public InsufficientBalanceException(String message) {
super(message);
}
// 3. Constructor accepting message + cause (for exception chaining)
public InsufficientBalanceException(String message, Throwable cause) {
super(message, cause);
}
}
2. The throw Keyword: Triggering an Exception Intentionally
To use the exception we created, we use the throw keyword to create an exception object and throw it when the error condition occurs.
// throw vs throws summary
// - throw : keyword that actually triggers the exception (verb)
// - throws : keyword in a method declaration that passes the exception upward (noun form)
public class Account {
private String owner;
private long balance;
public Account(String owner, long balance) {
this.owner = owner;
this.balance = balance;
}
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be greater than 0: " + amount);
}
this.balance += amount;
System.out.printf("[%s] %,d deposited. Balance: %,d%n", owner, amount, balance);
}
public void withdraw(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be greater than 0: " + amount);
}
if (this.balance < amount) {
// Throw the custom exception with the throw keyword
long shortfall = amount - this.balance;
throw new InsufficientBalanceException(
String.format("Insufficient balance: need %,d more. Current balance: %,d", shortfall, balance)
);
}
this.balance -= amount;
System.out.printf("[%s] %,d withdrawn. Balance: %,d%n", owner, amount, balance);
}
public long getBalance() { return balance; }
public String getOwner() { return owner; }
}
3. Custom Exceptions with Error Code Fields
In practice, including error codes in exceptions makes it easier for clients (such as front-end applications) to distinguish and handle errors.
// Error code enum
public enum ErrorCode {
INSUFFICIENT_BALANCE("E001", "Insufficient balance"),
ACCOUNT_NOT_FOUND("E002", "Account not found"),
INVALID_AMOUNT("E003", "Invalid amount"),
ACCOUNT_LOCKED("E004", "Account is locked");
private final String code;
private final String description;
ErrorCode(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() { return code; }
public String getDescription() { return description; }
}
// Business exception base class with error code
public class BankException extends RuntimeException {
private final ErrorCode errorCode;
public BankException(ErrorCode errorCode) {
super(errorCode.getDescription());
this.errorCode = errorCode;
}
public BankException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public BankException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() { return errorCode; }
@Override
public String toString() {
return String.format("[%s] %s", errorCode.getCode(), getMessage());
}
}
4. Designing a Business Exception Hierarchy
In real-world projects, you design exception hierarchies that fit your business domain.
// Top-level business exception
public class BankBusinessException extends RuntimeException {
public BankBusinessException(String message) {
super(message);
}
public BankBusinessException(String message, Throwable cause) {
super(message, cause);
}
}
// Account-related exception (extends BankBusinessException)
public class AccountException extends BankBusinessException {
public AccountException(String message) { super(message); }
}
// Insufficient funds exception (extends AccountException)
public class InsufficientFundsException extends AccountException {
private final long shortage;
public InsufficientFundsException(long shortage) {
super(String.format("Insufficient funds: need %,d more", shortage));
this.shortage = shortage;
}
public long getShortage() { return shortage; }
}
// Account locked exception (extends AccountException)
public class AccountLockedException extends AccountException {
public AccountLockedException(String accountId) {
super("Account is locked: " + accountId);
}
}
// Account not found exception (extends AccountException)
public class AccountNotFoundException extends AccountException {
public AccountNotFoundException(String accountId) {
super("Account not found: " + accountId);
}
}
5. Exception Chaining
This pattern catches a low-level exception (e.g., SQLException) and wraps it in a more meaningful higher-level exception. The original exception can be traced via getCause(), which aids in debugging.
import java.sql.SQLException;
public class ExceptionChainingExample {
// Low-level method: database access
static long queryBalance(String accountId) throws SQLException {
// In reality this performs a DB query, but here we simulate it
if (accountId.equals("ERROR")) {
throw new SQLException("DB connection error: Connection refused");
}
return 50000L;
}
// High-level method: business logic
static long getAccountBalance(String accountId) {
try {
return queryBalance(accountId);
} catch (SQLException e) {
// Chain the low-level exception (SQLException) into a high-level exception
throw new BankBusinessException(
"Failed to retrieve account balance: " + accountId, e // e is the cause
);
}
}
public static void main(String[] args) {
try {
long balance = getAccountBalance("ERROR");
} catch (BankBusinessException e) {
System.out.println("Business error: " + e.getMessage());
System.out.println("Root cause: " + e.getCause().getMessage());
}
}
}
Output:
Business error: Failed to retrieve account balance: ERROR
Root cause: DB connection error: Connection refused
6. Practical Example: Complete Bank Account System
A practical example integrating all the elements created above.
import java.util.HashMap;
import java.util.Map;
public class BankSystem {
// Account repository (accountId → Account)
private Map<String, Account> accounts = new HashMap<>();
// Open an account
public void createAccount(String accountId, String owner, long initialBalance) {
if (accounts.containsKey(accountId)) {
throw new BankBusinessException("Account number already exists: " + accountId);
}
accounts.put(accountId, new Account(owner, initialBalance));
System.out.printf("Account created: %s (Owner: %s, Initial balance: %,d)%n",
accountId, owner, initialBalance);
}
// Find an account
private Account findAccount(String accountId) {
Account account = accounts.get(accountId);
if (account == null) {
throw new AccountNotFoundException(accountId);
}
return account;
}
// Deposit
public void deposit(String accountId, long amount) {
Account account = findAccount(accountId);
account.deposit(amount);
}
// Withdraw
public void withdraw(String accountId, long amount) {
Account account = findAccount(accountId);
account.withdraw(amount);
}
// Transfer
public void transfer(String fromId, String toId, long amount) {
Account from = findAccount(fromId);
Account to = findAccount(toId);
// Process withdrawal and deposit as a single transaction
try {
from.withdraw(amount);
to.deposit(amount);
System.out.printf("[Transfer] %s → %s: %,d transferred successfully%n", fromId, toId, amount);
} catch (InsufficientBalanceException e) {
System.out.println("[Transfer failed] " + e.getMessage());
throw e; // Re-throw exception upward
}
}
// Check balance
public void printBalance(String accountId) {
Account account = findAccount(accountId);
System.out.printf("[%s] %s's balance: %,d%n",
accountId, account.getOwner(), account.getBalance());
}
public static void main(String[] args) {
BankSystem bank = new BankSystem();
// Open accounts
bank.createAccount("ACC001", "Alice", 100000);
bank.createAccount("ACC002", "Bob", 50000);
System.out.println();
// Normal transactions
bank.deposit("ACC001", 30000);
bank.withdraw("ACC001", 20000);
bank.transfer("ACC001", "ACC002", 50000);
System.out.println();
// Check balances
bank.printBalance("ACC001");
bank.printBalance("ACC002");
System.out.println();
// Exception case handling
System.out.println("=== Exception Case Tests ===");
// 1. Insufficient balance
try {
bank.withdraw("ACC001", 999999);
} catch (InsufficientBalanceException e) {
System.out.println("Withdrawal failed: " + e.getMessage());
}
// 2. Account does not exist
try {
bank.deposit("ACC999", 10000);
} catch (AccountNotFoundException e) {
System.out.println("Account error: " + e.getMessage());
}
// 3. Invalid amount
try {
bank.deposit("ACC001", -5000);
} catch (IllegalArgumentException e) {
System.out.println("Input error: " + e.getMessage());
}
// 4. Catch with parent exception type
try {
bank.transfer("ACC001", "ACC999", 10000); // account not found
} catch (BankBusinessException e) {
// AccountNotFoundException is a descendant of BankBusinessException, so it's caught here
System.out.println("Transfer failed: " + e.getMessage());
}
System.out.println("\nFinal balances:");
bank.printBalance("ACC001");
bank.printBalance("ACC002");
}
}
Output:
Account created: ACC001 (Owner: Alice, Initial balance: 100,000)
Account created: ACC002 (Owner: Bob, Initial balance: 50,000)
[Alice] 30,000 deposited. Balance: 130,000
[Alice] 20,000 withdrawn. Balance: 110,000
[Alice] 50,000 withdrawn. Balance: 60,000
[Bob] 50,000 deposited. Balance: 100,000
[Transfer] ACC001 → ACC002: 50,000 transferred successfully
[ACC001] Alice's balance: 60,000
[ACC002] Bob's balance: 100,000
=== Exception Case Tests ===
Withdrawal failed: Insufficient balance: need 939,999 more. Current balance: 60,000
Account error: Account not found: ACC999
Input error: Deposit amount must be greater than 0: -5000
Transfer failed: Account not found: ACC999
Final balances:
[ACC001] Alice's balance: 60,000
[ACC002] Bob's balance: 100,000
7. Why Use Custom Exceptions
| Reason | Description |
|---|---|
| Readability | throw new InsufficientFundsException() clearly expresses "insufficient funds" by name alone |
| Granular control | Each business error can be handled in its own dedicated catch block |
| Maintainability | Error types are organized as classes, making code changes easier |
| Hierarchical handling | catch (AccountException e) can handle all account-related exceptions at once |
| Additional information | Custom fields like error codes and shortage amounts can carry extra context |
- Exception class names should be clear and specific(
InvalidUserEmailExceptionis better thanDataException). - Provide at least 3 constructors: default, message-only, and message+cause.
- Design a hierarchy that supports both broad handling via parent exceptions and fine-grained handling via child exceptions.
- Use Checked Exceptions only for recoverable situations. Most business exceptions are better suited as
RuntimeException.
You have now mastered Java exception handling from the fundamentals of preventing program crashes all the way to custom exceptions! Starting from the next chapter, we will explore Java's core java.lang package.