Railway Oriented Programming in Java

Fachlichkeit in Java programmieren ohne Exception Handling, geht das? Eine Idee aus der funktionalen Programmierung macht es möglich. Beim Railway Oriented Programming wird der Datentyp Monade verwendet, um den erfolgreichen oder fehlerhaften Ausgang einer Operation zu kapseln. Das mag sich hochtrabend anhören, aber der resultierende Code wird lesbarer und verständlicher.

Know-how  –  167 Kommentare
Railway Oriented Programming in Java

Fachliche Logik setzen Entwickler am besten durch kurze und prägnante Methoden um, die auf einem hohen Abstraktionsniveau das Problem beschreiben und lösen. Häufig enthalten diese Methoden aber zwischen den eigentlich interessanten Anweisungen viele rein technische Notwendigkeiten zum Behandeln von Fehlersituationen.

Als Beispiel hierfür zeigt das folgende Codestück eine Methode zum Ändern des Passworts eines Benutzers. Dazu werden sein Datensatz anhand seines Benutzernamens aus dem Repository geladen, das Passwort geändert und sein Profil am Ende wieder gespeichert.

public User changePassword(
Username username, Password oldPassword, Password newPassword) {
User user = this.userRepository.find(username);
if (user.isCorrectPassword(oldPassword)) {
user.changePassword(newPassword);
this.userRepository.update(user);
}
return user;
}

In der Praxis können allerdings mehrere Fehlersituationen auftreten

  • username, oldPassword oder newPassword könnten "null" sein.
  • Zum angegebenen Benutzernamen findet sich kein Benutzer.
  • Das alte Passwort stimmt nicht mit dem angegebenen überein.
  • Beim Zugriff auf die Datenbank tritt ein Fehler auf.

Der folgende Code zeigt das gleiche Beispiel, allerdings inklusive der Behandlung obiger Fehler:

public User changePassword(
Username username, Password oldPassword, Password newPassword)
throws UserNotFoundException, InvalidPasswordException,
DatabaseException {
if (username == null) {
throw new IllegalArgumentException("Username cannot be empty");
}
if (oldPassword == null) {
throw new IllegalArgumentException("Old password cannot be empty");
}
if (newPassword == null) {
throw new IllegalArgumentException("New Password cannot be empty");
}

User user = null;
try
{
user = this.userRepository.find(username);
if (user == null) {
throw new UserNotFoundException(username);
}

if (!user.isCorrectPassword(oldPassword)) {
throw new InvalidPasswordException(username, oldPassword);
}
user.changePassword(newPassword);
this.userRepository.update(user);
}
catch (DatabaseException e) {
this.logger.error("Password could not be changed due to
database error");
throw e;
}
return user;
}

Aus dem einfachen Algorithmus ist ein komplexes Monstrum mit verschachtelter Logik geworden. Diese Methode ist nicht nur schwieriger zu verstehen, sie versteckt auch den eigentlichen fachlichen Anwendungsfall hinter vielen technischen Anweisungen zur Fehlerbehandlung. Sicherlich ließe sich die Methode refaktorisieren und zum Beispiel auf mehrere kleinere aufteilen (was in der Praxis dringend anzuraten ist), aber das eigentliche Problem wird dadurch nicht behoben: Code zur Fehlerbehandlung ist eng verwoben mit der fachlichen Logik.

Dass es auch anders geht, zeigt das folgende Codebeispiel. Es implementiert die komplette Fehlerbehandlung, aber auf eine Art und Weise, die den Code kurz und prägnant hält und den fachlichen Anwendungsfall nicht versteckt, sondern sogar verständlicher macht:

public Result<User> changePassword(
Username username, Password oldPasswort, Password newPassword) {
return Result.of(username, "Username cannot be empty")
.combine(Result.of(oldPassword, "Old password cannot be empty"))
.combine(Result.of(newPassword, "New password cannot be empty"))
.onSuccess(() -> userRepo.find(username))
.ensure(user -> user.isCorrectPassword(oldPassword), "Invalid
password")
.onSuccess(user -> user.changePassword(newPassword))
.onSuccess(user -> userRepo.update(user))
.onFailure(() -> logger.error("Password could not be changed"))
.map(user -> user);
}

Statt Exceptions für Fehlersituationen oder "null" für nicht vorhandene Werte zu nutzen, setzt der obige Code auf den generischen Datentypen Result. Er repräsentiert das Ergebnis einer Aktion: Sie kann entweder erfolgreich oder fehlgeschlagen sein. Die verwendeten Methoden combine(), onSuccess(), ensure(), onFailure() und map() liefern fortwährend ein Result-Objekt zurück. Dadurch lässt sich der gesamte Ablauf des Anwendungsfalls als eine einzige Kette an Methodenaufrufen programmieren (s. Fluent API), ohne Fallunterscheidungen einsetzen zu müssen. Das Result-Objekt selbst verhält sich nämlich intern wie eine Weiche für Bahnschienen: Ist es erfolgreich, wird der eine Weg gewählt (und der übergebene Code ausgeführt), ansonsten der andere. Dieses Vorgehen gibt dem Verfahren seinen Namen: Railway Oriented Programming (ROP). Im Folgenden sollen die Ideen hinter dieser Entwicklungsmethode vorgestellt und die einzelnen Bestandteile der Klasse Result erarbeitet werden.