Error handling: Excepties vs Either

Stel je moet een rest controller maken die een brief moet versturen waarbij de volgende logica gebruikt moet worden:

1: haal klant op (kan mis gaan, dus foutmelding afvangen)
2: roep functie aan die aangeeft of het een zakelijke klant is
3: zoek huisadres of werkadres (kan niet bestaan, dan fout teruggeven)
4: Genereer brief (kan mis gaan, dus foutmelding afvangen)
5: Verstuur brief (kan mis gaan, dus foutmelding afvangen)

Voor deze logica heb ik 4 verschillende implementaties gemaakt om deze te kunnen vergelijken.

Implementatie 1 : Gebruik checked exceptions voor de fouten

Gegeven de volgende hulp functies:

private Optional<Klant> zoekKlant(int id)
private boolean isZakelijkeKlant(Klant klant)
private Adres findPriveAdres(Klant klant)
private Optional<Adres> findWerkAdres(Klant klant)
private Brief genereerBrief(Adres adres, Klant klant, String body) throws BriefException
private VerstuurResult verstuurBrief(Brief brief) throws SendException

Dan ziet de functie voor de logica er zo uit:

private VerstuurResult sendBrief(int id, String body) throws KlantException, AdresException, BriefException, SendException {
  // haal klant op, throw Exceptie als niet gevonden
  final Optional<Klant> maybeKlant = zoekKlant(id);
  if (maybeKlant.isEmpty()) { throw new KlantException("Klant niet gevonden"); }
  final Klant klant = maybeKlant.get();

  // zoek of deze klant een zakelijke klant is
  final boolean isZakelijkeKlant = isZakelijkeKlant(klant);

  // zoek adres (afhankelijk van zakelijk of niet), als geen adres gevonden, throw een Exceptie
  final Optional<Adres> mayBeAdres = isZakelijkeKlant ? findWerkAdres(klant) : Optional.of(findPriveAdres(klant));
  if (mayBeAdres.isEmpty()) { throw new AdresException("Adres niet gevonden"); }
  Adres adres = mayBeAdres.get();

  // Genereer een brief (deze functie gooit zelf een exceptie als er iets misgaat)
  Brief brief = genereerBrief(adres, klant, body);

  // verstuur de brief (deze functie gooit zelf een exceptie als er iets misgaat)
  return verstuurBrief(brief);
}

En de controller zo:

@RequestMapping("/brief1")
private ResponseEntity<String> briefController() {
  try {
    String result = sendBrief(0, "Bief inhoud").getResult();
    return ResponseEntity.ok(result);
  } catch (KlantException | AdresException | BriefException | SendException ex) {
    return ResponseEntity.status(500).body(ex.getMessage());
  }
}

Voordelen van deze manier zijn:

  • de code van de losse functies zijn makkelijk te begrijpen

En de nadelen:

  • checked excepties zorgen er voor dat we geen stream en lambda’s kunnen gebruiken
  • De “sendBrief” waarin de echte logica zit is slecht te lezen
  • De kans is groot dat er stacktraces in je logging komen voor niet-systeem fouten

Implementatie 2 : Gebruik unchecked exceptions voor de fouten

Gegeven de volgende hulp functies (zelfde als Implementatie 1, maar nu zijn de Excepties unchecked):

private Optional<Klant> zoekKlant(int id)
private boolean isZakelijkeKlant(Klant klant)
private Adres findPriveAdres(Klant klant)
private Optional<Adres> findWerkAdres(Klant klant)
private Brief genereerBrief(Adres adres, Klant klant, String body) throws BriefException
private VerstuurResult verstuurBrief(Brief brief) throws SendException

Dan ziet de functie voor de logica er zo uit:

private VerstuurResult sendBrief(int id, String body) {
  return zoekKlant(id).flatMap(klant -> {
    final boolean isZakelijkeKlant = isZakelijkeKlant(klant);
    final Optional<Adres> mayBeAdres = isZakelijkeKlant ? findWerkAdres(klant) : Optional.of(findPriveAdres(klant));
    return mayBeAdres.map(adres -> verstuurBrief(genereerBrief(adres, klant, body)));
  }).orElseThrow(() -> new KlantException("Klant niet gevonden"));
}

En de controller zo:

@RequestMapping("/brief2")
private ResponseEntity<String> briefController() {
  try {
    String result = sendBrief(0, "Bief inhoud").getResult();
    return org.springframework.http.ResponseEntity.ok(result);
  } catch (KlantException | AdresException | BriefException | SendException ex) {
    return org.springframework.http.ResponseEntity.status(500).body(ex.getMessage());
  }
}

