Wo sollten catch und Logging in der Ausnahmebehandlung stehen?

· · Ausnahmebehandlung, Logging, Fehlerbehandlung, Design, C# / .NET

Bei Code-Reviews zur Ausnahmebehandlung wiederholen sich meist dieselben drei Beobachtungen:

  • Die tiefste gemeinsame Funktion fängt catch (Exception) ab, sodass der Aufrufer nicht unterscheiden kann, ob „keine Daten vorhanden waren“ oder „etwas mittendrin kaputtgegangen ist“
  • Bei einem einzigen Vorfall stehen vier Kopien desselben Stack Trace nebeneinander — aus Repository, Service, Controller und dem Handler für unbehandelte Ausnahmen
  • Ein Benutzer hat lediglich abgebrochen, trotzdem erscheint ein Error-Log, in dem sich dann ein wirklich gefährlicher Fehler versteckt

In keinem dieser Fälle ist try / catch schlecht geschrieben. Es fehlt lediglich eine klare Aufteilung: Wo wird abgefangen, wer schreibt das Log, wo wird die Form des Fehlers festgelegt? Ist diese Rollenverteilung nicht geklärt, fügt jede Entwicklerin und jeder Entwickler „sicherheitshalber“ in der eigenen Schicht weitere catch-Blöcke und Logs hinzu — mit dem Ergebnis, dass die eigentliche Ursache am Ende nicht mehr erkennbar ist.

Dieser Artikel richtet sich an Entwicklerinnen und Entwickler, die mit C# / .NET Geschäftsanwendungen oder Web-APIs schreiben, sowie an alle, die entsprechende Design-Reviews durchführen. Er ordnet, wie man die Grenze zum Abfangen von Ausnahmen, den Ort des Primärlogs und die Verantwortung für Wiederherstellungsentscheidungen aufteilt. Wer vorab festlegt, was in welcher Ebene der Aufrufhierarchie geschieht, trifft im Review wie bei der Störungssuche verlässlichere Entscheidungen.

In diesem Artikel verwendete Begriffe

Drei Begriffe tauchen im Text immer wieder auf. Sie werden hier vorab definiert — nicht als Allgemeinbegriffe, sondern in der Bedeutung, die sie in diesem Artikel haben.

Begriff Bedeutung in diesem Artikel
Fehlereinheit Die fachlich zusammenhängende Verarbeitungseinheit, die beschreibt, „was einmal fehlgeschlagen ist“. Eine einzelne Bildschirmbedienung, eine HTTP-Anfrage, ein Job, eine Nachricht oder eine Zeile einer CSV-Datei zählen dazu. Diese Einheit taucht sowohl im Log als auch in der Antwort auf
Primärlog Der einmalige Error- oder Critical-Eintrag für einen Fehler. Er enthält die Fehlereinheit sowie Betriebskontext wie requestId, userId oder eine Ziel-ID. Alle übrigen Einträge gelten als unterstützende Logs auf Debug / Information / Warning
Ergebnisumwandlung Das Beenden des fortlaufenden Werfens einer Ausnahme zugunsten eines Rückgabewerts wie eines Result-Typs oder eines DTOs, das einen Fehler abbildet. Damit lässt sich ein erwarteter Fehler in eine Form bringen, die der Aufrufer per Verzweigung behandeln kann

Inhaltsverzeichnis

  1. Das Wichtigste zuerst
  2. catch, Logging und Fehlerbehandlung sind unterschiedliche Dinge
    • 2.1. catchen
    • 2.2. Logging
    • 2.3. Fehlerbehandlung
    • 2.4. Ausnahmen übersetzen
  3. Die Entscheidungstabelle zum ersten Nachschlagen
  4. Was in welcher Ebene der Aufrufhierarchie zu tun ist
    • 4.1. Der tiefste Helper / die tiefste Utility / private Methode
    • 4.2. Externe I/O-Grenze: Repository / Gateway / SDK-Wrapper
    • 4.3. Application Service / UseCase
    • 4.4. Grenze zu UI / HTTP / Job / Message
    • 4.5. Der letzte Handler für unbehandelte Ausnahmen
    • 4.6. Entlang einer einzelnen Aufrufhierarchie betrachtet
  5. Erwartete Fehler von unerwarteten Ausnahmen trennen
  6. Wo und wie oft sollte protokolliert werden?
  7. Häufige Anti-Patterns
  8. Checkliste für Reviews
  9. Grobe Übersicht
  10. Zusammenfassung
  11. Referenzlinks
  12. Verwandte Artikel

1. Das Wichtigste zuerst

  • Das Prinzip lautet: in tiefen Schichten nicht breit catchen. Das Abfangen wird zu den Grenzen verlagert, an denen sich eine Fehlereinheit definieren lässt.
  • Beim Logging gilt als Grundregel: ein Primärlog pro Fehler. Protokolliert jede Schicht dieselbe Ausnahme erneut mit Error, leidet darunter, wer das Log liest.
  • Die Aufgabe der tiefsten Schicht ist Aufräumen, lokaler Rollback, das Übersetzen von Ausnahmen und, falls nötig, ein begrenzter Retry. Wird erneut geworfen, schreibt diese Schicht dort normalerweise kein Primärlog.
  • Prozessgrenzen — eine Bildschirmbedienung, eine HTTP-Anfrage, ein Job, eine Nachricht — sind meist der natürlichste Ort für das Primärlog.
  • Erwartete Fehler werden auf Ebene des jeweiligen Anwendungsfalls in ein Ergebnis umgewandelt. Nicht alles muss weiter als Ausnahme nach oben geworfen werden.
  • AppDomain.UnhandledException, WPFs DispatcherUnhandledException, WinForms’ ThreadException, der Ausnahme-Handler von ASP.NET Core und die abschließende Ausnahmebehandlung des Hosts sind eher letzte Aufzeichnungsstellen als Wiederherstellungspunkte.
  • Eine OperationCanceledException durch Benutzerabbruch oder Shutdown wird normalerweise nicht als Error behandelt.
  • Im Zweifel in dieser Reihenfolge prüfen:
    1. Kann an dieser Stelle wirklich entschieden werden?
    2. Ist hier bekannt, welche Einheit fehlgeschlagen ist?
    3. Lässt sich der Zustand hier zurücksetzen oder neu aufbauen?
    4. Wird dieselbe Ausnahme, wenn hier geloggt wird, auch weiter oben noch geloggt?

