Ein Objekt für jeden Zustand

Wie wäre es, wenn wir jeden Zustand durch ein eigenes Objekt repräsentieren? Das ist übrigens die Idee, die auch dem State-Pattern zugrunde liegt. Und es ist gut mit der Unveränderlichkeit vereinbar, die wir für Wertobjekte brauchen. Also, legen wir los.

Wir benennen unser Wertobjekt IBAN um in UnvalidatedIBAN, weil wir zunächst noch nicht geprüft haben, ob es das entsprechende Bankkonto überhaupt gibt. Du merkst vielleicht, dass wir den Begriff "unvalidated" hier in der fachlichen Bedeutung verwenden: eine Kontonummer, von der wir nicht wissen, ob das Konto existiert. Das hat nicht wirklich mehr etwas mit dem "Daten validieren" zu tun, von dem wir im Alltag gerne sprechen.

Dich verwirren diese Wortklaubereien? Gut, denn das ist der erste Schritt auf dem Weg zum richtigen Verständnis!

Ergänzend dazu haben legen wir ValidatedIBAN an, mit dem wir ein existierendes Bankkonto repräsentieren:

<?php declare(strict_types=1);

class ValidatedIBAN
{
    private UnvalidatedIBAN $iban;

    public function __construct(UnvalidatedIBAN $iban)
    {
        $this->iban = $iban;
    }

    public function asString(): string
    {
        return $this->iban->asString();
    }
}
ValidatedIBAN.php

Hier müssen wir im Konstruktor nichts mehr (syntaktisch) prüfen, weil das bereits unser UnvalidatedIBAN getan hat. Die gleiche Guard Clause hier nochmal zu wiederholen, wäre unerwünschte Codeduplikation.

Wie kommen wir nun von einer nicht validierten IBAN zu einer validierten IBAN? Ganz einfach: unser UnvalidatedIBAN-Objekt erzeugt die ValidatedIBAN, und zwar als Ergebnis der eigentlichen Validierung, die wir hier erst mal geflissentlich weglassen:

public function validate(): ValidatedIBAN
{
    return new ValidatedIBAN($this);
}
UnvalidatedIBAN.php

Wir können nun je nach Situation eine der beiden Klassen UnvalidatedIBAN oder ValidatedIBAN als Typ deklariern. Das hat den großen Vorteil, dass wir nichts durcheinanderbringen und etwa Geld nach Nirvana überweisen können, weil unsere Überweisungsmethode nur eine ValidatedIBAN akzeptiert. Dass ich diese als Entwickler auch "fälschen" könnte, verbleibt natürlich als Restproblem. Aber das sollte dann zumindest in einem Code-Review auffallen.

Es könnte aber Fälle geben, in denen es nicht wichtig ist, ob wir mit einer validierten oder nicht validierten IBAN arbeiten. Das ist oft so, wenn Daten anzeigen wollen. In diesem Fall müssen wir eine Gemeinsamkeit zwischen beiden Klassen schaffen. Hierfür würden wir ein gemeinsames Interface IBAN verwenden.

Wir haben uns jetzt überhaupt nicht darum gekümmert, wie die eigentliche Validierung der IBAN (im Sinne von: existiert das Konto) funktioniert. Das ist kein Fehler, weil sich diese ja als Geschäftsprozess außerhalb von unseren Wertobjekten abspielt. Das Ergebnis dieses Prozesses werden wir irgendwann von extern, beispielsweise durch ein Event oder durch ein Kommando mitgeteilt bekommen. Zur Verarbeitung rufen wir dann die Methode validate() im UnvalidatedIBAN-Objekt auf.

Zusammengefasst: im Gegensatz zu "Validierung von Daten" haben wir es hier mit einer Validierung als Geschäftsprozess zu tun. Daher findet sich die Logik nicht im Wertobjekt selbst.

Im Domain-Driven-Design würden wir hier anmerken, dass ein Software-Entwickler unter "IBAN validieren" etwas ganz anderes versteht als etwa ein Mitarbeiter in der Finanzabteilung, die für Überweisungen zuständig ist. Ich würde das erste als "technische Validierung" bezeichnen und das zweite als "fachliche Validierung". Letzere geschieht immer außerhalb unserer Wertobjekte.

Ein Beispiel für "fachliche Validierung" ist das Senden einer Bestätigungsmail an einen Benutzer. Das kennen wir alle: "klicken Sie auf diesen Link, um ihre E-Mail-Adresse zu bestätigen". Das ist ein Prozess, der sich außerhalb unserer Software abspielt, der asynchron ist, und dessen Ergebnis in Form eines GET-Requests wieder in unserer Software ankommt.

Die europäische VAT-ID ist ein weiteres Beispiel für technische vs. fachliche Validierung: Ich kann im Code relativ einfach entscheiden, ob eine VAT-ID gültig aussieht, indem ich die Regeln der einzelnen Länder dafür programmiere. Diese Regeln sind sehr gut dokumentiert.

Um zu entscheiden, ob auf einer Rechnung Mehrwertsteuer ausgewiesen werden muss oder nicht (Reverse-Charge-Verfahren), sind wir verpflichtet, VAT-IDs explizit zu prüfen. Dafür gibt es einen eigenen Online-Service der Europäischen Kommission. Dieser Service gibt nach erfolgreicher Validierung einen Code zurück, den wir uns zum Nachweis merken sollten, beispielsweise indem wir ihn als zweiten Konstruktorparameter von ValidatedVatId erfordern.

So wird in unserer Anwendung das Ergebnis der fachlichen Validierung als zusätzliche Information repräsentiert. Genauso könnten wir das übrigens auch bei der Banküberweisung machen, indem wir den Cent mit einem Code im Betreff überweisen, den der Kunde dann in unsere Anwendung wieder eingeben muss. Wir dürfen dann davon ausgehen, dass nur derjenige, der Zugriff auf das Bankkonto (oder die Kontoauszüge) hat, diesen Code aus dem Betreff der Überweisung entnehmen kann.

In Bezug auf VAT-IDs könnten wir übrigens besonders viel Spaß mit Temporalität haben. Wie sieht es denn beispielsweise mit der Gültigkeit einer britischen VAT-ID aus?