Voordelen van deze manier zijn:

  • De code van de losse functies zijn makkelijk te begrijpen
  • We kunnen nu wel streams en lambda’s gebruiken zodat we de “sendBrief” functie veel duidelijker kunnen schrijven

En de nadelen:

  • Je weet niet welke excepties er gegooit kunnen worden. De kans is erg groot dat een foutsituatie niet goed wordt afgevangen.
  • De kans is groot dat er stacktraces in je logging komen voor niet-systeem fouten

Implementatie 3 : Gebruik het Either type uit de vavr library voor de fouten

Gegeven de volgende hulp functies (alle functies die ook fouten kunnen teruggeven geven een Either terug ipv direct de return value):

private Either<String, Klant> zoekKlant(int id
private boolean isZakelijkeKlant(Klant klant)
private Adres findPriveAdres(Klant klant)
private Option<Adres> findWerkAdres(Klant klant)
private Either<String, Brief> genereerBrief(Adres adres, Klant klant, String body)
private Either<String, VerstuurResult> verstuurBrief(Brief brief)

Dan ziet de functie voor de logica er zo uit:

private Either<String, VerstuurResult> sendBrief(int id, String body) {
  return zoekKlant(id).flatMap(klant -> {
    boolean isZakelijkeKlant = isZakelijkeKlant(klant);
    Option<Adres> mayBeAdres = isZakelijkeKlant ? findWerkAdres(klant) : Option.of(findPriveAdres(klant));
    return mayBeAdres.toEither("")
        .flatMap(adres -> genereerBrief(adres, klant, body)
        .flatMap(brief -> verstuurBrief(brief))
        );
  });
}

En de controller zo:

@RequestMapping("/brief3")
private ResponseEntity<String> briefController() {
  return sendBrief(0, "Bief inhoud")
      .fold(
          err -> ResponseEntity.status(500).body(err),
          result -> ResponseEntity.ok(result.getResult())
      );
}

Voordelen van deze manier zijn:

  • Het is veel duidelijker welke fouten er in het systeek kunnen ontstaan
  • De “sendbrief” methode is beter te lezen dan de “checked exception” variant van de code

En de nadelen:

  • Het is even wennen om het Either type te gebruiken
  • De “sendbrief” methode is minder duidelijk dan de “unchecked exception” variant van de code

Implementatie 4 : Gebruik Kotlin en het Either type uit de Arrow library voor de fouten

Gegeven de volgende hulp functies:

private fun zoekKlant(id: Int): Either<String, Klant>
private fun isZakelijkeKlant(klant: Klant): Boolean
private fun findPriveAdres(klant: Klant): Adres
private fun findWerkAdres(klant: Klant): Option<Adres>
private fun genereerBrief(adres: Adres, klant: Klant, body: String): Either<String, Brief>
private fun verstuurBrief(brief: Brief): Either<String, VerstuurResult>

Dan ziet de functie voor de logica er zo uit:

private fun sendBrief(id: Int, briefBody: String): Either<String, VerstuurResult> {
    return Either.fx<String, VerstuurResult> {
        val (klant) = zoekKlant(id)
        val zakelijkeKlant = isZakelijkeKlant(klant)
        val (adres) = findAdres(zakelijkeKlant, klant)
        val (brief) = genereerBrief(adres, klant, briefBody)
        val (result) = verstuurBrief(brief)
        result
    }
}

private fun findAdres(zakelijkeKlant: Boolean, klant: Klant) =
        if (zakelijkeKlant) findWerkAdres(klant).toEither { "Adres niet gevonden" }
        else Either.right(findPriveAdres(klant))

En de controller zo:

@RequestMapping("/brief4")
private fun sendBriefController(): ResponseEntity<String> {
    return sendBrief(0, "Dit is een brief")
            .fold(
                    { err -> ResponseEntity.status(500).body(err) },
                    { result -> ResponseEntity.ok(result.result) }
            )
}

Voordelen van deze manier zijn:

  • Het is veel duidelijker welke fouten er in het systeek kunnen ontstaan
  • De “sendbrief” is zeer goed te lezen

En de nadelen:

  • Het is even wennen om het Either type te gebruiken

 

Sourcecode:

https://github.com/robbertvdzon/eithervsexceptions