Kurz gesagt: nicht dort abfangen, wo man es kann, sondern dort, wo man verantwortlich entscheiden kann.

2. catch, Logging und Fehlerbehandlung sind unterschiedliche Dinge

2.1. catchen

catch bedeutet, eine Ausnahme einmal entgegenzunehmen und den Ablauf zu ändern. Das allein ist jedoch keine Wiederherstellung.

Wenn eine untere Methode zum Beispiel eine Ausnahme empfängt, aber

  • nicht weiß, was dem Benutzer angezeigt werden soll
  • nicht weiß, ob wegen dieses Fehlers der ganze Bildschirm gestoppt werden soll oder nur diese eine Operation fehlschlagen darf
  • nicht weiß, ob der Request oder Job fortgesetzt werden darf,

dann ist diese Stelle meist nicht der richtige Ort für catch.

2.2. Logging

Ein Log hält nicht nur die Tatsache fest, dass „eine Ausnahme aufgetreten ist“, sondern dokumentiert, welche Arbeit fehlgeschlagen ist, damit sich das später nachvollziehen lässt.

Deshalb liegt an einer guten Log-Stelle meist eines der folgenden Dinge vor.

  • requestId / traceId
  • userId
  • orderId / fileId / batchId
  • die wievielte Eingabe es war
  • welche Bildschirmbedienung es war
  • welche Queue, welche Nachricht

Tiefe Helper und gemeinsame Funktionen kennen oft die technischen Details, aber nicht diesen Kontext. Deshalb sind der Ort, der die technischen Details kennt, und der Ort, der den Betriebskontext kennt, häufig nicht derselbe.

2.3. Fehlerbehandlung

Mit Fehlerbehandlung ist hier etwa Folgendes gemeint.

  • Eine Fehlermeldung auf dem Bildschirm anzeigen
  • Bei HTTP einen 4xx- / 5xx-Status zurückgeben
  • Nur diesen einen Eintrag als fehlgeschlagen behandeln und zum nächsten übergehen
  • Das betroffene Subsystem neu initialisieren
  • Den Prozess beenden und den Neustart dem Supervisor überlassen
  • Ressourcen freigeben und sicher aussteigen

Kurz: entscheiden, wie der Fehler aus Sicht des Aufrufers oder der Benutzerin aussieht.

2.4. Ausnahmen übersetzen

In der Praxis gibt es zwischen catch und „Behandeln“ noch eine weitere wichtige Aufgabe: die Übersetzung.

Lässt man zum Beispiel

  • HttpRequestException
  • IOException
  • JsonException
  • datenbanktreiberspezifische Ausnahmen
  • herstellerspezifische SDK-Ausnahmen

unverändert bis zur UI oder zum Controller durchsickern, beginnen die oberen Schichten, die internen Belange der unteren Implementierung zu kennen.

An der Grenze übersetzt man sie deshalb in Fehler, die auf dieser Ebene Bedeutung haben, etwa:

  • „Verbindung zum Zahlungsdienst konnte nicht hergestellt werden“
  • „Das CSV-Format war fehlerhaft“
  • „In das Ziel konnte nicht geschrieben werden“
  • „Die Antwort des Geräts war ungültig“

Wichtig ist hier, dass Übersetzung und Logging nicht dasselbe sind. Wer nur übersetzt und erneut wirft, schreibt normalerweise noch kein Primärlog.

3. Die Entscheidungstabelle zum ersten Nachschlagen

In diesem Artikel tauchen drei ähnlich aufgebaute Tabellen auf. Da sie unterschiedliche Rollen haben, hier vorab die Abgrenzung.

Tabelle Wann nachschlagen Was sie zeigt
Entscheidungstabelle in Kapitel 3 Beim Design. Wenn die Verantwortung jeder Schicht festgelegt wird Grundhaltung je Ort, ob ein Primärlog geschrieben wird, Hauptverantwortlichkeiten
Tabelle der Log-Stellen in Kapitel 6 Bei der Implementierung. Wenn die Hand beim Schreiben einer Log-Zeile stockt Für jede Fehlerart, wo mit welcher Stufe protokolliert wird
Grobe Übersicht in Kapitel 9 Beim Review. Zur abschließenden Kontrolle Kapitel 3 und 6, komprimiert auf die drei Spalten catch / Log / Fehlerbehandlung

Am einfachsten legt man zunächst mit dieser Tabelle die grobe Richtung fest.

Ort Grundhaltung Primärlog Hauptverantwortung
Helper / Utility / private Methode Grundsätzlich nicht breit catchen Nein Aufräumen via finally, lokaler Rollback, minimale Kontextanreicherung
Repository / Gateway / SDK-Wrapper Nur konkrete Ausnahmen abfangen Normalerweise nein Übersetzung von Ausnahmen, begrenzter Retry, Verwerfen von Verbindungen und Handles
Application Service / UseCase Erwartete Fehler in Ergebnisse umwandeln Beim Abfangen hier bei Bedarf Definition der Fehlereinheit, Behandlung von Teilfehlern, Entscheidungen auf Anwendungsfallebene
Grenze UI / Controller / API / Job / Message Haupt-Abnahmestelle für unerwartete Ausnahmen Hier meist das Primärlog Benutzerantworten, HTTP-Antworten, Fortsetzen mit nächstem Eintrag, Abbruchentscheidung
Handler für unbehandelte Ausnahmen / letzte Host-Grenze Letzte Linie gegen Verluste Critical Abschließende Aufzeichnung, Flush, Dump, Exit- / Neustartpfad

