Exceptions

Als software-ontwikkelaar heb je constant te maken met exceptions: gooien, afvangen, herstellen, meldingen tonen aan de eindgebruiker enzovoorts. Omdat je er zo veel mee te maken hebt, is het erg fijn als je er op een prettige manier mee kan werken.

Maar helaas zie ik het regelmatig ‘mis’ gaan, waardoor je op een erg moeilijke manier -of helemaal niet- van een bepaalde exception kan herstellen. Dat is niet fijn voor jouw ervaring als ontwikkelaar. Maar ook niet voor je eindgebruiker, want die krijgt de fout ‘Er is iets mis gegaan’ waar hij of zij niks mee kan. Ik ga een aantal fouten toelichten en uitleggen hoe ik die oplos. Daarmee zorg ik voor zowel een prettige ontwikkelaarservaring (DX) als een aangename eindgebruikerservaring (UX).

Misbruik van de \Exception-class

De meest gemaakte fout die ik zie, is dat er overal de \Exception-class wordt gebruikt (of één van de andere base-classes zoals bijvoorbeeld \RuntimeException). Het probleem daarmee is dat je geen idee hebt wat er mis is gegaan: praktisch iedere exception is een instantie van deze class. Daardoor kun je dus niet bepalen of je van deze exception kan herstellen, of dat de gebruiker een foutmelding te zien moet krijgen.

Neem het volgende voorbeeld uit de badcow/dns-library:

/**
 * @throws \Exception
 */
public function fromText(string $text): void
{
    $iterator = new \ArrayIterator(explode(' ', $text));

    while ($iterator->valid()) {
        $matches = [];
        if (1 !== preg_match('/^(?!)?[1-2]:(?.+)$/i', $iterator->current(), $matches)) {
            throw new \Exception(sprintf('"%s" is not a valid IP range.', $iterator->current()));
        }

        $ipBlock = IPBlock::create($matches['block']);
        $this->addAddressRange($ipBlock, '!' !== $matches['negate']);
        $iterator->next();
    }
}

Als ik een formulier heb waar de gebruiker een IP-range invoert die niet geldig is, hoe kan ik deze exception dan afvangen en daar een nette melding van maken?

Dat kan alleen door alle exceptions af te vangen, te controleren of het bericht ‘is not a valid IP range’ bevat en zo niet de exception opnieuw gooien. Dat ziet er dan zo uit:

try {
    $apl->fromText(...);
} catch (\Exception $e) {
    if (str_contains($e->getMessage(), 'is not a valid IP range')) {
        // Error tonen aan gebruiker ...
    } else {
        throw $e;
    }
}

Terwijl het ook zo had gekund:

try {
    $apl->fromText(...);
} catch (\InvalidIpRange $e) {
    // Error tonen aan gebruiker ...
}

Het tweede voorbeeld leest een stuk prettiger, heeft minder code nodig en is een stuk robuuster. Stel de library past de exceptionmessage aan, dan werkt het eerste voorbeeld opeens niet meer. En daar zal je niet snel achterkomen, omdat het statisch niet te analyseren valt. Als de library de Exception-class aanpast, verwijdert of verplaatst dan weet je dat vrijwel direct: door statische analysators of door een ‘class not found’-foutmelding. Een Exception-class is een contract tussen jou en de library – een string niet.

Exceptionmessage (vertaald) aan eindgebruikers tonen

Wat ik ook vaak zie is dat de message van een exception getoond wordt aan de eindgebruiker. Vaak gecombineerd met een poging tot het vertalen van die message. Dat vind ik een slechte oplossing. Niet alleen omdat de library de string kan aanpassen. Maar vaak staan er ook nog eens variabelen in (zoals in het vorige voorbeeld) die het vertalen moeilijker maken (regex gebruiken ontkom je praktisch niet aan). Daarnaast kunnen die variabelen gevoelige informatie vrijgeven, zoals de naam van een database of pad van een bestand. Of is de message in zijn geheel onvoorspelbaar, zoals sommige HTTP-libraries die de gehele HTML-pagina als message in de exception stoppen. En sommige exceptions zouden helemaal niet voor moeten kunnen komen op productie.

Kortom: exceptionmessages zijn niet voor eindgebruikers, maar voor ontwikkelaars.

Onjuist gebruik van de \LogicException (en extensions)

