lars
webmobiledatenbankendevopsarchitektur
hello (at) larskoelpin.de

Java Code und Validierung

September 09, 2022
javaarchitecture

In Java-Anwendungen werden häufig Klassen als Datenhalter verwendet. Diese besitzen nur Attribute und getter und setter. Warum das ein Problem ist und wie das Problem gelöst werden kann, will ich in diesem Beitrag ausführen.

Als Beispiel soll folgender Usecase dienen. Ein Todo einer Todo-App hat einen Zeitraum in dem dieses Todo aufjedenfall erledigt werden muss. Dazu kann der Nutzer ein Datum angeben, an dem er eine Erinnerung bekommen möchte. Außerdem kann er einen Text als Beschreibung angeben. (Zur Vereinfachung wird angenommen, dass jeglicher externer Client diese Daten ungefiltert setzen kann (z.B. durch eine HTTP-Schnittstelle).

Eine Implementierung sieht beispielsweise so aus.

class Todo {
    
    private Date startDate;
    private Date endDate;
    private Date preferredDate;
    
    private String descrption;
    private String email;

    public Date getStartDate() {
        return startDate;
    }

    public void setStartDate(Date startDate) {
        this.startDate = startDate;
    }

    public Date getEndDate() {
        return endDate;
    }

    public void setEndDate(Date endDate) {
        this.endDate = endDate;
    }

    public Date getPreferredDate() {
        return preferredDate;
    }

    public void setPreferredDate(Date preferredDate) {
        this.preferredDate = preferredDate;
    }

    public String getDescription() {
        return description;
    }

    public void setTodo(String todo) {
        this.todo = todo;
    }
}

Das Problem liegt klar auf der Hand: Unsere Klassen sind nur noch Datenhalter ohne jegliches Leben. Und das hat Folgen: Jegliche Validierung der Daten fällt komplett weg. Das führt in der Praxis dazu, dass wir Todos in jeglichen Formen erstellen können, ganz gleich ob es überhaupt Sinn ergibt, wie im Code-Abschnitt verdeutlicht.

Todo todo = new Todo();
todo.setStartDate(new Date("2022-09-10"); // StartDate vor EndDate?
todo.setEndDate(new Date("2022-09-08"); // EndDate vor StartDate?
// Preferred Date außerhalb der Range. Wir haben uns im Jahr vertan.
todo.setPreferredDate(new Date("2023-09-10"); 
todo.setDescription(""); // Komische Todo Beschreibung
todo.setDescription(null); // Das ist eigentlich ein Pflichtfeld
todo.setEmail("'DROP TABLE USERS;"); // nette emailadresse

Was ist das Problemm invalide Daten zu verarbeiten? Die Antwort ist ganz klar Komplexität. Aus Komplexität folgen Bugs und aus Bugs folgt Arbeitswaufwand. Außerdem hift frühe Validierung beim Debuggen, wenn bei Fehlern in den Daten nicht der gesamte Stacktrace der Verarbeitung durchschaut werden muss.

Nehmen wir an das obrige Todo wird weiter verarbeitet, kann man in den Kommentare mögliche Fehler erkennen. Zu erkennen ist: es sind viele Stellen die sich potenzieren: Von Exceptions bis hin zu falschen Programmfluss kann alles dabei sein.

public doSmthWithTodo(Todo todo) { // das Todo ist absolute invalide

    if (todo.getDescription().startsWith("IMPORTANT")) {...} // Crasht: Wir haben es grade genullt.
  
    // Exception: Externer Service kann die Daten nicht verarbeiten. TimeSpan stimmt nicht.
    // Noch schlimmer: Externer Service akzeptiert Request und liefert einfach leere Daten. 
    // Das Programm würde schlichtweg falsch funktionieren. Viel Spaß beim debuggen.
    externalService.requestAdditionalDataForTimespan(todo.getid(), todo.getStartDate(), todo.getEndDate())
    someRepoitory.save(todo.getEmail()) // hoffentlich sind wir SQL-Injection safe. (siehe oben)
    emailService.sendEmail(todo.getEmail()) // :)
}

Um den Code vor diesen Fehlern zu schützen, muss also validiert werden. Zunächst in jeder Nutzenden Klasse oder Methode von Todos

public doSmthWithTodo(Todo todo) {
    
    if (todo.getDescription() === null) { // Invariante Sichergestellt
        throw new InvalidTodoException(); 
    }
    
    if (todo.getDescription().equals("")) { // Invariante Sichergestellt
        throw new InvalidTodoException()
    }
    
    if (!todo.getPreferredDate().isBetween(startDate, endDate)) { // Invariante Sichergestellt
         throw new InvalidTodoException()
    }
    
    if(startDate.isAfter(endDate)) { // Invariante Sichergestellt
         throw new InvalidTodoException()
    }
    
    if (!EmailUtils.isEmail(email)) { // Invariante Sichergestellt
     throw new InvalidTodoException()
    }

    if (todo.getDescription().startsWith("IMPORTANT")) {...}
    if (new Date().equals(todo.getPreferredDate())) {} 
    externalService.requestAdditionalData(todo.getid(), todo.getStartDate(), todo.getEndDate())
    emailService.sendEmail(todo.getEmail()) // :)
}

Folgt jetzt der nächste Usecase, beginnt das gesamte Prozedere von vorne: Jede Stelle, die ein Todo als Parameter annimmt, muss sicherstellen, ob es sich überhaupt um ein valides Todo handelt.

Jedes mal muss der implementierende Programmierer hinterfragen, ob das Typ-System nicht gerade lügt, wenn die IDE grade ein startDate anbietet, was eigentlich ein endDate ist, oder ob die Description nicht gerade auch null sein kann, oder gar eine Beschreibung aus ?=)%!? enthält, obwohl die Software das gar nicht vorsieht. Doppelter Code und unnötige Komplexität sind die Folge. Häufig die Geburt von “*Util” und “*Helper”.

Doch wie kann die Komplexität vermieden werden? Der erste Schritt ist dafür zu sorgen, dass das Todo niemals einen invaliden Zustand erreichen kann. Also die Invarianten der Entitäten immer eingehalten werden. Dazu soll der Default-Konstruktor vermieden werden und Pflichtfeld-Konstruktor eingeführt sowie in den Settern / Mutationen validiert werden.

class Todo {
    
    private int id;
    private Date startDate;
    private Date endDate;
    private Date preferredDate;
    
    private String email;
    private String description;
    
    private void validateState() {
     if (this.getDescription() === null) {
        throw new InvalidTodoException()
      }
    
    if (this.getDescription().equals("")) {
        throw new InvalidTodoException()
    }
    
    if (!this.getPreferredDate().isBetween(startDate, endDate)) {
         throw new InvalidTodoException()
    }
    
    }
    
    Todo(Date startDate, Date endDate, Date preferredDate, String descr) {
       this.validateState()
    }
    
    setDescrption(d: String) {
     this.description = d;
     validateState();
    }
    
    // ...

Value Objects

Nachdem also jetzt die Todo-Entity immer valide ist, muss nicht an vielen Stellen, sondern nur noch an einer einzigen validiert werden und Todo-Abhängige Klassen und Funktionen können sich die Validierungslogik sparen.

Häufig besitzen nicht nur Entitäten, sondern auch dessen Attribute Invarianten. Im Beispiel oben kann ein Todo einer Email-Adresse zugewiesen werden. Im Beispiel gibt es beispielsweise einen emailService der mittels einer send Methode, eine Nachricht an eine Email-Adresse senden kann. Dass die Signatur eine String Variable annimmt, die emailAdresse heißt, hilft nicht wirklich.

public void sendEmail(String emailAdresse) {...}

DROP DATABASE users;, ?=)=! und äää sind Strings, aber es sind keine Emailadressen. Der Datentyp bei den Beispielen stimmt, die Invarianten nicht.

Um das Problem zu lösen, hilft das Konzept der Value-Objekte. Value Objekte sind Objekte, die Daten und dessen Invarianten als eigene Klassen abbilden. In Java kann dies wie im Folgenden Code durch eine Komposition abgebildet werden. Mutations, also setter, sind verboten. Stattdessen muss bei jeglichen Veränderungen eine neue Instanz durch das new-Keyword erstellt werden.

Ein Beispiel für das Einführen von Value-Objekten ist eine Email-Adresse, welche folglich als Attribut und Parameter beliebig verwendet werden kann.

class EmailAdress {
  private String value;
  public EmailAdress(String rawValue) {
  
    if (!emailRegex.match(rawValue)) {
        throw new InvalidValueException() // <-- Dieser Code wird immer durchlaufen, wenn es um EmailAdressen geht.
    }
    this.value = Objects.requireNonNull(rawValue);
  }
}

class EmailService {
    ...
    public void sendEmail(EmailAdress emailAdresse) {...} // <- Validierte EmailAdresse statt String
}

class Todo {
    ...
    private EmailAdress email; // <- Validierte EmailAdresse statt String
    
}

Weitere häufige Vertreter

  • Firstname
  • Lastname
  • Username
  • Street
  • ZipCode

Das Einführen von Value-Objects hat den Vorteil, dass der Code explizit wird. Expliziter Code ist gut, da er Validierungs-Komplexität vermindert und Konzepte, die eventuell im Variablennamen leben, in das Typsystem hebt. Dadurch kann der Compiler aktiv mithelfen validierung zu forcieren. Programmierer und Kollegen können damit ihren Daten vertrauen — Das schafft Vertrauen in den Code und verhindert vermeidbare Bugs frühzeitig.

Jetzt fehlt Java eigentlich nur noch der Non-Nullable type …