Als Diagramm sieht das ungefähr so aus.

NeinJaNeinJaNeinJaEine Ausnahme ist aufgetretenKann hier über Retry / Ergebnisumwandlung / Fortsetzbarkeit entschieden werden?Grundsätzlich nicht catchen, nach oben weiterreichenIst dies eine Schichtgrenze?Nur lokales AufräumenBei Bedarf in eine aussagekräftige Ausnahme übersetzenSind hier Fehlereinheit und Betriebskontext bekannt?Kein Primärlog schreiben, nach oben weiterreichenPrimärlog einmal schreiben und die Antwort festlegenBei Bedarf: beenden / neu initialisieren / mit nächstem Eintrag fortfahren

Das Diagramm macht zwei Punkte deutlich.

  1. Der erste Grund für catch ist Wiederherstellung oder Aufräumen — nicht das Logging.
  2. Der erste Grund zum Loggen ist, dass der Betriebskontext vorliegt — nicht, dass eine Ausnahme entdeckt wurde.

4. Was in welcher Ebene der Aufrufhierarchie zu tun ist

4.1. Der tiefste Helper / die tiefste Utility / private Methode

Hier gilt grundsätzlich: nicht breit abfangen.

Orte wie Stringkonvertierung, Parsing, Berechnung, interne Formatierung oder gemeinsame Helper können nicht entscheiden,

  • welche Bildschirmbedienung es war
  • welcher Request es war
  • ob es genügt, nur diesmal fehlzuschlagen
  • ob der ganze Bildschirm geschlossen werden soll.

Was diese Schicht tun darf, ist im Wesentlichen Folgendes.

  • Ressourcen in finally freigeben
  • Halb veränderten lokalen Zustand zurückrollen
  • Der Ausnahmemeldung minimalen Kontext hinzufügen
  • Durch einen passenderen Ausnahmetyp ersetzen
  • Nicht mehr wiederverwendbare Objekte verwerfen

Gemeinsam ist all dem: Es ist Aufräumarbeit, die korrekt ausgeführt werden kann, ohne den Aufrufer zu kennen. Nur Dinge, die keine Entscheidung erfordern, gehören hierher — alles, was eine Entscheidung braucht, wandert nach oben.

Zu vermeiden sind dagegen Schreibweisen wie diese.

  • catch (Exception) und null / false / ein leeres Array zurückgeben
  • Hier eine MessageBox anzeigen
  • Hier mit Error loggen und dann erneut werfen
  • „Einfach weitermachen“, obwohl der Zustand nicht wiederhergestellt werden kann

Besonders gefährlich ist das Muster, nach dem teilweisen Verändern des eigenen Zustands fehlzuschlagen und diesen Zustand trotzdem weiterzuverwenden. In diesem Fall gilt entweder: an Ort und Stelle zurücksetzen, wenn möglich, oder das Objekt als nicht mehr verwendbar behandeln.

4.2. Externe I/O-Grenze: Repository / Gateway / SDK-Wrapper

Hier ist der Grund für catch eindeutig.

Denn hier treten die Implementierungsdetails der darunterliegenden Schicht zutage.

  • Datenbanktreiber-Ausnahmen
  • HTTP-Kommunikationsausnahmen
  • Datei-I/O-Ausnahmen
  • COM- / P/Invoke- / herstellerspezifische SDK-Ausnahmen
  • Ausnahmen von Parsing-Bibliotheken und Serialisierern

Diese Schicht erledigt im Wesentlichen vier Dinge.

  1. Konkrete Ausnahmen abfangen Nicht ein breites Exception, sondern konkrete, aussagekräftige Ausnahmen.

  2. In einen aussagekräftigen Fehler übersetzen Damit die oberen Schichten die internen Details der unteren nicht direkt kennen müssen.

  3. Ein lokaler Retry gehört hierher, falls nötig Die Bedingungen sind allerdings streng:
    • Der Fehler ist als vorübergehend bekannt
    • Die Operation ist idempotent
    • Maximale Versuchsanzahl und Wartezeit sind festgelegt
    • Das Verhalten bei endgültigem Fehlschlag ist klar definiert Nur wenn alle vier Punkte zutreffen.
  4. Defekte Verbindungen und Handles verwerfen „Die Verbindung neu aufbauen“ ist oft sicherer als „mit demselben Objekt weitermachen“.

Die Logging-Strategie hier bleibt stabil, wenn man so denkt.

  • Wird nach oben erneut geworfen, schreibt man normalerweise kein Primärlog
  • Wird die Ausnahme hier abgefangen und in ein Ergebnis umgewandelt, werden die nötigen Logs und Metriken an dieser Stelle geschrieben
  • Einzelne Retry-Versuche bleiben im Bereich Debug / Information / Warning, nur der endgültige Fehlschlag wird deutlich festgehalten

Diese Schicht ist ein Ort der Übersetzung, in der Regel nicht der Ort der finalen Entscheidung.

4.3. Application Service / UseCase

Hier wird entschieden, „wie diese Arbeit fehlschlägt“.

Zum Beispiel:

  • ein Speichervorgang
  • eine Bestellbestätigung
  • ein CSV-Import
  • die Verarbeitung eines Batch-Eintrags
  • das Anwenden einer Nachricht

als Anwendungsfall zusammenhängende Einheiten liegen hier.

Diese Schicht kann Entscheidungen wie diese treffen.

  • Ein Validierungsfehler lässt nur diesen Versuch fehlschlagen
  • NotFound entspricht einem 404
  • Ein Verstoß gegen eine Geschäftsregel wartet auf eine Korrektur durch die Benutzerin
  • Eine fehlerhafte CSV-Zeile wird mit Warning protokolliert, die Verarbeitung läuft weiter
  • Ein vorübergehender Ausfall eines externen Dienstes lässt den gesamten Vorgang fehlschlagen
  • Zwischenergebnisse werden verworfen und von vorn begonnen

