Exceptions in Java: Checked, Unchecked und Design
1. Übersicht
Exceptions sind ein Java-Mechanismus, um anzuzeigen, dass etwas nicht wie erwartet verläuft. Viele Projekte verwenden Exceptions inkonsistent. An manchen Stellen sieht man überall throws Exception deklariert. An anderen Stellen werden Fehler in leeren catch-Blöcken verschluckt.
In diesem Artikel wirst du den Unterschied zwischen Checked und Unchecked Exceptions verstehen. Du wirst auch wissen, wann du welchen Typ verwenden solltest und wie du ein eigenes Exception-System für deine Anwendung gestaltest. Am Ende des Artikels findest du einen Abschnitt zum Testen von Fehlerverhalten mit JUnit 5 und AssertJ.
2. Die Throwable-Hierarchie
Alles, das mit throw geworfen werden kann, erbt von java.lang.Throwable. Die vereinfachte Hierarchie sieht so aus:
Throwable
├── Error (unchecked) – ernsthafte JVM-Fehler
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception (checked)
├── IOException
├── SQLException
└── RuntimeException (unchecked)
├── IllegalArgumentException
├── IllegalStateException
└── NullPointerException
Die Klassifizierungsregel ist sehr einfach:
- Checked Exceptions sind
Exceptionund dessen Unterklassen, außer derRuntimeException-Branche. Der Compiler zwingt dich, diesen Typ zu behandeln. - Unchecked Exceptions sind
RuntimeException,Errorund deren Unterklassen. Der Compiler zwingt dich nicht, diesen Typ zu behandeln.
Du solltest fast nie Error abfangen. Wenn der JVM der Speicher ausgeht, kann sich die Anwendung selten selbst erholen.
3. Checked Exceptions
Checked Exceptions sind Teil des „Vertrags” einer Methode. Wenn eine Methode throws deklariert, muss der Aufrufer eine von zwei Optionen wählen. Die erste Option ist, den Fehler mit try/catch abzufangen. Die zweite Option ist, throws weiter nach oben zu deklarieren.
Stell dir ein Zahlungsgateway vor, das Transaktionen ablehnen kann. Diese Situation kann vollkommen normal vorkommen, und der Aufrufer sollte entscheiden, was als Nächstes zu tun ist:
public class PaymentDeclinedException extends Exception {
private final String reason;
public PaymentDeclinedException(String reason) {
super("Zahlung abgelehnt: " + 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("Limit überschritten");
}
System.out.println("Abgebucht " + amount + " von der Karte " + cardNumber);
}
}
PaymentDeclinedException erbt von Exception, also ist es eine Checked Exception. Wenn du charge(...) aufrufst, ohne den Fehler zu behandeln, wird der Compiler dies melden:
error: unreported exception PaymentDeclinedException; must be caught or declared to be thrown
Der Aufrufer muss sich zwangsläufig mit dem Fehlerfall auseinandersetzen:
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("Grund: " + 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));
}
}
Ausgabe:
Abgebucht 50000 von der Karte 4111-xxxx
SUCCESS
Grund: Limit überschritten
DECLINED
Hier wird der Fehler in einen Geschäftsstatus (DECLINED) umgewandelt. Dies ist der Typ der Situation, in der Checked Exceptions am besten funktionieren.
4. Unchecked Exceptions
Unchecked Exceptions signalisieren normalerweise Programmierfehler oder Verstöße gegen Vorbedingungen. Beispiele sind die Übergabe von null, die Übergabe negativer Zahlen oder das Aufrufen einer Methode, wenn sich das Objekt in einem ungültigen Zustand befindet.
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("Menge muss > 0 sein, erhalten: " + quantity);
}
this.id = id;
this.quantity = quantity;
}
public void changeQuantity(int newQuantity) {
if (shipped) {
throw new IllegalStateException("Bestellung " + id + " wurde versandt, Menge kann nicht geändert werden");
}
this.quantity = newQuantity;
}
public void ship() {
this.shipped = true;
}
}
Der Konstruktor wirft IllegalArgumentException, wenn die Eingabe ungültig ist. Die Methode changeQuantity wirft IllegalStateException, wenn die Bestellung bereits versandt wurde. Niemand sollte diese Fehler an jeder Aufrufstelle „abfangen und behandeln”. Der richtige Weg ist, den fehlerhaften Code zu korrigieren.
Du solltest die vorhandenen JDK-Exceptions wiederverwenden, bevor du neue Klassen erstellst:
| Situation | Exception zum Verwenden |
|---|---|
| Ungültiger Parameter | IllegalArgumentException |
| Objekt im falschen Zustand | IllegalStateException |
Erforderlicher Parameter ist null |
NullPointerException (via Objects.requireNonNull) |
| Operation wird nicht unterstützt | UnsupportedOperationException |
| Index außerhalb des Bereichs | IndexOutOfBoundsException |
5. Checked oder Unchecked?
Es gibt eine praktische Regel: Frag dich, ob der Aufrufer etwas Sinnvolles mit diesem Fehler tun kann.
- Der Aufrufer kann sich vernünftig erholen, z. B. durch Wiederholung, Alternative oder Benutzerbenachrichtigung. In diesem Fall ist eine Checked Exception eine akzeptable Wahl.
- Der Fehler ist aufgrund eines Bugs oder einer Infrastrukturausfalls, bei dem der Aufrufer nichts tun kann. Verwende dann eine Unchecked Exception.
In der Praxis verwenden moderne Frameworks wie Spring und Hibernate fast ausschließlich Unchecked Exceptions. Beispielsweise wickelt Spring SQLException in DataAccessException ein. Der Grund ist, dass Checked Exceptions mehrere Nachteile haben:
- Sie breiten sich über alle Schichten aus: ein
throwsim Repository ziehtthrowsim Service und Controller nach sich. - Sie sind nicht kompatibel mit Lambda und Stream API (siehe Abschnitt 8).
- Sie offenbaren Implementierungsdetails. Interface
OrderRepositorysollte nichtthrows SQLExceptiondeklarieren.
Hinweis: Bei einer Spring-Boot-Anwendung solltest du standardmäßig Unchecked Exceptions für geschäftliche Fehler verwenden. Behandle sie dann zentral mit
@RestControllerAdvice. Verwende Checked Exceptions nur, wenn du den Aufrufer wirklich zwingen möchtest, den Fehler sofort zu behandeln.
6. Designing Application-Specific Exceptions
6.1. Erstelle eine Basis-Klasse
Du solltest für jede Domäne eine Basis-Exception erstellen. So kannst du alle Fehler einer Domäne bei Bedarf in einem catch-Block abfangen:
public class OrderException extends RuntimeException {
public OrderException(String message) {
super(message);
}
public OrderException(String message, Throwable cause) {
super(message, cause);
}
}
Diese Klasse muss immer einen Konstruktor haben, der cause akzeptiert. Dadurch geht der ursprüngliche Fehler nicht verloren, wenn du ihn umhüllst.
6.2. Unterklassen mit Kontext
Eine gute Exception muss die Frage beantworten: „Was ist passiert und mit welchen Daten?“. Schreibe nicht einfach nur "Not found":
public class OrderNotFoundException extends OrderException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("Bestellung mit id=" + orderId + " nicht gefunden");
this.orderId = orderId;
}
public long getOrderId() {
return orderId;
}
}
Das Feld orderId wird gespeichert, damit die oberen Schichten es weiterverwenden können. Beispielsweise kann ein Controller Advice es lesen, um ohne String-Parsing eine Fehler-JSON an den Client zurückzugeben.
6.3. Fehler zwischen Schichten übersetzen (Exception Translation)
Wenn die Service-Schicht die Infrastruktur aufruft, solltest du technische Fehler in Domänenfehler übersetzen und cause beibehalten:
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("Bestellung " + orderId + " nach " + target + " exportieren fehlgeschlagen", e);
}
}
}
Der zweite Parameter e ist die Ursache. Wenn du den Stack Trace ausgibst, siehst du beide Ebenen:
OrderException: Bestellung 42 nach /readonly/order.txt exportieren fehlgeschlagen
at OrderExportService.export(OrderExportService.java:12)
Caused by: java.nio.file.AccessDeniedException: /readonly/order.txt
...
Wenn du throw new OrderException(e.getMessage()) schreibst, ohne e zu übergeben, verschwindet der Caused by-Teil. Das Debuggen wird dann sehr schwierig.
7. Häufige Anti-Pattern
Exceptions verschlucken. Das ist der gefährlichste Fehler, denn die Anwendung läuft weiter, als würde nichts passieren:
try {
gateway.charge(card, amount);
} catch (PaymentDeclinedException e) {
// TODO
}
Wenn du wirklich einen Fehler ignorieren möchtest, dokumentiere dies mit einem Kommentar. Minimal solltest du den Fehler protokollieren.
Gleichzeitig protokollieren und erneut werfen. Das Schreiben von log.error(...) gefolgt von throw e; führt dazu, dass derselbe Fehler mehrfach protokolliert wird. Du solltest dich für eines entscheiden: Behandle (und protokolliere) vor Ort oder werfe die Exception nach oben.
Exception oder Throwable zu breit abfangen. So fängst du versehentlich auch NullPointerException und andere Bugs ab. Fange den exakten Typ ab, den du behandeln kannst. Die einzige sinnvolle Ausnahme ist an der äußeren Grenze der Anwendung, z. B. ein Global Handler.
Exceptions zur Kontrollsteuerung verwenden. Exceptions zu erstellen ist teuer, weil ein Stack Trace erfasst werden muss. Dieser Code-Stil ist auch schwer zu lesen:
// Nicht empfohlen
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) {
return 0;
}
Bei Daten, bei denen du erwartest, dass sie häufig ungültig sind, prüfe vorher. Du kannst auch Optional zurückgeben. Exceptions sollten für außergewöhnliche Situationen reserviert sein.
throws Exception deklarieren. Dies löscht alle Informationen darüber, welche Fehlertypen auftreten können. Es zwingt auch alle Aufrufer, einen sehr breiten Typ abzufangen.
8. Checked Exceptions in Lambdas
Interface Function deklariert keine throws, daher wird der folgende Code nicht kompiliert:
List<String> contents = paths.stream()
.map(Files::readString) // Fehler: nicht gemeldete IOException
.toList();
Die häufige Lösung ist, den Fehler in UncheckedIOException zu wickeln. Diese Klasse ist im JDK genau dafür vorgesehen:
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("Konnte nicht lesen " + path, e);
}
}
}
Die Methode readQuietly isoliert den try/catch-Block vom Pipeline, daher bleibt der Stream sauber. Der Aufrufer von readAll kann UncheckedIOException abfangen und die ursprüngliche IOException über getCause() abrufen.
9. Try-with-Resources und unterdrückte Exceptions
Wenn du mit Ressourcen arbeitest, die geschlossen werden müssen, verwende Try-with-Resources. Es gibt einen oft übersehenen Punkt: Wenn sowohl der try-Body als auch close() einen Fehler werfen, wird der Fehler von close() nicht den Hauptfehler überschreiben. Er wird stattdessen als Suppressed an den Hauptfehler angehängt:
public class SuppressedDemo {
static class ReportConnection implements AutoCloseable {
void query() {
throw new IllegalStateException("Query fehlgeschlagen");
}
@Override
public void close() {
throw new IllegalStateException("Close fehlgeschlagen");
}
}
public static void main(String[] args) {
try (var connection = new ReportConnection()) {
connection.query();
} catch (IllegalStateException e) {
System.out.println("Hauptfehler: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
System.out.println("Unterdrückt: " + s.getMessage());
}
}
}
}
Ausgabe:
Hauptfehler: Query fehlgeschlagen
Unterdrückt: Close fehlgeschlagen
Wenn du selbst try/finally schreibst und close() in finally aufrufst, überschreibt der Fehler von close() den Query-Fehler. Dann verlierst du die wahre Ursache.
10. Testen von Exception-Verhalten
Bei Spring Boot ist JUnit 5 und AssertJ bereits in spring-boot-starter-test enthalten. Für ein reines Java-Projekt, füge diese Abhängigkeiten hinzu:
<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>
Du solltest den Exception-Typ, die Nachricht, die Kontextdaten und die Ursache überprüfen:
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("Menge muss > 0");
}
@Test
void whenOrderShipped_thenCannotChangeQuantity() {
var order = new Order(7L, 2);
order.ship();
assertThatIllegalStateException()
.isThrownBy(() -> order.changeQuantity(5))
.withMessage("Bestellung 7 wurde versandt, Menge kann nicht geändert werden");
}
@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("Limit überschritten"));
}
@Test
void whenExportFails_thenCauseIsPreserved() {
var service = new OrderExportService();
Path invalid = Path.of("/verzeichnis-nicht-vorhanden/order.txt");
assertThatThrownBy(() -> service.export(42L, invalid))
.isInstanceOf(OrderException.class)
.hasMessageContaining("Bestellung 42")
.hasCauseInstanceOf(java.io.IOException.class);
}
}
Ein paar wichtige Punkte:
assertThatThrownByakzeptiert eine Lambda, die jedenThrowablewerfen kann, auch Checked Exceptions. Daher brauchst du keintry/catchim Test.hasFieldOrPropertyWithValueliest den GettergetOrderId(). So stellst du sicher, dass die Kontextdaten korrekt übergeben werden.hasCauseInstanceOfbestätigt, dass die Ursache nicht verloren geht, wenn der Fehler eingewickelt wird. Der Fehler, die Ursache zu vergessen, kann leicht übersehen werden, ohne diesen Test. Im Test existiert das übergeordnete Verzeichnis nicht, daher wirftFiles.writeStringNoSuchFileException, eine Unterklasse vonIOException.
11. Fazit
In diesem Artikel hast du gesehen, dass Checked Exceptions für Situationen gelten, in denen der Aufrufer sich erholen kann. Unchecked Exceptions sind für Programmierfehler, Vorbedingungsverletzungen oder Infrastrukturausfälle bestimmt, bei denen der Aufrufer nichts tun kann. Bei modernen Anwendungen, besonders Spring Boot, sind Unchecked Exceptions die sinnvolle Standardwahl.
Wenn du Exceptions selbst gestaltest, folge diesen Prinzipien:
- Erstelle für jede Domäne eine Basis-Klasse.
- Integriere Kontextdaten in Unterklassen.
- Behalte
cause, wenn du Fehler zwischen Schichten übersetzt.
Zum Abschluss solltest du Anti-Pattern vermeiden wie Exceptions verschlucken, gleichzeitig protokollieren und werfen, oder Exception zu breit abfangen. Schreibe Tests, um das Exception-Verhalten deines Codes zu sichern.