Exceptions in Java: checked, unchecked and design
1. Overview
An exception is Java’s mechanism to signal that something did not happen as expected. Many projects use exceptions inconsistently. Some places are littered with throws Exception. Others swallow errors in empty catch blocks.
In this post, you’ll understand the difference between checked and unchecked exceptions. You’ll also learn when to use each type and how to design a custom exception system for your application. At the end, there’s a section on testing error-throwing behavior with JUnit 5 and AssertJ.
2. The Throwable Hierarchy
Everything you can throw with throw inherits from java.lang.Throwable. Here’s a simplified hierarchy:
Throwable
├── Error (unchecked) – serious JVM error
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception (checked)
├── IOException
├── SQLException
└── RuntimeException (unchecked)
├── IllegalArgumentException
├── IllegalStateException
└── NullPointerException
The classification rule is simple:
- Checked exceptions are
Exceptionand its subclasses, except theRuntimeExceptionbranch. The compiler forces you to handle them. - Unchecked exceptions are
RuntimeException,Error, and their subclasses. The compiler does not force you to handle them.
You should almost never catch Error. When the JVM runs out of memory, an application rarely recovers on its own.
3. Checked exceptions
A checked exception is part of a method’s “contract.” When a method declares throws, the caller has two choices. The first is to catch the error with try/catch. The second is to declare throws further up the stack.
Consider a payment gateway that can reject a transaction. This situation can definitely happen, and the caller should decide what to do next:
public class PaymentDeclinedException extends Exception {
private final String reason;
public PaymentDeclinedException(String reason) {
super("Payment declined: " + 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("exceeds limit");
}
System.out.println("Charged " + amount + " to card " + cardNumber);
}
}
PaymentDeclinedException extends Exception, so it’s a checked exception. If you call charge(...) without handling the error, the compiler will complain:
error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown
The caller is forced to think about failure:
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("Reason: " + 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));
}
}
Output:
Charged 50000 to card 4111-xxxx
SUCCESS
Reason: exceeds limit
DECLINED
Here the error is converted into a business state (DECLINED). This is the scenario where checked exceptions serve best.
4. Unchecked exceptions
Unchecked exceptions usually signal programming errors or precondition violations. Examples are passing null, passing a negative number, or calling a method when the object is in the wrong state.
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 must be > 0, got: " + quantity);
}
this.id = id;
this.quantity = quantity;
}
public void changeQuantity(int newQuantity) {
if (shipped) {
throw new IllegalStateException("Order " + id + " is shipped, cannot change quantity");
}
this.quantity = newQuantity;
}
public void ship() {
this.shipped = true;
}
}
The constructor throws IllegalArgumentException for invalid input. The changeQuantity method throws IllegalStateException when the order is already shipped. Nobody should “catch and handle” these errors at every call site. The right way is to fix the calling code.
You should reuse JDK’s built-in exceptions before creating new classes:
| Scenario | Exception to use |
|---|---|
| Invalid argument | IllegalArgumentException |
| Object in wrong state | IllegalStateException |
Required parameter is null |
NullPointerException (via Objects.requireNonNull) |
| Operation not supported | UnsupportedOperationException |
| Index out of range | IndexOutOfBoundsException |
5. Checked or unchecked?
There’s a practical rule: ask if the caller can do something useful with this error.
- The caller can recover in a reasonable way, e.g., retry, choose an alternative, or notify the user. Then a checked exception is acceptable.
- The error is due to a bug or infrastructure failure that the caller cannot handle. Then use an unchecked exception.
In practice, modern frameworks like Spring and Hibernate almost exclusively use unchecked exceptions. For example, Spring wraps SQLException into DataAccessException. The reasons are:
- They cascade up every layer: a
throwsat the repository layer forces one at the service and controller layers. - They are incompatible with lambdas and the Stream API (see section 8).
- They leak implementation details. The
OrderRepositoryinterface should not declarethrows SQLException.
Note: For Spring Boot applications, default to unchecked exceptions for business errors. Then handle them centrally with
@RestControllerAdvice. Use checked exceptions only if you really want to force the caller to handle immediately.
6. Designing custom exceptions for your application
6.1. Create a base exception class
You should create one base exception per domain. This lets you catch all errors in that domain with a single catch block if needed:
public class OrderException extends RuntimeException {
public OrderException(String message) {
super(message);
}
public OrderException(String message, Throwable cause) {
super(message, cause);
}
}
This class must always have a constructor that takes a cause. This way, the root error is not lost when you wrap it.
6.2. Subclasses carry context
A good exception answers “what happened, with what data?” Don’t just write "Not found":
public class OrderNotFoundException extends OrderException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("Order not found with id=" + orderId);
this.orderId = orderId;
}
public long getOrderId() {
return orderId;
}
}
The orderId field is saved so upper layers can use it. For example, a controller advice can read it to return error JSON to the client without parsing the message string.
6.3. Wrap lower-layer errors (exception translation)
When the service layer calls infrastructure, translate technical errors into domain errors and preserve the 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("Failed to export order " + orderId + " to " + target, e);
}
}
}
The second parameter e is the cause. When you print the stack trace, you’ll see both layers:
OrderException: Failed to export order 42 to /readonly/order.txt
at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
...
If you write throw new OrderException(e.getMessage()) without passing e, the Caused by section disappears. Debugging becomes very hard then.
7. Common anti-patterns
Swallowing exceptions. This is the most dangerous mistake, because the application keeps running as if nothing happened:
try {
gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
// TODO
}
If you really want to ignore an error, make the reason explicit in a comment. At minimum, you should log it.
Logging and rethrowing. Writing log.error(...) then throw e; makes the same error appear multiple times in the log. Pick one: handle (and log) at this level, or rethrow for an upper layer to handle.
Catching Exception or Throwable too broadly. This accidentally catches NullPointerException and other bugs. Catch only the type you can actually handle. The only reasonable exception is at the very edge of your application, like a global handler.
Using exceptions for control flow. Throwing an exception is expensive because it captures the stack trace. This style is also hard to read:
// Don't do this
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) {
return 0;
}
For data you expect to be invalid often, check beforehand. You could also return an Optional. Exceptions are for unexpected situations.
Declaring throws Exception. This wipes out information about what exceptions can occur. It also forces every caller to catch a very broad type.
8. Checked exceptions with lambdas
The Function interface does not declare throws, so this won’t compile:
List<String> contents = paths.stream()
.map(Files::readString) // error: unreported exception IOException
.toList();
The common solution is to wrap the error in UncheckedIOException. This class exists in the JDK for exactly this purpose:
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("Cannot read " + path, e);
}
}
}
The readQuietly method isolates the try/catch from the pipeline, keeping the stream clean. Callers of readAll can catch UncheckedIOException and get the original IOException via getCause().
9. try-with-resources and suppressed exceptions
When working with resources that need closing, use try-with-resources. One thing many miss: if both the try body and close() throw errors, the close() error does not override the primary error. It gets attached as suppressed:
public class SuppressedDemo {
static class ReportConnection implements AutoCloseable {
void query() {
throw new IllegalStateException("Query failed");
}
@Override
public void close() {
throw new IllegalStateException("Close failed");
}
}
public static void main(String[] args) {
try (var connection = new ReportConnection()) {
connection.query();
} catch (IllegalStateException e) {
System.out.println("Primary: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("Suppressed: " + s.getMessage());
}
}
}
}
Output:
Primary: Query failed
Suppressed: Close failed
If you write your own try/finally and call close() in the finally, the “Close failed” error will override “Query failed”. Then you lose the real cause.
10. Testing exception behavior
If using Spring Boot, spring-boot-starter-test already includes JUnit 5 and AssertJ. For pure Java projects, add:
<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>
You should check the exception type, message, context data, and 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 must be > 0");
}
@Test
void whenOrderShipped_thenCannotChangeQuantity() {
var order = new Order(7L, 2);
order.ship();
assertThatIllegalStateException()
.isThrownBy(() -> order.changeQuantity(5))
.withMessage("Order 7 is shipped, cannot change quantity");
}
@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("exceeds limit"));
}
@Test
void whenExportFails_thenCauseIsPreserved() {
var service = new OrderExportService();
Path invalid = Path.of("/nonexistent-dir/order.txt");
assertThatThrownBy(() -> service.export(42L, invalid))
.isInstanceOf(OrderException.class)
.hasMessageContaining("Failed to export order 42")
.hasCauseInstanceOf(java.io.IOException.class);
}
}
A few points to note:
assertThatThrownBytakes a lambda that can throw anyThrowable, including checked exceptions. So you don’t needtry/catchin tests.hasFieldOrPropertyWithValuereads the gettergetOrderId(). This ensures context is passed correctly.hasCauseInstanceOfconfirms the cause is not lost when wrapping. Forgetting to preserve cause is easy to miss without this test. In the test, the parent directory doesn’t exist, soFiles.writeStringthrowsNoSuchFileException, a subclass ofIOException.
11. Conclusion
In this post, you’ve seen that checked exceptions are for situations where the caller can recover. Unchecked exceptions are for programming errors, precondition violations, or infrastructure failures the caller cannot handle. With modern applications, especially Spring Boot, unchecked exceptions are a sensible default.
When designing custom exceptions, follow these principles:
- Create one base exception per domain.
- Put context data into subclasses.
- Always preserve the
causewhen translating errors between layers.
Finally, avoid anti-patterns like swallowing errors, logging and rethrowing, or catching Exception too broadly. Write tests to lock down exception-throwing behavior.