Kurz: Hier lässt sich die Fehlereinheit festlegen.

Diese Schicht eignet sich für Aufgaben wie:

  • Erwartete Fehler in ein Result oder ein Fehler-DTO umwandeln
  • Teilfehler aggregieren
  • Festlegen, wie viele Fehlschläge toleriert werden, bevor abgebrochen wird
  • Umwandeln in Fehlercodes oder benutzerseitige Nachrichtenschlüssel

Was diese Schicht dagegen nicht tun sollte, ist zu viel UI-Darstellung oder den Aufbau des HTTP-Antworttexts an sich zu ziehen. Die Trennung gelingt sauberer, wenn diese Schicht nur bis zur Bedeutung auf Ebene des Anwendungsfalls entscheidet und die endgültige Darstellung der Grenzschicht überlässt.

4.4. Grenze zu UI / HTTP / Job / Message

Hier liegt in vielen Anwendungen der Ort für das Primärlog.

Zum Beispiel solche Einheiten:

  • ein Klick auf „Speichern“ in WinForms / WPF
  • eine HTTP-Anfrage in ASP.NET Core
  • eine Nachricht in einem Worker
  • ein Eingabeeintrag in einem Batch
  • ein Lauf eines geplanten Jobs

Dieser Ort weiß:

  • was die Operation war
  • wessen Operation es war
  • der wievielte Eintrag es war
  • welcher Request / Batch / welche Nachricht es war
  • was bei einem Fehler an Benutzerin oder Aufrufer zurückgegeben werden soll

Diese fünf Dinge liegen in vielen Anwendungen nur an dieser Stelle gleichzeitig vor. Die darunterliegenden Schichten kennen zwar die technischen Details, aber nicht den Betriebskontext, und bis zum Handler für unbehandelte Ausnahmen ist die Fehlereinheit meist nicht mehr bekannt. Deshalb übernimmt diese Grenze häufig die Rolle,

  • unerwartete Ausnahmen hier gesammelt abzufangen
  • einmal ein Primärlog mit Kontext zu schreiben
  • in einen Fehlerdialog, HTTP 500, Problem Details, einen Job-Fehlschlag, das Fortfahren mit dem nächsten Eintrag und Ähnliches umzuwandeln.

Wichtig an dieser Schicht ist nicht das breite Abfangen selbst, sondern dass definiert ist, was nach dem breiten Abfangen zurückgegeben wird.

Bei Batches oder Queues hilft es, in zwei Stufen zu denken.

  • An der Grenze eines einzelnen Eintrags abfangen Entscheiden, ob nur dieser eine Eintrag fehlschlägt und weitergemacht wird
  • In der äußeren Schleife nicht breit alles unterdrücken Stirbt die äußere Schleife, sollte eher der gesamte Prozess neu gestartet werden

„Einträge einzeln fehlschlagen lassen und weitermachen“ und „die äußere Schleife bleibt trotz unerwarteter Ausnahme stillschweigend am Leben“ sind völlig verschiedene Dinge.

4.5. Der letzte Handler für unbehandelte Ausnahmen

Dies ist die letzte Verteidigungslinie. Kein magischer Wiederherstellungspunkt.

Typische Beispiele sind:

  • AppDomain.UnhandledException
  • WPFs Application.DispatcherUnhandledException
  • WinForms’ Application.ThreadException
  • Die Middleware und Handler zur Ausnahmebehandlung von ASP.NET Core
  • Die abschließende Ausnahmebehandlung von Generic Host / Workern / BackgroundService

Die Hauptverantwortung dieser Schicht beschränkt sich im Wesentlichen auf:

  • Das abschließende Log
  • Flush
  • Ein Pfad zur Dump-Erfassung
  • Sichern von Sitzungsinformationen und dem letzten Kontext
  • Einrichten von Exitcode und Neustartpfad

Umgekehrt sollte man hiervon nicht zu viel erwarten.

  • Ist es bis hierher durchgesickert, liegt meist eine Designlücke weiter oben vor
  • Der Zustand kann bereits beschädigt sein
  • Locks können gehalten werden, was schwere Verarbeitung hier riskant macht
  • Auch wenn ein Fortsetzen scheinbar möglich ist, heißt das nicht, dass es sicher ist

Für .NET gibt es dazu auch praktische Hinweise.

  • AppDomain.UnhandledException ist ein Ereignis zur Benachrichtigung und Aufzeichnung unbehandelter Ausnahmen. Zu viel Wiederherstellungslogik hier hineinzupacken, ist riskant.
  • WPFs DispatcherUnhandledException bietet die Möglichkeit, mit Handled = true scheinbar fortzufahren, aber zuerst muss beurteilt werden, ob eine Wiederherstellung überhaupt möglich ist.
  • Auch WinForms’ ThreadException kann die Anwendung nach der Behandlung in einem unbekannten Zustand zurücklassen.
  • Die Middleware zur Ausnahmebehandlung von ASP.NET Core muss früh in der Pipeline platziert werden, damit sie nachfolgende Ausnahmen abfangen kann.
  • Eine unbehandelte Ausnahme in einem BackgroundService wird ab .NET 6 protokolliert und stoppt standardmäßig den Host. Den Host zu stoppen und sich auf die Neustartstrategie zu verlassen, ist manchmal sicherer, als in der äußeren Schleife alles zu unterdrücken.

Besonders in Desktop-Anwendungen gibt es die Möglichkeit, „die unbehandelte Ausnahme abzufangen und fortzufahren“. Aber fortfahren zu können und fortfahren zu dürfen sind zwei verschiedene Dinge.

4.6. Entlang einer einzelnen Aufrufhierarchie betrachtet

Betrachten wir zum Beispiel diesen Ablauf.

Grenze zu UI / Controller / JobApplication Service / UseCaseDomain / FachlogikRepository / Gateway / SDK-WrapperDB / HTTP / Datei / Hersteller-SDK

Die Rollen verteilen sich dabei etwa so.

