Domain-Driven Design direkt im ORM
Primitive Obsession, also das Abbilden fachlicher Konzepte durch nackte Strings und Integer, ist eine der häufigsten Ursachen für unklaren Domänencode. Embeddables machen Value Objects wie Money oder Address zu erstklassigen Bürgern im Doctrine-Mapping, ohne eigene Datenbanktabellen zu benötigen.
Inhaltsverzeichnis
- 1. Value Objects versus Entities und Primitive Obsession
- 2. Eine Embeddable-Klasse definieren
- 3. Einbetten in die Entity mit Column-Prefix
- 4. Eigene Werttypen: Money, Address, EmailAddress
- 5. Verschachtelte Embeddables
- 6. Unveränderlichkeit und Validierung im Konstruktor
- 7. Querying: DQL auf eingebetteten Feldern
- 8. Datenbank-Mapping und Migration
- 9. Embeddable im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Value Objects versus Entities und Primitive Obsession
Eine Entity in Doctrine hat eine eigene Identität, definiert über ihre ID, und bleibt über ihren gesamten Lebenszyklus dieselbe Entity, auch wenn sich ihre Attribute ändern. Ein Value Object hingegen hat keine eigene Identität, es wird ausschließlich über seine Werte definiert. Zwei Money-Objekte mit demselben Betrag und derselben Währung sind identisch, unabhängig davon, wo sie im Code erzeugt wurden. Diese Unterscheidung ist der Ausgangspunkt für Embeddables und Value Objects in Doctrine.
Ohne Value Objects landen fachliche Konzepte häufig als nackte Primitive im Code, ein Phänomen, das als Primitive Obsession bekannt ist. Ein Preis wird als float gespeichert, eine Adresse als vier separate String-Felder, eine E-Mail-Adresse als einfacher string ohne Validierungsgarantie. Das Problem: Die Fachlogik, die zu diesen Werten gehört, Rundungsregeln bei Geldbeträgen, Formatprüfung bei E-Mail-Adressen, Ländervalidierung bei Adressen, verteilt sich über die gesamte Codebasis, statt an einer Stelle zu leben.
Embeddables und Value Objects lösen dieses Problem, indem sie fachliche Konzepte als eigene, unveränderliche Klassen kapseln, die trotzdem transparent auf mehrere Spalten derselben Datenbanktabelle gemappt werden, ohne eine eigene Tabelle oder einen Join zu benötigen. Aus Sicht der Datenbank ändert sich nichts, aus Sicht des Domänencodes gewinnt die Anwendung deutlich an Ausdruckskraft und Typsicherheit.
2. Eine Embeddable-Klasse definieren
Eine Doctrine-Embeddable-Klasse wird mit dem Attribut #[ORM\Embeddable] markiert und verhält sich strukturell wie eine normale PHP-Klasse mit Doctrine-Feld-Mappings, besitzt aber keine eigene ID und keine eigene Tabelle. Die Felder innerhalb der Embeddable-Klasse werden mit dem regulären #[ORM\Column]-Attribut annotiert, exakt wie bei einer Entity.
<?php
declare(strict_types=1);
namespace App\ValueObject;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
#[ORM\Embeddable]
final readonly class Money
{
#[ORM\Column(type: 'integer')]
private int $amountInCents;
#[ORM\Column(type: 'string', length: 3)]
private string $currency;
public function __construct(int $amountInCents, string $currency)
{
if ($amountInCents < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
$this->amountInCents = $amountInCents;
$this->currency = strtoupper($currency);
}
public function amountInCents(): int
{
return $this->amountInCents;
}
public function currency(): string
{
return $this->currency;
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Cannot add different currencies');
}
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
Diese Embeddable-Klasse für Money kapselt sowohl die Datenrepräsentation als auch die Fachlogik, hier die Regel, dass nur Beträge in derselben Währung addiert werden dürfen. Diese Regel wäre bei einer reinen Primitive-Lösung mit zwei separaten Spalten price und currency nirgendwo zentral durchsetzbar.
3. Einbetten in die Entity mit Column-Prefix
Um ein Embeddable in einer Entity zu verwenden, wird das Attribut #[ORM\Embedded] auf der entsprechenden Eigenschaft gesetzt. Der optionale Parameter columnPrefix steuert, wie die Spaltennamen in der Datenbank aussehen, was wichtig wird, sobald mehrere Embeddables desselben Typs in einer Entity verwendet werden, etwa eine Rechnungs- und eine Lieferadresse.
<?php
declare(strict_types=1);
namespace App\Entity;
use App\ValueObject\Money;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 200)]
private string $name;
#[ORM\Embedded(class: Money::class, columnPrefix: 'price_')]
private Money $price;
public function __construct(string $name, Money $price)
{
$this->name = $name;
$this->price = $price;
}
public function price(): Money
{
return $this->price;
}
public function withPrice(Money $newPrice): self
{
$clone = clone $this;
$clone->price = $newPrice;
return $clone;
}
}
Doctrine generiert daraus automatisch die Spalten price_amount_in_cents und price_currency in der product-Tabelle. Aus Sicht des Domänencodes bleibt $product->price() jedoch ein vollwertiges Money-Objekt mit all seiner Fachlogik, nicht zwei lose Skalar-Werte. Genau darin liegt der praktische Nutzen von Embeddables und Value Objects: Die Datenbank sieht flache Spalten, der Code sieht reiche Objekte.
4. Eigene Werttypen: Money, Address, EmailAddress
Neben Money sind Adressen ein klassisches Beispiel für Embeddables und Value Objects. Eine Adresse besteht typischerweise aus Straße, Postleitzahl, Stadt und Land, vier Werte, die immer zusammen auftreten und zusammen validiert werden sollten. Als eigenständige Address-Embeddable-Klasse lässt sich diese Validierung zentral im Konstruktor durchsetzen, statt sie an jeder Stelle im Code zu wiederholen, an der eine Adresse erzeugt wird.
Eine EmailAddress-Embeddable-Klasse mit Format-Validierung im Konstruktor stellt sicher, dass niemals eine ungültige E-Mail-Adresse in der Datenbank landet, weil die Validierung an der einzigen Stelle passiert, an der ein solches Objekt entstehen kann. Diese Garantie ist stärker als eine Validierung auf Formular-Ebene, weil sie auch bei direkter Objekterzeugung im Domänencode greift, etwa bei Imports oder internen Services, die keine Formulare durchlaufen.
5. Verschachtelte Embeddables
Doctrine unterstützt seit einigen Versionen auch verschachtelte Embeddables, also ein Embeddable, das selbst wieder ein anderes Embeddable enthält. Ein OrderLine-Embeddable könnte beispielsweise sowohl ein Money-Feld für den Einzelpreis als auch ein Quantity-Feld für die Menge enthalten, während OrderLine selbst wiederum in eine Order-Entity eingebettet, oder als Element einer Collection referenziert wird.
Bei verschachtelten Embeddables und Value Objects summieren sich die Column-Prefixe der einzelnen Verschachtelungsebenen, was schnell zu langen Spaltennamen führt. Ein durchdachtes Präfix-Schema, kurze, aber eindeutige Kürzel statt vollständiger Klassennamen, hält die generierten Spaltennamen innerhalb der Längenlimits gängiger Datenbanksysteme und lesbar für manuelle SQL-Abfragen.
6. Unveränderlichkeit und Validierung im Konstruktor
Ein zentrales Designprinzip für Embeddables und Value Objects ist Unveränderlichkeit. Mit PHP 8.4 lässt sich das elegant über readonly-Eigenschaften und final readonly class durchsetzen, wie im Money-Beispiel oben gezeigt. Eine Änderung an einem Wert erzeugt immer ein neues Objekt, statt den bestehenden Zustand zu mutieren, was Seiteneffekte in komplexen Objektgraphen zuverlässig verhindert.
Validierung gehört bei Embeddables und Value Objects konsequent in den Konstruktor, nicht in separate Validator-Klassen, die erst nachträglich aufgerufen werden müssen. Ein Money-Objekt, das erfolgreich konstruiert wurde, ist per Definition immer gültig, es kann keinen negativen Betrag oder eine ungültige Währung enthalten. Diese Garantie, oft als "Make illegal states unrepresentable" bezeichnet, ist einer der stärksten Vorteile von Value Objects gegenüber reinen Primitiven, bei denen ungültige Zustände jederzeit möglich sind.
7. Querying: DQL auf eingebetteten Feldern
Ein häufiges Missverständnis bei Embeddables und Value Objects ist die Annahme, dass eingebettete Felder nicht direkt abgefragt werden können. Tatsächlich unterstützt DQL den Zugriff auf Embeddable-Felder über Punkt-Notation, exakt wie bei regulären Entity-Feldern.
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Product;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<Product>
*/
final class ProductRepository extends EntityRepository
{
/**
* @return Product[]
*/
public function findAffordableInCurrency(int $maxAmountInCents, string $currency): array
{
return $this->createQueryBuilder('p')
->andWhere('p.price.amountInCents <= :max')
->andWhere('p.price.currency = :currency')
->setParameter('max', $maxAmountInCents)
->setParameter('currency', $currency)
->orderBy('p.price.amountInCents', 'ASC')
->getQuery()
->getResult();
}
}
Die Punkt-Notation p.price.amountInCents übersetzt Doctrine transparent in die richtige Spalte, price_amount_in_cents in unserem Beispiel. Für den Entwickler bleibt vollkommen unsichtbar, dass hinter price ein Embeddable aus zwei Spalten steht, statt einer einzigen Spalte, was die Composability von Embeddables und Value Objects mit dem restlichen QueryBuilder-Ökosystem sicherstellt.
8. Datenbank-Mapping und Migration
Aus Datenbanksicht erzeugt ein Embeddable keine neue Tabelle und keinen Join, sondern ausschließlich zusätzliche Spalten in der Tabelle der einbettenden Entity. Das bedeutet, eine Migration, die ein neues Embeddable-Feld hinzufügt, ist strukturell identisch mit dem Hinzufügen mehrerer einzelner Spalten, nur dass Doctrine die Spaltennamen basierend auf dem Column-Prefix automatisch generiert.
Ein wichtiger Fallstrick beim Refactoring: Wird ein bereits bestehendes Primitiv-Feld nachträglich in ein Embeddable umgewandelt, ändert sich der Spaltenname fast immer, weil der Column-Prefix-Mechanismus greift. Eine sorgfältig geschriebene Migration mit RENAME COLUMN statt DROP plus ADD verhindert dabei Datenverlust, ein Aspekt, der eng mit den allgemeinen Best Practices für Doctrine Migrations zusammenhängt.
9. Embeddable im direkten Vergleich
Die folgende Tabelle vergleicht Embeddables mit anderen Ansätzen, fachliche Konzepte im Doctrine-Mapping abzubilden, und zeigt, wann welcher Ansatz die richtige Wahl ist.
| Ansatz | Eigene Tabelle | Identität | Typischer Einsatz |
|---|---|---|---|
| Entity | Ja | Eigene ID über den Lebenszyklus | Kunde, Bestellung, Produkt |
| Embeddable | Nein | Keine, nur Wertegleichheit | Money, Address, EmailAddress |
| Custom DBAL Type | Nein, eine Spalte | Keine | Ein einzelner Skalarwert mit Logik |
| Rohe Primitive | Nein | Keine | Primitive Obsession, keine zentrale Validierung |
Der entscheidende Unterschied zwischen einem Embeddable und einem Custom DBAL Type liegt in der Anzahl der Spalten: Ein Embeddable bildet ein Konzept mit mehreren zusammengehörigen Werten ab, ein Custom Type transformiert einen einzelnen Skalarwert. Money mit Betrag und Währung ist ein klassisches Embeddable, eine einzelne verschlüsselte Zeichenkette wäre eher ein Custom Type.
Mironsoft
Symfony-Architektur, Domain-Driven Design und Doctrine-Modellierung
Primitive Obsession in eurem Domänenmodell beheben?
Wir identifizieren fachliche Konzepte in eurer Codebasis, modellieren sie als saubere Value Objects und Embeddables und begleiten die Migration bestehender Entities ohne Datenverlust.
Domänen-Analyse
Primitive Obsession im bestehenden Code identifizieren
Value-Object-Design
Unveränderliche Embeddables mit zentraler Validierung
Migrations-Begleitung
Sichere Umstellung bestehender Spalten ohne Datenverlust
10. Zusammenfassung
Embeddables und Value Objects lösen Primitive Obsession, indem sie fachliche Konzepte wie Geldbeträge, Adressen oder E-Mail-Adressen als eigenständige, unveränderliche Klassen kapseln, ohne dabei eine zusätzliche Datenbanktabelle zu erzeugen. Das Attribut #[ORM\Embeddable] markiert die Werttyp-Klasse, #[ORM\Embedded] mit columnPrefix bindet sie an eine Entity, und DQL-Punkt-Notation erlaubt weiterhin direktes Querying auf den eingebetteten Feldern.
Unveränderlichkeit über readonly-Eigenschaften und Validierung im Konstruktor stellen sicher, dass ungültige Zustände gar nicht erst entstehen können. Verschachtelte Embeddables erlauben komplexere Werttyp-Kompositionen, erfordern aber ein durchdachtes Präfix-Schema, um lesbare Spaltennamen zu behalten. Wer Value Objects konsequent für zusammengehörige Wertegruppen einsetzt, gewinnt Typsicherheit und zentrale Validierung, ohne die Einfachheit des relationalen Schemas zu opfern.
Embeddables und Value Objects in Doctrine — Das Wichtigste auf einen Blick
Attribute
#[ORM\Embeddable] auf der Werttyp-Klasse, #[ORM\Embedded] mit columnPrefix auf der Entity-Eigenschaft.
Unveränderlichkeit
final readonly class plus Validierung im Konstruktor, damit ungültige Zustände unrepresentierbar bleiben.
Querying
DQL-Punkt-Notation wie p.price.amountInCents funktioniert transparent auf eingebetteten Feldern.
Datenbank-Impact
Keine neue Tabelle, nur zusätzliche Spalten in der einbettenden Entity-Tabelle.