Einhaltung von Invarianten mit dem Value Object Pattern

Zentraler Bestandteil eines mit Domain-Driven Design entwickelten Domänenmodells sind Objekte, die große Teile der Geschäftslogik repräsentieren. "heise Developer" zeigt, wie man Domänen-Objekte entwickelt, die keine ungültigen Zustände erreichen können.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 12 Min.
Von
  • Stefan Erras
  • Alexander Neumann
Inhaltsverzeichnis

Zentraler Bestandteil eines mit Domain-Driven Design (DDD) entwickelten Domänenmodells sind Objekte, die große Teile der Geschäftslogik repräsentieren. Beim Implementieren dieser sogenannten Domänenobjekte stellt sich häufig die Frage, wie sie sich validieren lassen. Der Artikel zeigt an einem einfachen Beispiel, wie man mit DDD Objekte entwickelt, die keine ungültigen Zustände erreichen können.

Um die komplexen Anforderungen an eine Geschäftsanwendung langfristig beherrschen zu können, ist es wichtig, Geschäftsdaten und -logik widerspruchsfrei und strukturiert in einer Applikation abzubilden. Um das zu erreichen, schlägt Eric Evans in seinem Buch "Domain-Driven Design: Tackling Complexity in the Heart of Software" [1] vor, alle fachlichen Aspekte einer Anwendung mit einem Domänenmodell zu implementieren, das sich möglichst frei von den technischen Rahmenbedingungen betrachten und realisieren lässt. Man löst ein fachliches Problem somit nicht auf technischer Ebene, sondern verarbeitet es mit dem Modell der Domäne. Das Hauptaugenmerk bei der Softwareentwicklung ist dabei auf dieses Domänenmodell zu richten.

Da sich die Objektorientierung in vielen Fällen für das Implementieren eines solchen Modells eignet, definiert Evans einige Begriffe und Design Patterns, die seiner Strukturierung beitragen können. Die Anwendung dieser Pattern-Sprache nennt man (in Anlehnung an den Buchtitel) "Domain-Driven Design".

Domänenobjekte sind als Einheit aus Daten und Verhalten zu verstehen, die von außen Befehle erhalten können. Es ist oft sinnvoll, diese Objekte so zu implementieren, dass sie das Ausführen eines Befehls verweigern, wenn dieser zu einem ungültigen Zustand führt. Diese Art der Implementierung hat folgende Vorteile:

  • Man kann alle Objekte im System jederzeit ohne weitere Prüfung verwenden oder an beliebige Subsysteme übertragen.
  • Häufig ist es einfacher und/oder performanter, innerhalb der einzelnen Objekt-Methoden zu überprüfen, ob die Ausführung mit den übergebenen Parametern zu einem ungültigen Zustand führen würde, als zu einem späteren Zeitpunkt den kompletten Zustand eines Objekts auf Gültigkeit zu prüfen (zum Beispiel durch Ausführen einer object.validate()-Methode). Das kommt vor allem zum Tragen, wenn untereinander abhängige Invarianten zu prüfen sind.

Mit Value Object bietet Evans ein Design Pattern an, das den Entwurf solcher Domänenobjekte einfacher gestaltet. Objekte, die diesem Entwurfsmuster entsprechen, haben drei wichtige Eigenschaften:

  • keine Identität: Das heißt, das Objekt ist nur durch seinen Dateninhalt definiert und nicht mit einem eindeutigen Schlüssel versehen. Value Objects gelten als gleich, wenn ihr Inhalt übereinstimmt.
  • Ein Value Object ist nach Erzeugen nicht mehr änderbar: Alle für die Konstruktion des Objekts nötigen Daten übergibt man dem Konstruktor.
  • Value Objects sind immer in einem gültigen Zustand zu erzeugen: Bei der Objekterstellung erfolgt eine Prüfung aller Eingabeparameter. Sind sie ungültig, verweigert die Prüfungslogik die Instanzierung.

Diese Eigenschaften erlauben es, Value Objects nahezu beliebig im System hin und her zu reichen, ohne unerwartete Seiteneffekte befürchten zu müssen. Im Domänenmodell unterstützen Value Objects vor allem die eigentlichen Domänenobjekte bei ihrer Arbeit. Das geschieht beispielsweise, indem man Teilaspekte eines Domänenobjekts mit diesem Design Pattern auslagert.

Kaufvertrag ohne Value Object (Abb. 1)

