Embeddables und Value Objects in Doctrine
AI generated
SF
{ }
Symfony · Doctrine ORM · Domain-Driven Design
Embeddables und Value Objects in Doctrine
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.

18 Min. Lesezeit Embeddable · Value Object · Domain-Driven Design Symfony 7 · Doctrine ORM 3 · PHP 8.4

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.

11. FAQ: Embeddables und Value Objects in Doctrine

1Entity vs. Embeddable?
Entity hat eigene ID und Tabelle. Embeddable hat keine Identitaet und erzeugt nur zusaetzliche Spalten.
2Eigene Tabelle noetig?
Nein, nur zusaetzliche Spalten in der einbettenden Tabelle, ohne Join.
3Spaltennamen-Kollisionen vermeiden?
Mit dem columnPrefix-Parameter, etwa billing_ und shipping_ fuer zwei Address-Embeddables.
4Direkt auf Embeddable-Felder filtern?
Ja, ueber Punkt-Notation wie p.price.amountInCents in DQL.
5Warum unveraenderlich?
Verhindert Seiteneffekte, macht Wertegleichheit vorhersagbar, Aenderungen erzeugen neue Objekte.
6Wo Validierung platzieren?
Im Konstruktor, damit ungueltige Zustaende gar nicht erst entstehen koennen.
7Verschachtelte Embeddables moeglich?
Ja, mit summierten Column-Prefixes ueber die Verschachtelungsebenen.
8Embeddable vs. Custom DBAL Type?
Embeddable bildet mehrere Spalten ab, Custom Type transformiert einen einzelnen Skalarwert.
9Sichere Migration von Primitiv zu Embeddable?
RENAME COLUMN statt DROP plus ADD verwenden, da sich der Spaltenname aendert.
10Loesen Embeddables Primitive Obsession vollstaendig?
Fuer zusammengehoerige Wertegruppen ja, fuer echte Entitaeten mit Identitaet bleibt eine Entity richtig.