Speichern-Button → SaveOrderUseCasePaymentGateway → HTTP

  • PaymentGateway
    • Fängt Kommunikationsfehler und fehlerhafte Antworten ab
    • Übersetzt sie in „Verbindung zum Zahlungsdienst fehlgeschlagen“ bzw. „Antwort des Zahlungsdienstes ungültig“
    • Führt einen Retry, falls nötig, hier und unter Bedingungen durch
    • Schreibt beim erneuten Werfen normalerweise kein Primärlog
  • SaveOrderUseCase
    • Wandelt erwartete Fehler wie eine Zahlungsablehnung in Ergebnisse um
    • Behandelt es als „nur diese Bestellbestätigung ist fehlgeschlagen“
    • Bringt das Fehlerergebnis in eine Form, die UI oder API leicht zurückgeben können
  • UI-Button-Handler / Controller
    • Fängt unerwartete Ausnahmen gesammelt ab
    • Schreibt das Primärlog mit orderId, userId, requestId
    • Wandelt es in einen Dialog oder eine 500- / 503-Antwort um
  • Der Handler für unbehandelte Ausnahmen
    • Zeichnet nur das auf, was bis hierher durchgesickert ist
    • Führt Dumps und das abschließende Flush durch
    • Priorisiert den Exit-Pfad, nicht die Wiederherstellung

Mit dieser Aufteilung ergibt sich das Muster: technische Details werden unten gekapselt, Betriebskontext wird oben ergänzt, Entscheidungen fallen an der Grenze.

Was jede Schicht bei diesem Vorfall tatsächlich protokolliert

Konkretisiert man, was jede Schicht bei ein und demselben Fehler tatsächlich schreibt, wird die Aufteilung greifbar. Angenommen wird dieser eine Vorgang: „Der Zahlungsdienst hat zweimal ein Timeout geliefert, beim dritten Versuch war er erfolgreich, danach ist bei der Bestandsreservierung ein gebrochener Grundzustand aufgetreten.“

Schicht Geschriebenes Log Stufe Beispielmeldung
PaymentGateway Jeder Retry-Versuch Warning Verbindung zum Zahlungsdienst wird erneut versucht. attempt={Attempt}/{MaxAttempts}, orderId={OrderId}
PaymentGateway Beim Übersetzen und erneuten Werfen Kein Log – (Primärlog ist Aufgabe der Grenze)
SaveOrderUseCase Beim Umwandeln eines erwarteten Fehlers in ein Ergebnis Information Zahlung der Bestellung wurde abgelehnt. orderId={OrderId}, reason={DeclineReason}
SaveOrderUseCase Bei unerwarteter Ausnahme Kein Log – (wird direkt an die Grenze weitergereicht)
UI-Button-Handler / Controller Primärlog der unerwarteten Ausnahme Error Bestätigung der Bestellung fehlgeschlagen. orderId={OrderId}, userId={UserId} + Ausnahmeobjekt
Handler für unbehandelte Ausnahmen Abschließende Aufzeichnung Critical Prozess wird wegen unbehandelter Ausnahme beendet + Ausnahmeobjekt

Entscheidend ist: Error erscheint nur in genau einer Zeile. Jeder Retry-Versuch liegt auf Warning, der erwartete Fehler auf Information, sodass eine Suche nach Error diesen Vorfall genau einmal findet.

// Primärlog. Das Ausnahmeobjekt wird als erstes Argument übergeben,
// der Kontext der Fehlereinheit wird benannt mitgegeben
_logger.LogError(ex, "Bestätigung der Bestellung fehlgeschlagen. orderId={OrderId}, userId={UserId}",
    orderId, userId);

Wird vergessen, das Ausnahmeobjekt als erstes Argument zu übergeben, wird der Stack Trace nicht aufgezeichnet. Übergibt man wie in _logger.LogError(ex.Message) nur den Text, lässt sich die Ursache später nicht mehr nachvollziehen.

5. Erwartete Fehler von unerwarteten Ausnahmen trennen

Bei diesem Thema ist am wichtigsten, nicht alles gleichermaßen als „Ausnahme“ zu behandeln.

Zunächst diese Unterteilung.

Fehlerart Wo zuerst behandelt Typische Behandlung
Validierungsmangel Grenze UseCase / Request Als Eingabefehler zurückgeben
NotFound / Conflict UseCase / Controller 404 / 409 oder Bildschirmmeldung
Benutzerabbruch / Shutdown Operationsgrenze Als Abbruch behandeln. Normalerweise nicht Error
Fehlerhafte CSV-Zeile Zeilengrenze Mit Warning protokollieren, weiter fortfahren
Vorübergehendes Timeout, das letztlich fehlschlägt I/O-Grenze bis Request-Grenze Nach Retries als Fehlschlag zurückgeben
NullReferenceException, gebrochener Grundzustand Request- / Job-Grenze Primärlog und Fehlerantwort
AccessViolationException, schwerer OutOfMemoryException, Anzeichen für native Speicherverletzung Letzte Grenze Critical, eher beenden

Erwartete Fehler sind Fehler, die im Design bereits vorab entschieden werden können. Unerwartete Ausnahmen sind Fehler, nach denen fraglich ist, ob dem Zustand noch getraut werden kann.

Allein diese Trennung verringert Vorfälle wie:

  • NotFound jedes Mal als Error protokollieren
  • Benutzerabbruch als Ausfall behandeln
  • Einen wirklich gefährlichen gebrochenen Grundzustand als „diesmal nur fehlgeschlagen“ durchgehen lassen

6. Wo und wie oft sollte protokolliert werden?

Beim Log-Design ist wichtiger, wer das Primärlog schreibt, als wo genau catch steht.

