Gleichheit, Validierung und die Grenzen sauberer Objektmodelle
Wer Value Objects nur als hübschere Datenklassen versteht, verschenkt ihren eigentlichen Nutzen: Ein Value Object kapselt Gleichheit, Gültigkeit und Unveränderlichkeit an genau einer Stelle im Code, statt diese Regeln über Services, Controller und Formulare zu verstreuen. Dieser Artikel zeigt in reinem, frameworkunabhängigem PHP 8.4, wie man equals() statt naivem Vergleich einsetzt, Invarianten im Constructor erzwingt und mit readonly-Properties echte Unveränderlichkeit erreicht, ohne dabei die Balance zwischen zu kleinen und zu großen Objekten zu verlieren.
Inhaltsverzeichnis
- 1. Was ein Value Object ist und wie es sich von einer Entity unterscheidet
- 2. Gleichheit richtig modellieren: Wertevergleich statt Identitätsvergleich
- 3. Immutability konsequent durchsetzen: readonly und wither-Methoden
- 4. Validierung in der Konstruktion: Invarianten sicherstellen
- 5. Praxisbeispiel Money: Betrag und Währung als Value Object
- 6. Praxisbeispiel EmailAddress: Validierung, Normalisierung, Vergleichbarkeit
- 7. Grenzen von Value Objects: wann ein Objekt doch eine Entity ist
- 8. Value Objects und Serialisierung: JsonSerializable, Arrays, Collections
- 9. Entity und Value Object im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Value Object ist und wie es sich von einer Entity unterscheidet
Ein Value Object ist ein Objekt, das ausschließlich über seine Werte definiert wird, nicht über eine fortlaufende Identität. Zwei Instanzen mit denselben Attributen sind austauschbar und gleich, egal ob es sich um zwei getrennte Objekte im Speicher handelt. Eine Entity dagegen trägt eine Identität, meist eine ID, die über die gesamte Lebensdauer stabil bleibt, selbst wenn sich sämtliche Attribute ändern. Ein Kunde bleibt derselbe Kunde, auch wenn Name, Adresse und E-Mail-Adresse sich ändern, solange die Kunden-ID identisch bleibt.
Typische Vertreter dieses Konzepts in einer Domäne sind Geldbeträge, E-Mail-Adressen, Datumsspannen, Koordinaten und Prozentwerte. Typische Entities sind Kunde, Bestellung und Produkt, also Dinge, die über Zeit verfolgt werden und deren Identität wichtiger ist als der aktuelle Zustand ihrer Attribute. Der Unterschied klingt akademisch, hat aber konkrete Konsequenzen für den Code: So ein Objekt braucht keine Datenbank-ID, keinen Konstruktor mit optionalen Parametern und keinen Zustand, der sich nach der Erzeugung verändert.
Wer diese Unterscheidung ignoriert, landet häufig bei Primitive Obsession: Geldbeträge als float, E-Mail-Adressen als string, Datumsspannen als zwei lose DateTime-Parameter. Jede dieser Stellen im Code muss die Validierung, die Formatierung und die Vergleichslogik erneut selbst implementieren. Ein sauber geschnittenes Value Object bündelt all das an einer einzigen Stelle und macht ungültige Zustände über den Constructor unmöglich.
2. Gleichheit richtig modellieren: Wertevergleich statt Identitätsvergleich
Entities werden über ihre ID verglichen: Zwei Bestellungen mit derselben ID sind dieselbe Bestellung, unabhängig vom übrigen Zustand. Bei einem Value Object ist es umgekehrt: Zwei Instanzen sind gleich, wenn und nur wenn alle relevanten Eigenschaften übereinstimmen. PHP bietet mit dem ==-Operator zwar einen Vergleich, der bei Objekten rekursiv Klasse und öffentliche Eigenschaften prüft, doch für diesen Objekttyp ist eine explizite equals()-Methode die bessere Wahl. Sie dokumentiert die Absicht im Code, bleibt stabil, wenn private Properties hinzukommen, und erlaubt Sonderfälle wie Toleranzvergleiche bei Fließkommazahlen.
Das folgende Beispiel zeigt eine minimale Klasse mit expliziter Gleichheitsmethode:
declare(strict_types=1);
final class Point
{
public function __construct(
public readonly float $x,
public readonly float $y,
) {
}
// Value equality: compare state, not object identity
public function equals(self $other): bool
{
return $this->x === $other->x
&& $this->y === $other->y;
}
}
$a = new Point(1.5, 2.5);
$b = new Point(1.5, 2.5);
var_dump($a == $b); // true (PHP compares public properties)
var_dump($a === $b); // false (two distinct object instances)
var_dump($a->equals($b)); // true (explicit, documents the intent)
Der Unterschied zwischen == und === ist bei Value Objects besonders tückisch: === prüft Identität und liefert bei zwei separat erzeugten Instanzen fast immer false, selbst wenn die Werte identisch sind. == funktioniert oft zufällig richtig, versagt aber, sobald so ein Objekt selbst wieder andere Objekte als Eigenschaften enthält, deren ==-Vergleich nicht das gewünschte Verhalten liefert. Eine eigene equals()-Methode macht die Vergleichslogik explizit, testbar und unabhängig von PHPs eingebautem Verhalten, das sich zwischen Versionen und Objektgraphen unterschiedlich verhalten kann.
3. Immutability konsequent durchsetzen: readonly und wither-Methoden
Unveränderlichkeit ist keine akademische Zierde, sondern die Eigenschaft, die Gleichheit bei Value Objects überhaupt erst verlässlich macht. Wenn sich der Zustand eines Objekts nach der Erzeugung ändern kann, ist jeder Vergleich, jede Nutzung als Array-Key und jede gemeinsame Referenz in mehreren Services eine potenzielle Fehlerquelle. Ein solches Objekt, das einmal erzeugt wurde, sollte für die gesamte Lebensdauer denselben Zustand tragen. PHP 8.4 unterstützt das direkt über readonly-Properties in Kombination mit Constructor Property Promotion, ganz ohne zusätzliche Bibliotheken.
Änderungen an einem unveränderlichen Objekt dieser Art laufen über sogenannte wither-Methoden: Statt eines Setters, der den bestehenden Zustand mutiert, gibt eine Methode wie withEnd() eine komplett neue Instanz mit dem geänderten Wert zurück. Der Aufrufer entscheidet selbst, ob er das Original weiterverwendet oder das neue Objekt übernimmt, beide Instanzen bleiben unabhängig voneinander gültig und konsistent.
declare(strict_types=1);
final class DateRange
{
public function __construct(
public readonly \DateTimeImmutable $start,
public readonly \DateTimeImmutable $end,
) {
if ($start > $end) {
throw new \InvalidArgumentException('Start must be before end.');
}
}
// "Wither" method: returns a new instance instead of mutating this one
public function withEnd(\DateTimeImmutable $end): self
{
return new self($this->start, $end);
}
public function days(): int
{
return (int) $this->start->diff($this->end)->days;
}
}
$range = new DateRange(
new \DateTimeImmutable('2026-01-01'),
new \DateTimeImmutable('2026-01-10'),
);
$extended = $range->withEnd(new \DateTimeImmutable('2026-02-01'));
// $range stays untouched, $extended is a new, independent instance
Eine Falle bei readonly-Properties: Das Schlüsselwort verhindert nur das Neuzuweisen der Property selbst, nicht das Verändern eines darin enthaltenen veränderlichen Objekts. Wer versehentlich \DateTime statt \DateTimeImmutable in einem Value Object speichert, öffnet eine Hintertür für Mutationen von außen, die die gesamte Unveränderlichkeitsgarantie unterläuft. Für diesen Objekttyp gilt deshalb die Regel: Jede Property muss entweder ein Skalar, ein selbst unveränderliches Objekt oder ein weiteres eigenständiges, unveränderliches Objekt sein.
4. Validierung in der Konstruktion: Invarianten sicherstellen
Der wirkungsvollste Ort für Validierung bei einem Value Object ist der Constructor selbst, nicht ein nachgelagerter Validator-Service. Wenn der Constructor jede Invariante prüft und bei Verstoß eine Exception wirft, kann ein ungültiges Objekt dieser Art gar nicht erst entstehen. Es gibt keinen Zwischenzustand, in dem ein Objekt existiert, aber ungültig ist, weil die Validierung erst später aufgerufen wird. Dieses Prinzip nennt man häufig "Always-Valid Domain Objects": Sobald die Instanz existiert, ist sie per Definition gültig.
Das folgende Beispiel demonstriert eine Prozentangabe, die ihre Grenzen selbst durchsetzt:
declare(strict_types=1);
final class Percentage
{
private const float MIN = 0.0;
private const float MAX = 100.0;
public function __construct(
public readonly float $value,
) {
if ($value < self::MIN || $value > self::MAX) {
throw new \InvalidArgumentException(
sprintf('Percentage must be between %.1f and %.1f, got %.2f.', self::MIN, self::MAX, $value)
);
}
}
}
// Throws immediately; an invalid Percentage instance can never exist
new Percentage(142.0);
Für Fälle, in denen ein ungültiger Wert kein harter Fehler sein soll, sondern eine erwartbare Alternative im Kontrollfluss, bietet sich eine statische Fabrikmethode wie tryFrom() an, die statt einer Exception null zurückgibt. So bleibt der Constructor selbst weiterhin strikt, während der Aufrufer entscheiden kann, ob er einen harten Fehler oder eine weiche Rückgabe erwartet. Wichtig ist, dass die Validierungslogik in beiden Fällen an einer einzigen Stelle im Value Object gebündelt bleibt und nicht an mehreren Stellen im Aufrufercode dupliziert wird.
5. Praxisbeispiel Money: Betrag und Währung als Value Object
Geldbeträge sind das klassische Lehrbuchbeispiel für ein Value Object, weil naive Implementierungen mit float fast immer zu Rundungsfehlern führen. Eine robuste Money-Klasse speichert den Betrag als Integer in der kleinsten Einheit, also Cent statt Euro, und führt die Währung als zweites, gleichberechtigtes Attribut mit. Erst die Kombination aus Betrag und Währung ergibt Sinn, ein Betrag ohne Währung ist unvollständig und damit ein Verstoß gegen die Invariante des Objekts.
Rechenoperationen auf einem Money-Objekt dürfen den ursprünglichen Betrag niemals verändern, sie geben stattdessen immer eine neue Instanz zurück. Das folgende Beispiel zeigt eine vollständige Implementierung mit Gleichheitsprüfung, Additions- und Subtraktionsmethoden sowie einer Absicherung gegen das Vermischen unterschiedlicher Währungen:
declare(strict_types=1);
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
if (! preg_match('/^[A-Z]{3}$/', $currency)) {
throw new \InvalidArgumentException(sprintf('Invalid currency code "%s".', $currency));
}
}
// Value equality: same amount and same currency, nothing else matters
public function equals(self $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currency === $other->currency;
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
$newAmount = $this->amountInCents - $other->amountInCents;
if ($newAmount < 0) {
throw new \InvalidArgumentException('Resulting amount cannot be negative.');
}
return new self($newAmount, $this->currency);
}
public function isGreaterThan(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amountInCents > $other->amountInCents;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Cannot operate on Money with different currencies.');
}
}
public function __toString(): string
{
return sprintf('%.2f %s', $this->amountInCents / 100, $this->currency);
}
}
$price = new Money(1999, 'EUR');
$discount = new Money(200, 'EUR');
$final = $price->subtract($discount);
echo $final; // 17.99 EUR ($price itself remains completely unchanged)
Diese Implementierung eines Value Objects für Geldbeträge lässt sich in einem Warenkorb, einer Rechnung oder einer Preisberechnung überall dort einsetzen, wo bisher lose Floats und Strings für Währungscodes verwendet wurden. Die Absicherung gegen das Vermischen von Währungen in assertSameCurrency() verhindert Fehler, die mit reinen Float-Additionen still und unbemerkt passieren würden, etwa das versehentliche Addieren von Euro und Dollar zu einer scheinbar plausiblen, aber fachlich falschen Summe.
6. Praxisbeispiel EmailAddress: Validierung, Normalisierung, Vergleichbarkeit
Eine E-Mail-Adresse als reiner string zu behandeln, führt fast zwangsläufig dazu, dass Validierung und Normalisierung an mehreren Stellen im Code dupliziert werden, mit dem Risiko, dass eine Stelle vergessen wird. Eine EmailAddress-Klasse bündelt beides: Der Constructor validiert das Format über filter_var() und normalisiert gleichzeitig Groß-/Kleinschreibung und umgebende Leerzeichen, damit zwei syntaktisch unterschiedlich geschriebene, aber fachlich identische Adressen als gleich erkannt werden.
Die Normalisierung ist bei diesem Value Object besonders wichtig für die Gleichheitsprüfung: Ohne sie würden User@Example.com und user@example.com als unterschiedlich gelten, obwohl beide dieselbe Mailbox adressieren. Das folgende Beispiel implementiert Validierung, Normalisierung und eine equals()-Methode in einer einzigen, kompakten Klasse:
declare(strict_types=1);
final class EmailAddress implements \JsonSerializable
{
private readonly string $value;
public function __construct(string $value)
{
// Normalize before validating: trim whitespace, lowercase the address
$normalized = strtolower(trim($value));
if (! filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(sprintf('"%s" is not a valid email address.', $value));
}
$this->value = $normalized;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function jsonSerialize(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->value;
}
}
$a = new EmailAddress(' User@Example.com ');
$b = new EmailAddress('user@example.com');
var_dump($a->equals($b)); // true, normalization makes both representations equal
echo json_encode(['contact' => $a]); // {"contact":"user@example.com"}
Wichtig ist die Abgrenzung, was in den Constructor eines Value Objects gehört und was nicht: Syntaktische Validierung, also die Prüfung auf ein gültiges E-Mail-Format, gehört zwingend in den Constructor, weil sie eine Invariante des Werts selbst ist. Fachliche Prüfungen, etwa ob eine Domain tatsächlich existiert, ob ein MX-Eintrag gesetzt ist, oder ob die Adresse bereits in der Datenbank registriert ist, gehören dagegen in einen separaten Anwendungsdienst, weil sie externe Abhängigkeiten wie DNS-Auflösung oder Datenbankzugriffe voraussetzen, die eine solche Klasse niemals selbst besitzen sollte.
7. Grenzen von Value Objects: wann ein Objekt doch eine Entity ist
Value Objects sollten bewusst klein gehalten werden. Sobald ein Objekt anfängt, ein Dutzend Felder, mehrere unabhängige Verhaltensweisen und Beziehungen zu anderen Objekten zu tragen, verliert es die Klarheit, die ein gut geschnittenes Exemplar dieser Art ausmacht. Ein typisches Warnsignal ist, wenn ein vermeintlicher Wertetyp Methoden bekommt, die eigentlich zu einem Anwendungsdienst gehören, etwa das Versenden einer Benachrichtigung oder das Schreiben in eine Datenbank. Solche Verantwortlichkeiten gehören nicht in ein Objekt, dessen einzige Aufgabe die Kapselung von Werten und Invarianten ist.
Die entscheidende Frage zur Abgrenzung lautet: Interessiert mich, welche konkrete Instanz ich in der Hand halte, oder interessieren mich nur die Werte, die sie trägt? Bei einer Adresse, die einem Kunden als reines Attribut zugeordnet ist, reicht in der Regel diese Modellierung. Sobald dieselbe Adresse aber über die Zeit verfolgt werden muss, etwa weil ein Umzugsverlauf gespeichert wird oder mehrere Kunden dieselbe Lieferadresse referenzieren und Änderungen an einer Stelle sich überall auswirken sollen, braucht das Objekt eine eigene Identität und wird damit zu einer Entity.
Diese Grenze ist keine einmalige Entscheidung, sondern kann sich mit wachsenden Anforderungen verschieben. Ein gut geschnittenes Value Object lässt sich bei Bedarf relativ leicht zu einer Entity weiterentwickeln, wenn eine ID und ein Repository hinzukommen. Der umgekehrte Weg, eine überladene Entity nachträglich in kleinere, fokussierte Objekte zu zerlegen, ist in der Praxis deutlich aufwendiger, weshalb sich die bewusste Entscheidung am Anfang eines Features auszahlt.
8. Value Objects und Serialisierung: JsonSerializable, Arrays, Collections
Für die Ausgabe in einer API oder das Logging ist es sinnvoll, dass ein Value Object selbst weiß, wie es sich in ein einfaches Format überführt. Das Interface JsonSerializable mit der Methode jsonSerialize() übernimmt genau diese Aufgabe für json_encode(), wie im EmailAddress-Beispiel oben gezeigt. Eine __toString()-Methode ergänzt das für Logging-Ausgaben und Debug-Zwecke, sodass eine Instanz direkt in einen Log-Eintrag oder eine Exception-Message eingebettet werden kann, ohne dass der Aufrufer manuell auf einzelne Properties zugreifen muss.
Beim Vergleich mehrerer Value Objects in Arrays oder Collections lauert eine Falle: PHP-Funktionen wie array_unique() oder in_array() nutzen standardmäßig lose Vergleiche oder ==, nicht die eigene equals()-Methode. Wer solche Objekte in einem Array deduplizieren will, muss entweder manuell über die Elemente iterieren und equals() aufrufen, oder die Instanz über __toString() als eindeutigen Array-Key nutzen, sofern die String-Repräsentation die Gleichheit vollständig abbildet.
Für größere Domänenmodelle lohnt sich häufig eine eigene, typisierte Collection-Klasse, die solche Objekte aufnimmt und Methoden wie contains() anbietet, die intern equals() statt eines eingebauten PHP-Vergleichs verwenden. So bleibt die Vergleichslogik an einer Stelle gebündelt, statt sich implizit auf das Standardverhalten von == zu verlassen, das sich je nach Objektstruktur unerwartet verhalten kann.
9. Entity und Value Object im direkten Vergleich
Nach den Beispielen mit Money und EmailAddress lässt sich der Unterschied zwischen Entity und Value Object in einer kompakten Übersicht zusammenfassen. Die Wahl zwischen beiden Konzepten ist keine akademische Feinheit, sondern bestimmt direkt, wie ein Objekt verglichen, gespeichert und verändert werden darf.
| Kriterium | Entity | Value Object |
|---|---|---|
| Identität | Trägt eine eigene ID, bleibt über die Lebensdauer stabil | Hat keine Identität, existiert nur durch seine Werte |
| Gleichheit | Vergleich über die ID, unabhängig vom übrigen Zustand | Vergleich über alle Attribute mittels equals() |
| Veränderlichkeit | Zustand ändert sich über die Zeit, ID bleibt gleich | Immutable, Änderungen erzeugen eine neue Instanz |
| Lebenszyklus | Wird angelegt, verändert, persistiert und irgendwann gelöscht | Wird erzeugt und bei Änderung durch eine neue Instanz ersetzt |
| Beispiel | Kunde, Bestellung, Produkt | Money, EmailAddress, DateRange, Percentage |
In der Praxis bestehen die meisten Domänenmodelle aus einer Mischung beider Konzepte: Entities mit Identität, die eine Reihe von Value Objects als Attribute enthalten. Eine Bestellung als Entity referenziert mehrere Money-Werte für Positionspreise und Gesamtsumme, eine EmailAddress für die Rechnungsadresse und eine DateRange für den Lieferzeitraum, ohne dass die Bestellung selbst diese Details erneut validieren oder vergleichen müsste.
10. Zusammenfassung
Ein sauber modelliertes Value Object löst immer dasselbe Grundproblem: Werte, Gültigkeit und Gleichheit, die sonst über Services, Controller und Formulare verstreut wären, werden an einer einzigen Stelle im Code gebündelt. Eine eigene equals()-Methode macht Wertevergleich explizit und unabhängig von PHPs eingebautem ==-Verhalten. readonly-Properties und wither-Methoden erzwingen Unveränderlichkeit, sodass eine einmal erzeugte Instanz für die gesamte Lebensdauer denselben, garantiert gültigen Zustand trägt. Validierung im Constructor stellt sicher, dass ein ungültiger Zustand gar nicht erst entstehen kann.
Die Beispiele Money und EmailAddress zeigen, wie sich diese Prinzipien in echten, produktionstauglichen Klassen umsetzen lassen, inklusive Normalisierung, Rechenoperationen ohne Mutation und Serialisierung über JsonSerializable. Genauso wichtig ist die bewusste Abgrenzung: Sobald ein Objekt eine eigene Identität über die Zeit braucht, ist es keine Entity mehr, sondern ein Value Object, das seine Grenzen verloren hat, oder umgekehrt eine Entity, die von Anfang an als solche hätte modelliert werden sollen.
Value Objects richtig modellieren, das Wichtigste auf einen Blick
Gleichheit
Eigene equals()-Methode statt == oder ===. Vergleicht Werte, nicht Objektidentität.
Immutability
readonly-Properties plus wither-Methoden wie withEnd() statt Settern, die den Zustand mutieren.
Validierung
Invarianten im Constructor prüfen. Ein ungültiges Value Object darf niemals entstehen können.
Grenzen
Klein und fokussiert halten. Braucht das Objekt Identität über die Zeit, ist es eine Entity, kein Value Object.
11. FAQ: Value Objects richtig modellieren
1Was ist ein Value Object?
2Was unterscheidet Value Object und Entity?
3Warum reicht == nicht für Gleichheit?
4Warum immutable sein?
5Was ist eine wither-Methode?
6Wo gehört Validierung hin?
7Vergleich in Arrays und Collections?
8Wann wird ein Value Object zu groß?
9Mehrere Werte in einem Value Object?
10Eigenes Interface für Value Objects nötig?
Mironsoft
PHP-Architektur, Domain-Driven Design und Code-Reviews für saubere Objektmodelle
Objektmodelle, die Fehlerklassen von vornherein ausschließen?
Wir überprüfen bestehende Domänenmodelle, identifizieren Primitive Obsession und Anämie-Anti-Pattern und arbeiten mit euch an klar geschnittenen Value Objects und Entities, inklusive Tests und PHPStan-Absicherung auf hohem Level.
Code-Review
Analyse bestehender Domänenklassen auf Primitive Obsession und fehlende Invarianten
Refactoring
Umbau loser Primitives in klar geschnittene Value Objects mit Validierung und Tests
Schulung
Workshops zu Domain-Driven Design, Immutability und sauberen Objektgrenzen