Am Beispiel eines Kaufvertrags zwischen zwei Personen sei verdeutlicht, wie sich die Implementierung des Domänenobjekts "Kaufvertrag" mit einem Value Object verbessern lässt. Abbildung 1 zeigt das Modell des Kaufvertrags ohne Verwendung eines Value Object.

Für die Klasse "Kaufvertrag" sollen folgende Invarianten gelten:

  1. Jeder Vertrag hat immer einen Käufer und einen Verkäufer.
  2. Käufer und Verkäufer sind nicht dieselbe Person.
  3. Ist ein Unterzeichnungsdatum gesetzt, lassen sich die Vertragspartner und der Kaufpreis nicht mehr ändern.

Außerdem sollen alle Verträge und Personen im System über eine Nummer eindeutig identifizierbar sein. Auf technische Aspekte wie Persistenz verzichtet der Artikel bewusst, um das Beispiel nicht unnötig zu verkomplizieren.

In einem anämischen Objektmodell (vgl. [2]) könnte eine erste Version von "Kaufvertrag" so aussehen (Programmiersprache Java):

public final class Kaufvertrag {
private final long vertragsnummer;
private Person kaeufer;
private Person verkaeufer;
private int kaufpreis;
private Date unterzeichnungsdatum;

public Kaufvertrag() {
// Eindeutige ID generieren
this.vertragsnummer = IDGenerator.newId();
}
public long getVertragsnummer() {
return vertragsnummer;
}
public Person getKaeufer() {
return kaeufer;
}
public void setKaeufer(Person kaeufer) {
this.kaeufer = kaeufer;
}
public Person getVerkaeufer() {
return verkaeufer;
}
public void setVerkaeufer(Person verkaeufer) {
this.verkaeufer = verkaeufer;
}
public int getKaufpreis() {
return kaufpreis;
}
public void setKaufpreis(int kaufpreis) {
this.kaufpreis = kaufpreis;
}
public Date getUnterzeichnungsdatum() {
return unterzeichnungsdatum;
}
public void setUnterzeichnungsdatum(Date unterzeichnungsdatum) {
this.unterzeichnungsdatum = unterzeichnungsdatum;
}
}

Dabei ist klar, dass die oben genannten Invarianten nur durch Prüfung von außen einzuhalten sind.

Um die Bedingung der ersten Invariante innerhalb des Objekts zu prüfen, könnte man die Setter für Käufer und Verkäufer wie folgt erweitern:

public void setVerkaeufer(Person verkaeufer) {
if (verkaeufer == null) {
throw new KeinVerkaeuferException();
}

this.verkaeufer = verkaeufer;
}
// setKaeufer(...) wird analog erweitert.

Zusätzlich lässt sich folgender Konstruktor integrieren, um das Objekt in einem gültigen Zustand zu initialisieren:

public Kaufvertrag(Person kaeufer, Person verkaeufer) { 
setKaeufer(kaeufer);
setVerkaeufer(verkaeufer);

// Eindeutige ID generieren
this.vertragsnummer = IDGenerator.newId();
}

Zur Einhaltung der zweiten Invariante sind sowohl der Konstruktor als auch die beiden Setter für Käufer und Verkäufer um einen Identitätsvergleich der beiden Personen zu erweitern:

public Kaufvertrag(Person kaeufer, Person verkaeufer) {
if (kaeufer == null) {
throw new KeinKaeuferException();
}

if (verkaeufer == null) {
throw new KeinVerkaeuferException();
}

// Identitätsvergleich zwischen kaeufer und verkaeufer
if (kaeufer.equals(verkaeufer)) {
throw new KaeuferGleichVerkaeuferException();
}

// Eindeutige ID generieren
this.vertragsnummer = IDGenerator.newId();

this.kaeufer = kaeufer;
this.verkaeufer = verkaeufer;
}
public void setVerkaeufer(Person verkaeufer) {
if (verkaeufer == null) {
throw new KeinVerkaeuferException();
}

// Identitätsvergleich zwischen kaeufer und verkaeufer
if (verkaeufer.equals(this.kaeufer)) {
throw new KaeuferGleichVerkaeuferException();
}

this.verkaeufer = verkaeufer;
}
// setKaeufer(...) wird analog erweitert.