Sechs Grundregeln gelten.

  1. Pro Fehler ein Primärlog auf Error / Critical
  2. Untere Schichten übernehmen bei Bedarf Übersetzung und Kontextanreicherung
  3. Die obere Grenze schreibt das Primärlog mit Fehlereinheit und Betriebskontext
  4. Nur die abfangende Schicht trägt die Aufzeichnungsverantwortung für den abgefangenen Fehler
  5. Erwartete Fehler werden nicht jedes Mal als Error protokolliert
  6. OperationCanceledException wird von gewöhnlichen Fehlerlogs getrennt gehalten

Hier eine grobe Tabelle der Log-Stellen. Während die Tabelle in Kapitel 3 festlegte, „welcher Schicht welche Verantwortung zukommt“, zeigt diese Tabelle, wo für welche Fehlerart welche Aufzeichnungsstufe gilt. Wer beim Implementieren nicht weiß, ob dieser catch-Block loggen soll, findet hier die Antwort.

Situation Hauptsächlicher Log-Ort Richtwert für die Stufe Anmerkung
Validierungsfehler Grenze Request / UseCase Information oder kein Log Kein Ausfall, sondern ein vertraglicher Fehler
Benutzerabbruch / Shutdown Operationsgrenze Debug / Information Normalerweise nicht Error
Vorübergehender Fehler während des Retry Schicht, die den Retry besitzt Debug / Warning Vor dem endgültigen Fehlschlag nicht zu viel Lärm
Retries ausgeschöpft, fehlgeschlagen Grenze Request / Job, oder die abfangende Schicht Warning / Error Mit Fehlereinheit aufzeichnen
Nur eine Zeile fehlerhaft, wird fortgesetzt Grenze des Eintrags Warning fileId, rowNumber anfügen
Unerwartete Ausnahme lässt den ganzen Request scheitern Grenze Request / UI / Job Error requestId, userId, entityId anfügen
Klasse Prozessende Grenze der unbehandelten Ausnahme Critical Flush, Dump, Neustartpfad

In der Praxis sehr häufig ist doppeltes Logging wie dieses.

  • Das Repository loggt Error
  • Der Service loggt dieselbe Ausnahme mit Error
  • Der Controller loggt erneut mit Error
  • Der letzte Handler für unbehandelte Ausnahmen loggt zusätzlich Critical

So entstehen bei einem einzigen Vorfall mehrere Kopien desselben Stack Trace nebeneinander. Wer liest, will nicht vier Kopien desselben Stack Trace, sondern ein Primärlog und bei Bedarf wenige unterstützende Logs.

Anders gesagt gilt als Grundregel: einmal loggen, mit so viel Kontext wie nötig.

7. Häufige Anti-Patterns

Im Folgenden werden Schreibweisen aufgelistet, die in Reviews immer wieder auftauchen. Zu den drei repräsentativsten gibt es minimalen NG- und OK-Code. Der Code setzt C# 10 / .NET 6 oder neuer sowie aktivierte nullable Referenztypen voraus und verwendet System.Text.Json und Microsoft.Extensions.Logging.

7.1. In einer tiefen Schicht catch (Exception) und null / false zurückgeben

Dabei geht die Information über die Ursache leicht verloren. Schlimmer noch: Der Aufrufer kann nicht mehr unterscheiden, ob „wirklich keine Daten vorhanden waren“ oder „etwas mittendrin kaputtgegangen ist“.

// NG: In einer tiefen Schicht breit abfangen und null zurückgeben
private static Order? LoadOrder(string path)
{
    try
    {
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<Order>(json);
    }
    catch (Exception)
    {
        // Der Aufrufer kann nicht unterscheiden, ob die Datei fehlte,
        // das JSON fehlerhaft war oder die Festplatte nicht gelesen werden konnte
        return null;
    }
}

Diese Schicht kann nicht entscheiden, wie der Fehler behandelt werden soll. Die Entscheidung wird an die Grenze weitergereicht, hier beschränkt man sich auf die Übersetzung in einen aussagekräftigen Fehler.

// Der von dieser Schicht geworfene, aussagekräftige Fehlertyp
public sealed class OrderFileFormatException : Exception
{
    public OrderFileFormatException(string message, Exception? innerException = null)
        : base(message, innerException)
    {
    }
}

// OK: Nur übersetzen, die Entscheidung geht an die obere Grenze
private static Order LoadOrder(string path)
{
    string json = File.ReadAllText(path);

    try
    {
        return JsonSerializer.Deserialize<Order>(json)
            ?? throw new OrderFileFormatException($"Die Bestelldatei ist leer: {path}");
    }
    catch (JsonException ex)
    {
        // JsonException, ein Detail der unteren Implementierung, wird hier
        // in einen für diese Schicht aussagekräftigen Fehler umgewandelt
        throw new OrderFileFormatException($"Das Format der Bestelldatei ist ungültig: {path}", ex);
    }

    // IOException und UnauthorizedAccessException werden nicht übersetzt,
    // sondern unverändert weitergereicht. „Die Datei kann nicht gelesen werden“
    // ist kein Fehler, dem diese Schicht Bedeutung hinzufügen kann
}

7.2. In jeder Schicht mit Error loggen und dann erneut werfen

Die häufigste Ursache für doppeltes Logging.

  • Untere Schichten übersetzen nur
  • Die obere Grenze schreibt das Primärlog

Diese Aufteilung reduziert das Problem erheblich.

// NG: Die untere Schicht loggt und wirft erneut. In der oberen Schicht wird
// dieselbe Ausnahme erneut geloggt, sodass zwei Kopien entstehen
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
    try
    {
        return await _gateway.ChargeAsync(payment, ct);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Zahlung fehlgeschlagen");
        throw;
    }
}

Die untere Schicht übersetzt nur und leitet weiter.

// PaymentGatewayException ist wie OrderFileFormatException ein eigener
// Ausnahmetyp, der ausdrückt, „die Kommunikation mit dem Zahlungsdienst ist fehlgeschlagen“

// OK: Die untere Schicht (PaymentGateway) übersetzt nur. Kein Log
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
    try
    {
        return await _gateway.ChargeAsync(payment, ct);
    }
    catch (HttpRequestException ex)
    {
        throw new PaymentGatewayException(
            $"Verbindung zum Zahlungsdienst konnte nicht hergestellt werden. orderId={payment.OrderId}", ex);
    }
}