De LogicException is een goed voorbeeld van een exception die nooit voor gebruikers is, maar juist voor de softwareontwikkelaars. Als we op php.net lezen (https://www.php.net/manual/en/class.logicexception.php) waarvoor deze exception gebruikt moet worden staat er letterlijk: This kind of exception should lead directly to a fix in your code. Gebruikers kunnen nooit jouw code aanpassen en zouden deze nooit te zien mogen krijgen. Oftewel, deze exceptions zouden niet eens op productie voor moeten kunnen komen.

De \LogicException heeft een aantal childclasses zoals bijvoorbeeld de \InvalidArgumentException en de \DomainException, wiens namen erg misleidend zijn. Valideren van een rapportcijfer 12, terwijl dat tussen de 1 en 10 moet zijn, is inderdaad een invalid argument. Dus de \InvalidArgumentException gebruiken is dan erg verleidelijk. Maar een andere input is geen wijziging in jouw code, dus de \InvalidArgumentException hier gebruiken is onjuist. Dat is juist een domein-exception, maar dan niet de \DomainException -want ook dat is weer een child van de \LogicException!

Gebruik van de Exception-code (foutcode)

Om eerlijk te zijn heb ik geen idee waar je de foutcodes voor gebruikt. Ik heb zelf nog nooit een geval gehad waarbij ik dacht: dat is handig! Maar wat ik wel weet, is dat het gebruik van de foutcodes voor het onderscheiden van verschillende exceptions, een erg ongemakkelijke interface biedt.

Vaak ziet een dergelijke implementatie er als volgt uit:

class DomeinException extends \Exception {
    public const GEBRUIKER_IS_AL_TOEGEVOEGD = 1;
    public const GEBRUIKER_NIET_GEACTIVEERD = 2;
    public readonly ?int $gebruikerId;

    private function __construct(int $code)
    {
        parent::__construct(null, $code, null);
    }

    public static function gebruikerIsAlToegevoegd(int $id): self
    {
        $e = new self(DomeinException::GEBRUIKER_IS_AL_TOEGEVOEGD);
        $e->gebruikerId = $id;
        return $e;
    }
    public static function gebruikerNietGeactiveerd(int $id): self
    {
        $e = new self(DomeinException::GEBRUIKER_NIET_GEACTIVEERD);
        $e->gebruikerId = $id;
        return $e;
    }
}

Om alleen te reageren op het ‘gebruiker is al toegevoegd’-geval, dan moet je de volgende constructie gebruiken:

try {
    $gebruikersGroep->voegGebruikerToe(...);
} catch (DomeinException $e) {
    if ($e->getCode() === DomeinException::GEBRUIKER_IS_AL_TOEGEVOEGD) {
        // TODO: Afhandelen ...
    }
    else {
        throw $e;
    }
}

Deze aanpak heeft gelukkig het voordeel dat je dit specifieke geval netjes kan afvangen. Maar je hebt er onnodig veel code voor nodig. Het maken van de Exception-class is erg omslachtig en ook het afvangen van de exception heeft een if-statement nodig. En dan moet je niet vergeten in de else de originele exception weer op te gooien.

Daarnaast heb je nog het probleem dat, als je één Exception-class hebt, de class ontzettend groot wordt en veel mergeconflicten zal opleveren omdat iedereen op verschillende branches deze class uitbreidt. Als je wel meerdere classes hebt, kun je geen gebruik maken van overerving. Want alle foutcodes uniek houden over alle child-classes is nagenoeg onmogelijk.

Een ander groot probleem is het gebrek aan typesafety in de parameters. Je hebt namelijk geen enkele garantie welke foutcode welke parameters beschikbaar heeft. Dat levert extra if-statements en extra codepaden op, wat de leesbaarheid en complexiteit niet ten goede komt.

Mijn oplossing

De aanpak die ik hanteer komt het beste tot zijn recht in een applicatie waar de gelaagde architectuur goed geïmplementeerd is. Maar ook in applicaties waar een andere architectuur wordt gebruikt zijn de principes toepasbaar.

De Exceptions die het belangrijkste zijn voor de gebruiker komen vrijwel alleen voort uit de domeinlaag. Dat zijn dus domeinexceptions waarvoor ik een DomainException-interface definieer. Die interface extend de \Throwable-interface. Vaak maak ik ook een abstracte implementatie van die interface waar ik de message, code en previous weghaal, omdat ik die vrijwel nooit gebruik. Iedere fout die er in het domein kan optreden, krijgt zijn eigen class die de DomainException-interface implementeert.

Is de domeinlaag nog eens verder opgedeeld in Boundex Context’s (BC)? Dan maak ik voor iedere context ook een abstracte class en/of interface voor domeinexceptions uit die BC.

Uiteraard zijn de namen van de classes in de taal van het domein. Voor een Nederlands domein dus ‘DomeinException’ 😉

Daarmee los ik dus al twee fouten op: het misbruiken van de \Exception-clas en het gebruiken van foutcodes. Maar, aangezien deze exceptions zo belangrijk zijn voor de gebruiker, hoe toon ik die aan de eindgebruiker?

Daar zijn twee manieren voor: een statische functie op de DomainException-interface die een Translator als argument ontvangt zodat iedere Exception zichzelf kan vertalen. Of een iets puristischere manier: het maken van een DomainExceptionTranslator. Die accepteert een instantie van de DomainException-interface als argument en geeft een (vertaalde) string terug.

Persoonlijk vind ik de tweede manier prettiger. Dan kan ik namelijk snel Exception-classes aanmaken (vaak staat daar namelijk niks in; de naam van de exception geeft meestal voldoende informatie) zonder direct na te denken over de vertaling. Maar de eerste manier is ook prima. Zo lang het maar consistent is in het project.

Nog meer fouten

Naast fouten uit het domein heb je nog fouten die vanuit de rest van de applicatie voortvloeien. Maar die zijn niet zo breed af te vangen als de domeinexceptions en vergen dus iets meer individuele aandacht. Maar dan nog blijft de strekking: ‘iedere uitzondering zijn eigen class’ gelden. Wat voor deze vaak wel geldt is dat ze globaal afgehandeld kunnen worden. Het toevoegen van een record terwijl de tabel gelockt is, kan bijvoorbeeld afgehandeld worden door het een aantal keer opnieuw te proberen met een timeout ertussen. Mocht het daarna nog steeds niet lukken, dan zit er niks anders op dan de gebruiker de melding: ‘Er is iets mis gegaan, probeer het later nog eens’ te laten zien. Maar je hebt de kans dat dit überhaupt voorkomt al aanzienlijk verminderd, waardoor dat verwaarloosbaar is.

En de LogicExceptions dan? Die gebruik je alleen maar om je mede-ontwikkelaars het leven makkelijker te maken. Wil je weten hoe? In mijn volgende blog vertel ik je daar meer over!

Silvan Wakker
Technologie