Diese Implementierung verhindert das Vertauschen von Käufer und Verkäufer und ist deshalb in der Praxis untauglich (es könnte vorkommen, dass die Vertragspartner nach einer Fehleingabe zu tauschen sind). Stattdessen führt man eine Methode zur gleichzeitigen Änderung der Vertragspartner ein:

public void vertragspartnerAendern(Person kaeufer, Person verkaeufer) {
if (kaeufer == null) {
throw new KeinKaeuferException();
}

if (verkaeufer == null) {
throw new KeinVerkaeuferException();
}

// Identitätsvergleich zwischen kaeufer und verkaeufer
if (kaeufer.equals(verkaeufer)) {
throw new KaeuferGleichVerkaeuferException();
}

this.kaeufer = kaeufer;
this.verkaeufer = verkaeufer;
}

Um die dritte Invariante einzuhalten, ist folgende Erweiterung durchzuführen:

public void vertragspartnerAendern(Person kaeufer, Person verkaeufer) {
if (unterzeichnungsdatum != null) {
throw new VertragBereitsUnterzeichnetException();
}
...
}

public void setKaufpreis(int kaufpreis) {
if (unterzeichnungsdatum != null) {
throw new VertragBereitsUnterzeichnetException();
}

this.kaufpreis = kaufpreis;
}

Kaufvertrag mit Value Object (Abb. 2)

Die Klasse ist jetzt relativ lang und enthält neben der Validierungslogik keinerlei Code. In einem realen Projekt müsste der Entwickler wahrscheinlich noch etliche Zeilen Code für die eigentlichen Aufgaben des Objekts integrieren. Mit einem Value Object kann man einige Zeilen der Validierungslogik in eine neue Klasse auslagern. Weil dies einen Namen benötigt, erhält das Projektteam gleichzeitig die Chance, die Sprache des Projekts (bei Evans "Ubiquitous Language" genannt) um einen neuen Begriff zu erweitern:

Käufer, Verkäufer und Preis sind in ein Value Object namens "Vertragsinhalt" auszulagern. Weiterhin lassen sich die Invarianten verfeinern:

Invarianten für "Kaufvertrag":

  1. Jeder Vertrag hat einen Vertragsinhalt.
  2. Nach Unterzeichnung lässt sich der Vertragsinhalt nicht mehr verändern.

Invarianten für "Vertragsinhalt":

  1. Käufer und Verkäufer sind vorhanden.
  2. Käufer und Verkäufer sind nicht dieselbe Person.

Da sich Value Objects nach ihrer Instanzierung nicht mehr ändern lassen, ist der Code nur wenige Zeilen lang:

public final class Vertragsinhalt {
private final Person kaeufer;
private final Person verkaeufer;
private final int kaufpreis;

public Vertragsinhalt(Person kaeufer, Person verkaeufer, int kaufpreis) {
if (kaeufer == null) {
throw new KeinKaeuferException();
}

if (verkaeufer == null) {
throw new KeinVerkaeuferException();
}

// Identitätsvergleich zwischen kaeufer und verkaeufer
if (kaeufer.equals(verkaeufer)) {
throw new KaeuferGleichVerkaeuferException();
}

this.kaufpreis = kaufpreis;
this.kaeufer = kaeufer;
this.verkaeufer = verkaeufer;
}
public Person getKaeufer() {
return kaeufer;
}
public Person getVerkaeufer() {
return verkaeufer;
}
public int getKaufpreis() {
return kaufpreis;
}
@Override
public boolean equals(Object obj) {
// Der Vergleich von Value Objects
// erfolgt auf Basis der Dateninhalte.
...
}
@Override
public int hashCode() {
// Auch die Berechnung des Hashcodes erfolgt
// anhand der Dateninhalte.
...
}
}

Lediglich der Konstruktor enthält Code zur Prüfung der Invarianten. Die Methoden hashCode() und equals(...) implementiert das Value Object auf Basis der Dateninhalte. Im Falle von equals(...) bedeutet dies, dass die Methode "true" zurückgibt, wenn die zu vergleichenden Objekte vom selben Typ sind und man sie mit denselben Daten initialisiert hat.

Auch die Klasse "Kaufvertrag" ist nun deutlich kürzer:

public final class Kaufvertrag {
private final long vertragsnummer;
private Vertragsinhalt vertragsinhalt;
private Date unterzeichnungsdatum;

public Kaufvertrag(Vertragsinhalt vertragsinhalt) {
setVertragsinhalt(vertragsinhalt);

// Eindeutige ID generieren
this.vertragsnummer = IDGenerator.newId();
}
public long getVertragsnummer() {
return vertragsnummer;
}
public Vertragsinhalt getVertragsinhalt() {
return this.vertragsinhalt;
}
public void setVertragsinhalt(Vertragsinhalt vertragsinhalt) {
// Der Vertragsinhalt kann nicht mehr geändert werden,
// sobald der Vertrag unterschrieben ist.
if (unterzeichnungsdatum != null) {
throw new VertragBereitsUnterzeichnetException();
}

if (vertragsinhalt == null) {
throw new KeinGueltigerVertragsinhaltException();
}

this.vertragsinhalt = vertragsinhalt;
}
public Date getUnterzeichnungsdatum() {
return unterzeichnungsdatum;
}
public void setUnterzeichnungsdatum(Date unterzeichnungsdatum) {
this.unterzeichnungsdatum = unterzeichnungsdatum;
}
}

Die beiden Invarianten für "Kaufvertrag" lassen sich durch zwei einfache if-Anweisungen in der Methode setVertragsinhalt(...) implementieren. Weitere Prüfungen sind nicht notwendig, weil nur diese eine Methode den Vertragsinhalt eines Kaufvertrags ändern kann. Eine von der Klasse "Kaufvertrag" unbemerkte Änderung des Vertragsinhalts ist demnach nicht realisierbar.

Das Einführen des Value Object hat den Code modularisiert und die Projektsprache bereichert. Das Aufteilen des Codes förderte Erweiterbarkeit, Wartbarkeit und Verständlichkeit der Software. Auch die Invarianten ließen sich aufteilen, was ebenfalls zur Verständlichkeit der Software und seiner Anforderungen beigetragen hat (da es sich bei der Klasse "Kaufvertrag" um ein Domänenobjekt handelt, ist zu bedenken, dass die Zahl der Invarianten und der enthaltenen Geschäftsregeln in einem realen Projekt deutlich größer wäre). Außerdem kommt die Aufteilung der Testbarkeit des Codes zugute. Vor allem das Value Object lässt sich relativ einfach testen, weil es seinen Zustand nicht ändern kann.

Allerdings hat das Value Object den Nachteil, dass man beim Ändern eines Attributs des Vertragsinhalts ein neues Objekt erzeugen muss, auch wenn die anderen Attribute unverändert bleiben. Dieser Umstand ist in der Praxis aber meist zu vernachlässigen.

Durch die Aufteilung lassen sich gegebenenfalls künftige Anforderungen leichter formulieren wie: "Das System soll es ermöglichen, einen neuen Vertrag auf Basis eines bestehenden Vertrags zu erzeugen. Dabei wird der Vertragsinhalt des Ursprungsvertrags übernommen." Realisieren lässt sich diese Anforderung durch einen Zweizeiler:

Kaufvertrag neuerVertrag = new
Kaufvertrag(ursprungsvertrag.getVertragsinhalt());

Da Value Objects unveränderbar sind und keine Identität haben, können sich beide Verträge eine Instanz des Vertragsinhalts teilen, ohne sich gegenseitig zu stören. Es sind keine Daten zu kopieren oder Objekte zu klonen. Durch geschicktes Ausnutzen dieses Umstands lässt sich der oben beschriebene Nachteil beim Ändern des Vertragsinhalts sogar mehr als kompensieren. Andererseits ließe sich dem neuen Vertrag auch eine neue Instanz von Vertragsinhalt zuweisen, die man mit den Ursprungsdaten initialisiert hatte. Für den logischen Programmablauf würde das wegen der Eigenschaften eines Value Object keinen Unterschied darstellen.

Die Unveränderbarkeit von "Vertragsinhalt" stellt außerdem sicher, dass das Objekt "Kaufvertrag" bei Änderung seines Vertragsinhalts informiert wird, weil in diesem Fall Kaufvertrag.setVertragsinhalt(...) aufzurufen ist.

Stefan Erras
ist selbstständiger Softwarearchitekt und -entwickler. Der Schwerpunkt seiner Arbeit liegt in der Erstellung geschäftskritischer Anwendungen.

  1. Eric Evans: Domain-Driven Design: Tackling Complexity in the Heart of Software, Addison-Wesley 2003
  2. AnemicDomainModel (Martin Fowler, 25. November 2003)