An der Grenze, an der Fehlereinheit und Betriebskontext zusammenkommen, wird dann genau einmal das Primärlog geschrieben.

// OK: An der Grenze wird einmal das Primärlog geschrieben und die Antwort an den Aufrufer festgelegt
[ApiController]
public sealed class PaymentController : ControllerBase
{
    private readonly ILogger<PaymentController> _logger;
    private readonly SaveOrderUseCase _useCase;

    public PaymentController(ILogger<PaymentController> logger, SaveOrderUseCase useCase)
    {
        _logger = logger;
        _useCase = useCase;
    }

    [HttpPost("orders/{orderId}/pay")]
    public async Task<IActionResult> PayAsync(string orderId, CancellationToken ct)
    {
        try
        {
            Receipt receipt = await _useCase.ExecuteAsync(orderId, ct);
            return Ok(receipt);
        }
        catch (PaymentGatewayException ex)
        {
            // Nur hier liegen die Fehlereinheit (diese eine Zahlung) und der Betriebskontext vor
            _logger.LogError(ex, "Zahlung für Bestellung {OrderId} fehlgeschlagen", orderId);
            return StatusCode(StatusCodes.Status502BadGateway);
        }
    }
}

Wird in C# erneut geworfen, gilt als Grundregel, throw; zu verwenden, damit der Stack Trace nicht zerstört wird. Schreibt man stattdessen throw ex;, wird der Stack Trace in dieser Zeile überschrieben und die eigentliche Ursprungsstelle geht verloren.

7.3. Bibliotheksschicht oder gemeinsame Komponente zeigt UI direkt an

Zeigt eine gemeinsame Komponente eine MessageBox an oder entscheidet direkt über den Inhalt einer HTTP-Antwort, brechen sowohl die Wiederverwendbarkeit als auch die Trennung der Verantwortlichkeiten zusammen. Untere Schichten sind sicherer, wenn sie sich darauf beschränken, einen aussagekräftigen Fehler zurückzugeben.

7.4. OperationCanceledException als Ausfall mit Error loggen

Ein Abbruch ist Teil des Kontrollflusses. Wird er jedes Mal als Error protokolliert, gehen die echten Ausfälle darin unter.

// NG: Ein breites catch schließt auch den Abbruch ein und loggt ihn als Error
try
{
    await _useCase.ImportAsync(file, ct);
}
catch (Exception ex)
{
    // Selbst wenn die Benutzerin nur „Abbrechen“ gedrückt hat, landet man hier und Error erscheint
    _logger.LogError(ex, "Import fehlgeschlagen");
    throw;
}

Da catch-Blöcke von oben nach unten ausgewertet werden, fängt man den Abbruch zuerst mit einem konkreteren Typ ab. Eine when-Klausel hilft dabei, den durch das eigene Token ausgelösten Abbruch nicht mit einer OperationCanceledException aus anderen Gründen — etwa einem internen Timeout — zu verwechseln.

