Exception Driven Design. Mein Weg zur richtigen Fehlerbehandlung

Vor einiger Zeit war ich in einem Unternehmen, in dem ständig von dem Begriff „Exception Driven Design“ die Rede war. Allerdings hatte der Begriff immer einen negativen Beigeschmack.

Gemeint waren Sachen wie Fehler-Verschleierung. Also im Fehlerfall am besten nichts tun bzw. so tun als, ob nichts wäre. Gerne wurde auch versucht im catch-Block den Fehler zu behandeln. Ist das wiederum Schiefgegangen, wurde noch ein trycatch-Block hinzugefügt. Nicht selten waren solche Konstrukte vorzufinden:

try
{
    // 500 Zeilen Quellcode
}
catch (Exception)
{
    try
    {
        // Sensibele Operation
    }
    catch (Exception)
    {
        try
        {
            // Versuch irgendwie, irgendwas zu retten
        }
        catch (Exception)
        {
        }
    }
    try
    {
        // weitere Operationen
    }
    catch (Exception)
    {
    }
}

Überall wo ich ein Kommentar hinzugefügt habe, stand tatsächlich Business-Code. Man beachte bitte auch, dass immer nur ‚Exception‘ gefangen wurde und keine konkrete Exception.

Ich benenne es immer ganz hämisch mit „Feuerwehr-(Anti)-Pattern“. Man versucht irgendwas zu retten, was schon (so gut wie) verloren ist.

Allerdings fasziniert mich der Begriff „Exception Driven Design“ bis heute noch. Und zwar so sehr, dass ich mal versucht habe diesem Begriff etwas positives zu verleihen.

Wie wichtig ist ein gutes Exception Handling?

Ich möchte dir eine kleine Geschichte erzählen.

Es war einmal eine mittelgroße Software Bude. Nennen wir die mal McSoft (der Name ist frei erfunden!).

McSoft hatte eine Softwarelösung entwickelt und hatte ein astreines Marketing. Und so wuchs der Kundenstamm von McSoft immer weiter.

Immer mehr Anforderungen kamen. Anforderungen, die „am besten Gestern“ umgesetzt werden mussten. Immer mehr Fehler schlichen sich in die Anwendung ein.

Und so hatte McSoft irgendwann eine relativ instabile Software, die jedes Mal abgestürzt ist, wenn man die nur schief angesehen hat.

Eines Tages kam der Senior Developer auf die glorreiche Idee, an jeder problematischen Stelle ein try-catch-Block hinzuzufügen.

McSoft hatte es geschafft. Sie haben endlich eine stabile Software auf den Markt gebracht. Die Kunden waren glücklich.

Jedoch waren die Fehler nicht wirklich weg. Sie waren nur versteckt.

Mit der Zeit kamen Datenverluste. Kundendatensätze wurden verfälscht. Daten sind auf einmal von einem Kunden zum anderen gewandert. Die (unsichtbaren) Fehler häuften sich.

Das größte Pech von McSoft war, dass der Kundenstamm, Anwälte waren.

McSoft gibt es nicht mehr. Du brauchst also nicht danach zu googeln 🙂

Was ist eine Exception?

Was ist das den für eine blöde Frage? Ein unerwartetes Verhalten in einer Applikation natürlich!

Klar, aber wo hört eine Exception auf?

Ist das eine Exception?

Ja, ist es.

Es ist ein unerwartetes Verhalten. Dieses kann und darf die Applikation aber nicht lösen.

Der nächste Schritt wäre jetzt, dass der Benutzer einen Administrator kontaktiert und ihn bittet ihm die nötigen Rechte zu verleihen. Auch das ist eine Exception. Vielleicht kann es der Admin sogar machen, darf es aber nicht. Er muss Rücksprache mit seinem Chef halten (ebenfalls Exception).

Der Chef behandelt dann diese Ausnahme, indem er die Anfrage verneint.

Merke: Eine Exception hört nicht zwangsläufig in der Applikation auf. Der Benutzer, ist eine ganz wichtige Schicht in der Softwarearchitektur.

Wie wird eine Exception nicht behandelt?

Nehmen wir mal an, wir haben eine Anforderung, die das Alter eines bestimmten Benutzers ermitteln soll.

Kein Exception-Handling

Die Methode dafür könnte in etwa so aussehen:

public int GetAgeOfUser(Guid id)
{
    User user = _dataContext.Single(u => u.Id == id);
    return user.Age;
}

Im Grunde genommen soweit alles i.O. Schaut man jedoch in die Dokumentation von Single wird man feststellen, dass diese Methode 2 Exceptions werfen kann.

Einmal die ArgumentNullException, wenn (in diesem Fall) _dataContext null ist und einmal eine InvalidOperationException sobald entweder kein oder mehr als ein Element in der Sequenz vorhanden sind.

Magic Values

Eine mögliche Lösung wäre sowas hier:

public int GetAgeOfUser(Guid id)
{
    try
    {
        User user = _dataContext.Single(u => u.Id == id);
        return user.Age;
    }
    catch (Exception)
    {
        return 0;
    }
}

Erhalte ich jedoch dieses Ergebnis, gehe ich davon aus, dass die Person das erste Lebensjahr noch nicht vollendet hat. Auch eine -1 ist nicht gerade optimal.

Würde man das Durchschnittsalter einer Familie berechnen, wäre das Ergebnis nicht mehr korrekt.

Außerdem ist es auch nicht wirklich sinnvoll die Exception abzufangen. Schließlich weiß man nicht was genau schiefgelaufen sein könnte.

Exceptions Loggen

Besser als nur die Daten zu verfälschen, ist es die Fehler zu loggen.

public int GetAgeOfUser(Guid id)
{
    try
    {
        User user = _dataContext.Single(u => u.Id == id);
        return user.Age;
    }
    catch (Exception)
    {
        _logger.Error($"Benutzer mit der ID {id} wurde nicht gefunden.");
        return 0;
    }
}

Schaust du in die logs einer aktiven und funktionierenden Applikation rein? Am besten liegen die dann noch im Windows Temp Verzeichnis.

Und selbst wenn, kann man mit dieser Fehlermeldung nichts anfangen.

Ein weiteres Vorgehen ist es, eine Exception zu fangen, zu loggen und weiterzuwerfen.

Das Problem dabei ist jedoch, dass man sich das Log mit redundanten Informationen vollstopft und damit sogar die Fehlersuche erschwert als erleichtert.

Exception Handling – Make It Right

Fakt ist, diese Methode kann nicht entscheiden was im Fehlerfall passieren soll. Die sagt einfach nur „Ja, hier: 42“ oder „kann ich nicht“.

Fakt ist auch, dass wir wissen, welche Fehler auftreten können und ebenfalls darauf reagieren können.

/// <exception cref="EmptyUserDatabaseException">
/// Wird ausgelöst, wenn keine Benutzer existieren
/// </exception>
/// <exception cref="UnknownUserException">
/// Wird ausgelöst, wenn der Benutzer nicht eindeutig gefunden wurde
/// </exception>
public int GetAgeOfUser(Guid id)
{
    try
    {
        User user = _dataContext.Single(u => u.Id == id);
        return user.Age;
    }
    catch (ArgumentNullException anex)
    {
        throw new EmptyUserDatabaseException(
            "Es wurden keine Benutzer gefunden.", anex);
    }
    catch (InvalidOperationException iopex)
    {
        throw new UnknownUserException(
            $"Der Benutzer {id} konnte nicht eindeutig identifiziert werden.", iopex);
    }
}

Beachte bitte den zweiten Parameter der Exception. Ich gebe die vorhergehende Exception immer mit als zweites Argumment in einer Exception mit an. Auf diese Art und Weise habe ich immer noch den kompletten StackTrace und die vorhergehenden Exceptions für eine Fehleranalyse.

Wenn jetzt irgend etwas komisches passiert, wird der Benutzer direkt benachrichtigt und kann dann entweder darauf reagieren oder entsprechend weiter nach oben reichen.

Als einen kleinen Hinweis an den Aufrufer, gebe ich noch in der Dokumentation an, welche Exceptions unter welcher Bedingung geworfen werden können.

Merke: Eine Exception darf nur an der Stelle behandelt werden, an der es auch Sinn ergibt.

Diese beiden Exceptions würde ich persönlich bis auf die GUI werfen lassen und entsprechend in einem Fehlerdialog anzeigen lassen.

Fazit – Exception Driven Design

Denkt man an solche Begriffe wie „Domain Driven Design“ oder „Test Driven Design“ klingt der Begriff etwas zu überspitzt.

Allerdings finde ich, dass wir Softwareentwickler viel zu schlampig mit Fehler umgehen.

Auch sehe ich sehr häufig das genaue Gegenteil. Man hat Angst eine Exception zu werfen und versucht den Fehler an der Wurzel zu beheben. Jedoch ist es gerade da selten von Vorteil.

Exceptions sind aber eher unser Freund als unser Feind. Wenn man die Exceptions gut strukturiert, benannt und verwendet hat, helfen die Exceptions nicht nur dem Entwickler, sondern auch dem Benutzer enorm.