// OK: Der Abbruch wird zuerst abgefangen und vom Fehlerlog getrennt
try
{
    await _useCase.ImportAsync(file, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // Abbruch durch die Benutzerin oder Shutdown. Teil des Kontrollflusses, daher kein Error
    _logger.LogInformation("Import wurde abgebrochen. fileId={FileId}", file.Id);
}
catch (Exception ex)
{
    // Hierher gelangen nur unerwartete Fehler. Mit Kontext der Fehlereinheit
    // wird einmal das Primärlog geschrieben
    _logger.LogError(ex, "Import fehlgeschlagen. fileId={FileId}", file.Id);
    throw;
}

7.5. Bei externen Nebenwirkungen unbedacht erneut versuchen

Bei E-Mail-Versand, Zahlungen, Gerätebefehlen oder Dateiverschiebungen führt eine zweite Ausführung derselben Operation oft zu Problemen. Ein Retry gehört nur dorthin, wo sowohl vorübergehender Charakter als auch Idempotenz erkennbar sind.

7.6. Im letzten Handler für unbehandelte Ausnahmen alles reparieren wollen

Dies ist die letzte Absicherung. Kein Ort, den man ins Zentrum des Designs stellt.

Die Wiederherstellungsstrategie ist sicherer eine Schicht vorher angesiedelt — an der Grenze zu Request, Job oder Subsystem.

8. Checkliste für Reviews

Beim Review der Ausnahmebehandlung entstehen in dieser Reihenfolge die wenigsten Lücken.

  • Lässt sich in einem Satz sagen, was dieses catch entscheiden soll?
  • Kann an dieser Stelle wirklich über Retry / Ergebnisumwandlung / Fortsetzbarkeit / Benutzerantwort entschieden werden?
  • Wird derselbe Fehler auch in der oberen Schicht noch mit Error protokolliert, wenn hier geloggt wird?
  • Werden implementierungsspezifische Ausnahmen der unteren Schicht an der Grenze in aussagekräftige Fehler übersetzt?
  • Lässt sich ein halb kaputter Zustand hier zurücksetzen? Falls nicht, wird er als nicht mehr verwendbar behandelt?
  • Wird OperationCanceledException von gewöhnlichen Fehlern getrennt gehalten?
  • Ist klar, ob es sich um Fortsetzung je Eintrag, Fehlschlag je Request oder Prozessende handelt?
  • Wird vom letzten Handler für unbehandelte Ausnahmen Aufzeichnung erwartet, nicht Wiederherstellung?
  • Trägt das Log Kontext der Fehlereinheit wie requestId / userId / batchId / fileId / rowNumber?
  • Werden „erwarteter Fehler“ und „gebrochener Grundzustand“ gleich behandelt?

Am wirksamsten ist es in dieser Checkliste, sich jedes Mal in Worten klarzumachen, was dieses catch entscheidet. Lässt sich das nicht beantworten, ist der catch-Block meist überflüssig oder zu tief angesiedelt.

9. Grobe Übersicht

Zum Schluss eine zusammenfassende Tabelle, die Kapitel 3 und 6 auf ein Blatt komprimiert. Beim Review oder bei der abschließenden Durchsicht fertigen Codes genügt oft dieses eine Blatt.

Situation catch Log Fehlerbehandlung
Helper / Utility Grundsätzlich nicht Nicht Nicht
Repository / Gateway / SDK-Wrapper Nur konkrete Ausnahmen abfangen Normalerweise kein Primärlog Übersetzung, lokaler Retry, Verbindungen verwerfen
UseCase / Application Service Erwartete Fehler abfangen Bei Bedarf, falls abgefangen Ergebnisumwandlung, Teilfehlerbehandlung
Grenze UI / Controller / Request / Item / Job Unerwartete Ausnahmen breit abfangen Primärlog Antwort, Meldung, Fortsetzen / Abbruch
Handler für unbehandelte Ausnahmen Nur was durchgesickert ist Critical Abschließende Aufzeichnung, Exit-Pfad

Im Zweifel genügen diese fünf Punkte.

  1. In tiefen Schichten nicht breit zugreifen
  2. An Grenzen abfangen
  3. Ein Primärlog
  4. Die abfangende Schicht trägt die Verantwortung
  5. Die letzte unbehandelte Ausnahme bedeutet Aufzeichnung und Exit-Pfad

10. Zusammenfassung

Ausnahmebehandlung bedeutet nicht: „Wir können überall catchen, also catchen wir überall.“

Die Prüfreihenfolge ist im Wesentlichen diese, und sie genügt.

  1. Kann an dieser Stelle wirklich entschieden werden?
  2. Ist hier die Fehlereinheit bekannt?
  3. Lässt sich der Zustand hier zurücksetzen oder neu aufbauen?
  4. Führt das Loggen hier zu Duplikaten?
  5. Ist dies ein Wiederherstellungspunkt oder die letzte Aufzeichnungsstelle?

Wer in dieser Reihenfolge prüft, kann die Aufrufhierarchie deutlich leichter ordnen.

Besonders wichtig sind diese drei Punkte:

  • Tiefe Schichten: vor allem Übersetzung und Aufräumen
  • Grenzen: vor allem Entscheidungen und das Primärlog
  • Der letzte Handler für unbehandelte Ausnahmen: vor allem Aufzeichnung und Exit-Pfad

Anders gesagt gilt als Grundregel: Ausnahmen an Grenzen abfangen, Kontext anfügen und nur dort behandeln, wo eine Wiederherstellung möglich ist.

Ist das einmal festgelegt, werden Code-Reviews und Störungssuche deutlich verlässlicher.

12. Verwandte Artikel

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

In welcher Schicht sollten Ausnahmen abgefangen werden?
Das Prinzip lautet, in tiefen Schichten nicht breit zu catchen, sondern das Abfangen zu den Grenzen zu verlagern, an denen sich eine Fehlereinheit definieren lässt. Eine einzelne Bildschirmbedienung, eine HTTP-Anfrage, ein Job oder eine Nachricht sind natürliche Abnahmestellen für solche Prozessgrenzen. Maßgeblich ist nicht, wo man catchen kann, sondern wo man verantwortlich über Retry, Ergebnisumwandlung und Fortsetzbarkeit entscheiden kann. Tiefe Helper und Utilities beschränken sich auf Aufräumarbeiten in finally, lokale Rollbacks und die Übersetzung von Ausnahmen.
Sollte jede Schicht die Ausnahme protokollieren?
Für einen Fehler sollte grundsätzlich nur ein Primärlog auf Error- oder Critical-Ebene geschrieben werden. Wenn das Repository Error protokolliert, der Service dieselbe Ausnahme erneut mit Error protokolliert und der Controller sie noch einmal protokolliert, stehen bei einem einzigen Vorfall mehrere Kopien desselben Stack Trace nebeneinander, was das Lesen erschwert. Untere Schichten beschränken sich auf Übersetzung und das Anreichern von Kontext, während die obere Grenze, an der Betriebskontext wie requestId und userId zusammenkommt, das Primärlog schreibt. Nur die Schicht, die eine Ausnahme abfängt und in ein Ergebnis umwandelt, trägt die Aufzeichnungsverantwortung für diesen Fehler.
Wie trennt man erwartete Fehler von unerwarteten Ausnahmen?
Erwartete Fehler sind Fehler, die im Design bereits vorab entschieden werden können — Validierungsmängel oder NotFound werden auf Ebene des Anwendungsfalls in ein Ergebnis umgewandelt und nicht jedes Mal als Error protokolliert. Auch eine OperationCanceledException durch Benutzerabbruch wird normalerweise nicht als Error behandelt. Ein NullReferenceException dagegen, der einen gebrochenen Grundzustand anzeigt, wird an der Request- oder Job-Grenze mit einem Primärlog versehen und als Fehlerantwort behandelt, während AccessViolationException oder ein schwerwiegender OutOfMemoryException als Critical eingestuft und eher zum Beenden führen. Allein diese Trennung verringert Vorfälle, bei denen wirklich gefährliche Fehler untergehen.
Was sollte der Handler für unbehandelte Ausnahmen tun?
AppDomain.UnhandledException, WPFs DispatcherUnhandledException oder WinForms' ThreadException sind kein Wiederherstellungspunkt, sondern die letzte Aufzeichnungsstelle. Die Hauptaufgaben sind das abschließende Log, das Flushen, ein Pfad zur Dump-Erfassung sowie das Einrichten von Exitcode und Neustartpfad. Da an dieser Stelle der Zustand bereits beschädigt sein kann, bedeutet die scheinbare Fortsetzbarkeit nicht, dass ein Fortsetzen auch richtig ist. Die Wiederherstellungsstrategie gehört sicherer an die davorliegende Request- oder Job-Grenze.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog