Persystencja obiektów domenowych z Doctrine ORM

Serwerownia

Domain Driven Design, niewątpliwie, to jeden z modniejszych tematów nowoczesnego programowania. Znalazł swoje miejsce również w ekosystemie PHP. Aktualnie, wiele aplikacji powstaje w oparciu o to podejście. Jak to zrobić? Lista tematów do ogarnięcia jest dość szeroka. O ile powstała cała masa materiałów teoretycznych, o tyle z praktyką jest trochę gorzej. Dużo mówi się o samej domenie, a więc sensowne przykłady związane z tą warstwą jeszcze da się znaleźć. To zrozumiałe, skoro właśnie tego dotyczy DDD.

Na koniec dnia, mimo wszystko, model musi mieć swoją reprezentację w infrastrukturze.

Innymi słowy, dane muszą zostać utrwalone. Błędne założenia na tym poziomie także potrafią zepsuć aplikację. Pozostaje wtedy pochwalić się w CV świetnie wydzieloną domeną, nie wspominając nic o tym, że aplikacja nie działa i jest do zaorania. Wystarczające, by dostać nową pracę.

Mimo wszystko, temat jest ważny, więc postanowiłem go podjąć. Miałem przyjemność brać udział w kilku projektach, gdzie domena grała pierwsze skrzypce. To pozwoliło mi wypracować dwie rozsądne opcje na pospawanie tego z warstwą infrastruktury. Zanim udało mi się do nich dojść, wielokrotnie miałem naświetlone oczy. Tak kończy się spawanie bez okularów. Oczywiście, nie jest tak, że są to innowacyjne pomysły. Obstawiam, że wielu z Was robi to w podobny sposób.

Każdy projekt ma swoje uwarunkowania techniczne. W tym materiale zakładam, że głównym źródłem danych jest baza relacyjna, a do persystencji wykorzystywane jest najpopularniejsze narzędzie mapowania obiektowo-relacyjnego w PHP. Wydaje mi się, że wciąż będzie to bardzo częsta konfiguracja.

Jak wspomniałem, wyróżniam dwa sposoby persystencji obiektów domenowych w PHP przy użyciu Doctrine ORM. Jedno trochę mniej radykalne i szybsze w implementacji, a drugie bardziej elastyczne, ale czasochłonne. Postaram się kompleksowo zdefiniować oba, porównać je i pokazać na realnych przykładach. Na samym początku zaznaczę, że jedno i drugie są dla mnie w pełni akceptowalne. Koniec tego przydługiego wstępu – do kodu!

Obiekty domenowe jako encje Doctrine

To podejście wydaje się bardzo naturalne. Ucząc się programowania, na samym początku, przyświeca jeden fundamentalny cel – ma działać. Bierze się ORM, tworzy się encje w zgodzie z dokumentacją, czy poradnikiem i śmiga. No, przynajmniej powinno. Kolejnym etapem jest próba poznania i wprowadzenia w życie dobrych praktyk. Okazuje się, że model do tej pory to zwykły worek na dane. Tak zwany model anemiczny. W programowaniu obiektowym istotne są również zachowania, które pozwalają sterować danymi. Tak zwany bogaty model, który wyraża intencje biznesowe. Zamiast trzymać logikę w tak zwanych serwisach, przenosi się poszczególne funkcje do modelu. Taki zabieg pozwala wprowadzić właściwą enkapsulację. Na tym etapie jest już naprawdę nieźle i dla części projektów wystarczająco.

Następny krok to bardziej zaawansowana architektura. Jak sprawić, żeby kod był łatwiej rozwijalny, eksponował faktycznie najistotniejsze elementy aplikacji i gwarantował ogólnie pojętą jakość. Opcji jest oczywiście kilka, ale jednym z ważnych elementów jest odseparowanie domeny od świata zewnętrznego. To pozwoli łatwiej trzymać ją w ryzach, a co za tym idzie rozwijać niezależnie.

Czy model, który udało się wypracować do tego momentu spełnia to założenie? Trzeba inaczej zadać pytanie. Czy baza danych albo ORM, jakkolwiek, mają wpływ na domenę? Odpowiedź jest następująca: tak. Niestety. Czy jest to problematyczne? W moim odczuciu, wpływ ten wydaje się niewielki i jest akceptowalny. Do tego przejdę zaraz, ale najpierw przykładowy model i jego mapowanie.

Warstwa domeny

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embedded;
use Doctrine\ORM\Mapping\Id;
use Koddlo\Payment\Domain\Event\PaymentArchived;
use Koddlo\Payment\Domain\Event\PaymentCreated;
use Koddlo\Payment\Domain\Event\PaymentPaid;
use Koddlo\Payment\Domain\Exception\OnlinePaymentCannotBeArchived;
use Koddlo\Payment\Domain\Exception\PaymentCannotBeArchived;
use Koddlo\Payment\Domain\Exception\PaymentCannotBeMade;
use Koddlo\Shared\Domain\AggregateRoot;

class Payment extends AggregateRoot
{
    #[Id]
    #[Column(type: Types::GUID)]
    private string $id;

    #[Embedded(class: Amount::class)]
    private Amount $amount;

    #[Column(type: Types::STRING, length: 16, enumType: Method::class)]
    private Method $method;

    #[Column(type: Types::STRING, length: 16, enumType: Status::class)]
    private Status $status;

    #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?DateTimeImmutable $paidAt = null;

    public function __construct(
        PaymentId $id,
        Amount $amount,
        Method $method
    ) {
        $this->id = $id->toString();
        $this->amount = $amount;
        $this->method = $method;
        $this->status = Status::PENDING;
    }

    public function pay(ClockInterface $clock): void
    {
        if (! $this->isPending()) {
            throw new PaymentCannotBeMade();
        }

        $this->status = Status::PAID;
        $this->paidAt = $clock->now();

        $this->raise(new PaymentPaid($this->getId()));
    }

    public function archive(): void
    {
        if (! $this->isPending()) {
            throw new PaymentCannotBeArchived();
        }

        if ($this->method->isOnline()) {
            throw new OnlinePaymentCannotBeArchived();
        }

        $this->status = Status::ARCHIVED;

        $this->raise(new PaymentArchived($this->getId()));
    }

    private function getId(): PaymentId
    {
        return new PaymentId($this->id);
    }

    private function isPending(): bool
    {
        return Status::PENDING === $this->status;
    }
}

Czy ten model można uznać jako zanieczyszczony? Zależy jak bardzo purystycznie podchodzi się do tematu. Generalnie, z infrastruktury pochodzą dwie zależności: klasa \DateTimeImmutable oraz atrybuty (we wcześniejszych wersjach adnotacje).

Ogólnie, uważam że konstrukcji językowych można spokojnie używać w modelu domenowym. Inne spojrzenie doprowadzi to do tego, że model domenowy będzie wyrażany w agnostycznej technologii jak JSON, czy YAML – bez sensu. Takie skrajności nie są niczym dobrym. W zgodzie z tym założeniem własne wyjątki domenowe mogą dziedziczyć po klasie \Exception, a obiekty typu \DateTime są mile widziane. Pomijając fakt, że czasem wprowadzenie własnego obiektu wartości reprezentującego date może dać wartość. Szczególnie jeśli będzie on potrzebował niestandardowych reguł i zachowań.

Co z mapowaniem w obiektach domenowych? Klapki na oczy i nie widzę przeszkód. Jeżeli decyzja należy do mnie to, mimo wszystko, wynoszę mapowanie do konfiguracji korzystając z XML. Przyznam, że kiedyś byłem do tego sceptycznie nastawiony. Okazało się, że mapowanie w XML tworzy się naprawdę łatwo i przyjemnie. Obiekty domenowe są bardziej przejrzyste, bo atrybuty i adnotacje potrafią zaśmiecić sedno modelu. Już nawet nie wchodząc w kwestie, że zależności z infrastruktury infekują domenę. Nie jest to szkodliwy rodzaj zanieczyszczenia.

Ważniejsza kwestia to wpływ mapowania na strukturę obiektu. Faktycznie, chociażby obiekt PaymentId jest wewnątrz trzymany jako pole typu string . Spłaszczanie obiektów na potrzeby persystencji to też całkiem sensowny zabieg. Dzięki enkapsulacji ta informacja i tak nie wycieka na zewnątrz. Nadal wszystkie operacje wykonywane są na obiektach. Jest kilka alternatyw:

  • własny typ (Doctrine jest łatwo rozszerzalny, ale mając własny identyfikator w każdej encji, robi się tego całkiem sporo);
  • używanie Embeddables jako typów (bardzo fajnie się sprawdza dla obiektów wartości, chociaż raczej nie jako identyfikator);
  • popularna biblioteka ramsey/uuid dostarcza własny typ (wtedy mowa o jednym typie identyfikatora dla wszystkich encji oraz zezwolenie na wyjątek, bo typ pochodzi z warstwy infrastruktury).

Faktycznie, widać tu wpływ persystencji na dziedzinę. Z drugiej strony, jest dużo opcji na robienie tego niezależnie, a tworzenie własnych typów pozwoli zrobić niemalże wszystko. Ale kiedy mowa o niezależności to warto wspomnieć, że przy tym podejściu trzeba mieć to na uwadze.

Istnieje inna zależność, której nie widać w przykładzie, a jest to częsty przypadek. Chodzi o kolekcje. Przy relacjach jeden-do-wielu i wiele-do-wielu trzeba używać dedykowanych kolekcji Doctrine. Przy tworzeniu jest to instancja Doctrine\Common\Collections\ArrayCollection, a przy odtwarzaniu Doctrine\ORM\PersistentCollection. Obie spięte interfejsem Doctrine\Common\Collections\Collection. Doctrine nie pozwala na własne kolekcje i trzeba zezwolić na takie ustępstwo. To już bez wątpienia jest infekcja domeny, chociaż w razie potrzeby łatwo ją wymienić. Pochodzi z osobnej paczki i nie trzeba dociągać całego ORM, żeby móc z niej korzystać. Traktowałbym to jako taka kolekcja zewnętrzna. Mimo wszystko, ograniczenie istnieje, bo nie ma możliwości implementacji kolekcji na własną rękę.

Niestety, dużo rzadziej w tym podejściu można postawić na łatwą relację wiele-do-jednego, gdyż to z poziomu agregatu będzie zarządzanie kolekcją. W tym wypadku trzeba mieć relację dwukierunkową, a tutaj niepotrzebnie rośnie stopień skomplikowania. I o ile w niektórych miejscach jest to naturalne, o tyle tutaj mowa o każdej takiej relacji.

Poniżej prezentuję pozostałą część modelu, gdzie mapowanie jest jeszcze w obiekcie wartości Amount. Wykorzystany został typ Embeddable. Doctrine pozwala też w łatwy sposób odtwarzać typy Enum, więc nie potrzeba dedykowanego typu, co widać na załączonym obrazku.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use Koddlo\Shared\Domain\Exception\InvalidIdException;

final class PaymentId
{
    private const ID = '/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i';

    public function __construct(
        private string $id
    ) {
        $this->guard();
    }

    public function toString(): string
    {
        return $this->id;
    }

    private function guard(): void
    {
        if (! preg_match(self::ID, $this->id)) {
            throw new InvalidIdException('Invalid id format.');
        }
    }
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embeddable;

#[Embeddable]
final class Amount
{
    #[Column(type: Types::INTEGER)]
    private int $value;

    #[Column(type: Types::STRING, length: 8, enumType: Currency::class)]
    private Currency $currency;

    public function __construct(int $value, Currency $currency)
    {
        $this->value = $value;
        $this->currency = $currency;
    }
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

enum Currency: string
{
    case PLN = 'pln';
    case EUR = 'eur';
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

enum Method: string
{
    case CASH = 'cash';
    case ONLINE = 'online';

    public function isOnline(): bool
    {
        return self::ONLINE === $this;
    }
}

Model płatności gotowy, ale brakuje interfejsu repozytorium. To również część domenowa. Ta warstwa wie, że gdzieś utrwala i skądś odczytuje stan. Nie interesują jej szczegóły, dlatego konkretna implementacja repozytorium trafi już do infrastruktury i tam oprze się o Doctrine ORM. Repozytorium bardzo często posiada tylko dwie metody: save() i get(), ale nic nie stoi na przeszkodzie, żeby dokładać kolejne.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use Koddlo\Payment\Domain\Exception\PaymentNotFoundException;

interface PaymentRepositoryInterface
{
    public function save(Payment $payment): void;

    /**
     * @throws PaymentNotFoundException
     */
    public function get(PaymentId $id): Payment;
}

Warstwa infrastruktury

Repozytorium opiera się o Entity Manager (publiczny interfejs ORM) i encje domenowe, które równocześnie są encjami Doctrine. Jest to całkiem przejrzyste podejście. W tym przykładzie nie ma relacji, ale trzeba pamiętać, że narzędzie to domyślnie używa mechanizmu lazy loading. Agregaty, jeżeli są właściwie skonstruowane, raczej powinny być odczytywane w całości. Warto pokusić się o strategię fetch eager, czyli dociąganie oryginalnych encji od razu. Raczej nie na poziomie encji, a na poziomie konretnego zapytania. I tak tutaj nie użyłbym metody find() a po prostu własne zapytanie z addSelect(). Dzięki temu zredukuje się liczbę dodatkowych zapytań.

W swoim założeniu lazy loading jest genialny. Mało będzie przypadków, gdzie agregat powinien być wczytany tylko częściowo. Do odczytu nie wykorzystuje się modelu domenowego. Jak wspomniałem, agregat z dobrymi granicami ma dokładnie te pola i zachowania, których potrzebuje i zawsze ładowany jest w całości. Gdyby jednak tworzyć model po prostu jako encje, nie jako agregaty, to lazy loading może się sprawdzić. Nie warto wczytywać całego obiektu, jeżeli akurat w tej konkretnej operacji nie są potrzebne obiekty zależne.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Infrastructure\Repository;

use Doctrine\ORM\EntityManagerInterface;
use Koddlo\Payment\Domain\Exception\PaymentNotFoundException;
use Koddlo\Payment\Domain\Payment;
use Koddlo\Payment\Domain\PaymentId;
use Koddlo\Payment\Domain\PaymentRepositoryInterface;

final class PaymentRepository implements PaymentRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {
    }

    public function save(Payment $payment): void
    {
        $this->entityManager->persist($payment);
        $this->entityManager->flush();
    }

    public function get(PaymentId $id): Payment
    {
        return $this->entityManager->find(Payment::class, $id->toString())
            ?? throw new PaymentNotFoundException();
    }
}

Warstwa aplikacji

Na sam koniec, warstwa aplikacji i przykładowy serwis pozwalający na podstawie komendy utworzyć nową płatność. W zasadzie, wystarczy go wpiąć to w kontroler i tak wygląda pełny cykl od momentu żądania użytkownika do momentu utrwalenia danych w bazie.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Application\Command;

final readonly class RegisterPayment
{
    public function __construct(
        public string $id,
        public int $amount,
        public string $currency,
        public string $method
    ) {
    }
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Application\Command;

use Koddlo\Payment\Domain\Amount;
use Koddlo\Payment\Domain\Currency;
use Koddlo\Payment\Domain\Method;
use Koddlo\Payment\Domain\Payment;
use Koddlo\Payment\Domain\PaymentId;
use Koddlo\Payment\Domain\PaymentRepositoryInterface;
use Koddlo\Shared\Application\Command\CommandHandlerInterface;
use Koddlo\Shared\Domain\DomainEventDispatcherInterface;

final class RegisterPaymentHandler implements CommandHandlerInterface
{
    public function __construct(
        private PaymentRepositoryInterface $repository,
        private DomainEventDispatcherInterface $eventDispatcher
    ) {
    }

    public function __invoke(RegisterPayment $command): void
    {
        $payment = new Payment(
            new PaymentId($command->id),
            new Amount($command->amount, Currency::from($command->currency)),
            Method::from($command->method)
        );

        $this->repository->save($payment);
        $this->eventDispatcher->dispatch(...$payment->pullEvents());
    }
}

Doctrine – mapowanie w XML

Tak jak wspomniałem, warto pomyśleć o wyciągnięciu mapowania do plików konfiguracyjnych XML. Pozwoli to zachować bardziej czytelne obiekty domenowe. Zgadzam się, że mapowanie jest delikatnie toporniejsze, ale z początku i tak myślałem, że jest to mniej przyjemne. Poza tym zamyka się dyskusja purystyczna, czy można używać adnotacji w domenie. Chociaż tak jak mówię, nie jest to kwestia o którą bym się zabijał. Dla mnie adnotacje w domenie są akceptowalne.

<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                          https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

    <entity name="Koddlo\Payment\Domain\Payment">

        <id name="id" type="guid"/>

        <embedded name="amount" class="Koddlo\Payment\Domain\Amount"/>

        <field name="method" type="string" enum-type="Koddlo\Payment\Domain\Method" length="16"/>

        <field name="status" type="string" enum-type="Koddlo\Payment\Domain\Status" length="16"/>

        <field name="paidAt" type="datetime_immutable"/>

    </entity>

</doctrine-mapping>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                          https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

    <embeddable name="Koddlo\Payment\Domain\Amount">

        <field name="value" type="integer"/>

        <field name="currency" type="string" enum-type="Koddlo\Payment\Domain\Currency" length="8"/>

    </embeddable>

</doctrine-mapping>

Poniżej dokładnie ta sama klasa Payment , ale już bez atrybutów. Mapowanie zostało przeniesione do infrastruktury lub konfiguracji projektu w oparciu o XML. Dla mnie takie klasy są bardziej przejrzyste, więc w tę stronę bym szedł, ale wiem że jest opór po stronie wielu programistów. Pojawiają się argumenty, że łatwiej zmieniać w jednym miejscu i tym podobne. Nie dyskutuję z tym.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use DateTimeImmutable;
use Koddlo\Payment\Domain\Event\PaymentArchived;
use Koddlo\Payment\Domain\Event\PaymentCreated;
use Koddlo\Payment\Domain\Event\PaymentPaid;
use Koddlo\Payment\Domain\Exception\OnlinePaymentCannotBeArchived;
use Koddlo\Payment\Domain\Exception\PaymentCannotBeArchived;
use Koddlo\Payment\Domain\Exception\PaymentCannotBeMade;
use Koddlo\Shared\Domain\AggregateRoot;

class Payment extends AggregateRoot
{
    private string $id;
    
    private Amount $amount;
    
    private Method $method;
    
    private Status $status;
    
    private ?DateTimeImmutable $paidAt = null;

    public function __construct(
        PaymentId $id,
        Amount $amount,
        Method $method
    ) {
        $this->id = $id->toString();
        $this->amount = $amount;
        $this->method = $method;
        $this->status = Status::PENDING;
    }

    public function pay(ClockInterface $clock): void
    {
        if (! $this->isPending()) {
            throw new PaymentCannotBeMade();
        }

        $this->status = Status::PAID;
        $this->paidAt = $clock->now();

        $this->raise(new PaymentPaid($this->getId()));
    }

    public function archive(): void
    {
        if (! $this->isPending()) {
            throw new PaymentCannotBeArchived();
        }

        if ($this->method->isOnline()) {
            throw new OnlinePaymentCannotBeArchived();
        }

        $this->status = Status::ARCHIVED;

        $this->raise(new PaymentArchived($this->getId()));
    }

    private function getId(): PaymentId
    {
        return new PaymentId($this->id);
    }

    private function isPending(): bool
    {
        return Status::PENDING === $this->status;
    }
}

Tak przygotowany model domenowy to cześć tylko i wyłącznie modelu zapisu. Odczyty powinny bazować na osobnym modelu. Oczywiście, można tworzyć zapytania używając DQL i Query Builder, ale rezultat nie powinien być hydrowany do encji, tylko do bardziej suchej postaci: DTO lub tablica. Te metody nie powinny znajdować się w repozytorium, które jest domenowe, a w osobynch klasach reprezentujących zapytania lub ewentualnie repozytorium odczytowym. Jest to bardzo istotne, bo mając pomieszane encje infrastrukturalne z encjami domenowymi może istnieć pokusa dalszego mieszania warstw.

Czas na podsumowanie tego rozwiązania. Czy to podejście ma rację bytu? Zdecydowanie tak. Po to korzysta się z ORM, żeby zwiększyć produktywność i łatwość dostarczania. Twórcy Doctrine naprawdę ograniczyli do minimum wpływ swojej biblioteki na model, dzięki temu, że oparli ją o wzorzec Data Mapper. Nie korzysta ona konstruktorów, czy getterów/setterów a pod spodem wykorzystuje mechanizm refleksji. Powoduje to, że można tworzyć całkiem niezależne modele. Mimo wszystko, nie jest to najwyższy poziom elastyczności i niezależności. W wielu projektach wystarczający i akceptowalny. Każde inne rozwiązanie wymaga dużo więcej pracy.

W taki sposób często tworzy się aplikacje w Symfony. Aktualnie równie znany jest Laravel. Z uwagi na jego popularność równie szeroko wykorzystywany jest Eloquent. Korzystając z tego narzędzia taki model domenowy jest niemożliwy do osiągnięcia. Stoi za nim Active Record, a więc klasy reprezentujące model są tak naprawdę reprezentacją rekordów z tabeli w bazie danych. Jest to tak mocno powiązane, że nie mowy o niezależności, a baza danych wymusza strukturę modelu. W takim układzie lepiej sprawdzi się podejście numer dwa. Stosuje się je także przy Doctrine dla bardziej rozbudowanych aplikacji.

Separacja modeli i transformacja

Ten koncept opiera się na transformacji modelu domenowego na model infrastrukturalny i w drugą stronę. Mechanizm ten daje więcej możliwości, ale niestety oczekuje dużego nakładu pracy. Na czym polega elastyczność? Obiekty domenowe da się odtworzyć z każdego źródła. Niemalże z kartki papieru. Zauważcie, że w tym artykule mowa o relacyjnych bazach danych. Bywa, że może być potrzeba odtworzenia stanu agregatu z baz typu NoSQL albo zdarzeń w podejściu Event Sourcing.

Warstwa domeny

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use DateTimeImmutable;
use Koddlo\Payment\Domain\Event\PaymentArchived;
use Koddlo\Payment\Domain\Event\PaymentCreated;
use Koddlo\Payment\Domain\Event\PaymentPaid;
use Koddlo\Payment\Domain\Exception\OnlinePaymentCannotBeArchived;
use Koddlo\Payment\Domain\Exception\PaymentCannotBeArchived;
use Koddlo\Payment\Domain\Exception\PaymentCannotBeMade;
use Koddlo\Shared\Domain\AggregateRoot;

final class Payment extends AggregateRoot
{
    private ?DateTimeImmutable $paidAt = null;

    private function __construct(
        private PaymentId $id,
        private Amount $amount,
        private Method $method,
        private Status $status
    ) {
    }

    public static function create(
        PaymentId $id,
        Amount $amount,
        Method $method
    ): self {
        $payment = new self($id, $amount, $method, Status::PENDING);
        $payment->raise(new PaymentCreated($id));

        return $payment;
    }

    public static function restore(
        PaymentId $id,
        Amount $amount,
        Method $method,
        Status $status,
        ?DateTimeImmutable $paidAt
    ): self {
        $payment = new self($id, $amount, $method, $status);
        $payment->paidAt = $paidAt;

        return $payment;
    }

    public function pay(ClockInterface $clock): void
    {
        if (! $this->isPending()) {
            throw new PaymentCannotBeMade();
        }

        $this->status = Status::PAID;
        $this->paidAt = $clock->now();

        $this->raise(new PaymentPaid($this->id));
    }

    public function archive(): void
    {
        if (! $this->isPending()) {
            throw new PaymentCannotBeArchived();
        }

        if ($this->method->isOnline()) {
            throw new OnlinePaymentCannotBeArchived();
        }

        $this->status = Status::ARCHIVED;

        $this->raise(new PaymentArchived($this->id));
    }

    public function getId(): PaymentId
    {
        return $this->id;
    }

    public function getAmount(): Amount
    {
        return $this->amount;
    }

    public function getMethod(): Method
    {
        return $this->method;
    }

    public function getStatus(): Status
    {
        return $this->status;
    }

    public function getPaidAt(): ?DateTimeImmutable
    {
        return $this->paidAt;
    }

    private function isPending(): bool
    {
        return Status::PENDING === $this->status;
    }
}

Powyżej model obiektu płatności, który został zmodyfikowany o potrzebne zmiany. Przydadzą się dwie metody: create() i restore(). Jedna do tworzenia obiektu, a druga do odtwarzania istniejącego. Wcześniej nie było to konieczne, bo jak wspomniałem Doctrine potrafi je odtwarzać w oparciu o refleksję. Gdyby wykorzystać poprzedni model to przy każdym odczytaniu z bazy danych emitowane byłoby zdarzenie o utworzeniu płatności, a status byłby ustawiany na oczekujący. I dlatego warto skorzystać z tak zwanych named constructors, czyli metod statycznych pozwalających utworzyć obiekty w różnych przypadkach. W tym podejściu konstruktor powinien być prywatny, co zablokuje możliwość stworzenia obiektu przez operator new.

Ostatnia z różnic, to gettery. Żeby przetransformować obiekty, trzeba mieć możliwość odczytania stanu obiektu. Można spotkać się z opinią, że jest to niewłaściwe. Wycieka stan agregatów na zewnątrz. Jest w tym trochę racji, ale nieszczęsną alternatywą jest tworzenie tak zwanych snapshotów. Tak naprawdę jest to implementacja wzorca Memento. Na papierze rozwiązanie świetne. W praktyce kolejny obiekt trzymający tę samą strukturę. Transformery to i tak już spory narzut i dodanie nowego pola powoduje, że trzeba przewalić się przez kilka warstw i dokonać modyfikacji w wielu plikach. Dodanie migawek to moim zdaniem przerost formy nad treścią. Mniej bolą mnie te gettery. Ich rozsądne używanie nie powinno stanowić problemu. Ponadto ułatwią testowanie stanu agregatu, więc dwie pieczenie na jednym ogniu. Bardziej niebezpieczne bywają settery, gdzie można wprowadzić obiekt w niespójność, ale tutaj też da się pilnować stanu. Na szczęście dzięki metodzie restore() nie ma potrzeby setterów.

Z drugiej strony, nie ma gwarancji że ktoś nie użyje metody restore() zamiast create(). Tak samo można użyć refleksji, czy po prostu dokonać zmian w kodzie. Chodzi o to, żeby publiczny interfejs był klarowny i jasno określał swoje przeznaczenie. A bez wątpienia tu ma to miejsce. Reszta modelu nie różni się w żaden sposób na tym etapie, ale wklejam je dla kompletności i przejrzystości.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use Koddlo\Shared\Domain\Exception\InvalidIdException;

final class PaymentId
{
    private const ID = '/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i';

    public function __construct(
        private string $id
    ) {
        $this->guard();
    }

    public function toString(): string
    {
        return $this->id;
    }

    private function guard(): void
    {
        if (! preg_match(self::ID, $this->id)) {
            throw new InvalidIdException('Invalid id format.');
        }
    }
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

final class Amount
{
    public function __construct(
        private int $value,
        private Currency $currency
    ) {}

    public function toInt(): int
    {
        return $this->value;
    }

    public function getCurrency(): Currency
    {
        return $this->currency;
    }
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

enum Currency: string
{
    case PLN = 'pln';
    case EUR = 'eur';
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

enum Method: string
{
    case CASH = 'cash';
    case ONLINE = 'online';

    public function isOnline(): bool
    {
        return self::ONLINE === $this;
    }
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

enum Status: string
{
    case PENDING = 'pending';
    case PAID = 'paid';
    case ARCHIVED = 'archived';
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Domain;

use Koddlo\Payment\Domain\Exception\PaymentNotFoundException;

interface PaymentRepositoryInterface
{
    public function save(Payment $payment): void;

    /**
     * @throws PaymentNotFoundException
     */
    public function get(PaymentId $id): Payment;
}

Warstwa infrastruktury

Trochę więcej będzie się działo w samej infrastrukturze. Po pierwsze, trzeba stworzyć encję Doctrine. Własności tej klasy polami przypominają wcześniejszy model. Nie ma jednak zachowań i jest to całkowicie anemiczna klasa z getterami i setterami. W zasadzie obeszłoby się też bez konstruktora, ale pilnuje on, czy wszystkie wymagane pola zostały ustawione. Tutaj nie ma też mapowania na typy Enum, a obiekt Amount został spłaszczony.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Infrastructure\Doctrine\Entity;

use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Id;

class Payment
{
    #[Id]
    #[Column(type: Types::GUID)]
    private string $id;

    #[Column(type: Types::INTEGER)]
    private int $amount;

    #[Column(type: Types::STRING, length: 8)]
    private string $currency;

    #[Column(type: Types::STRING, length: 16)]
    private string $method;

    #[Column(type: Types::STRING, length: 16)]
    private string $status;

    #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?DateTimeImmutable $paidAt = null;

    public function __construct(
        string $id,
        int $amount,
        string $currency,
        string $method,
        string $status
    ) {
        $this->id = $id;
        $this->amount = $amount;
        $this->currency = $currency;
        $this->method = $method;
        $this->status = $status;
    }

    public function getId(): string
    {
        return $this->id;
    }

    public function getAmount(): int
    {
        return $this->amount;
    }

    public function setAmount(int $amount): void
    {
        $this->amount = $amount;
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }

    public function setCurrency(string $currency): void
    {
        $this->currency = $currency;
    }

    public function getMethod(): string
    {
        return $this->method;
    }

    public function setMethod(string $method): void
    {
        $this->method = $method;
    }

    public function getStatus(): string
    {
        return $this->status;
    }

    public function setStatus(string $status): void
    {
        $this->status = $status;
    }

    public function getPaidAt(): ?DateTimeImmutable
    {
        return $this->paidAt;
    }

    public function setPaidAt(?DateTimeImmutable $paidAt): void
    {
        $this->paidAt = $paidAt;
    }
}

Do tego potrzebne będzie repozytorium Doctrine. Tym razem takie, które potrafi operować na encjach infrastrukturalnych. Teoretycznie to repozytorium mogłoby być wykorzystywane także do modelu odczytu. W tym podejściu model odczytu można oprzeć o encje. Oczywiście, nie rekomenduję takiego podejścia. Dla przyspieszenia prac i łatwości utrzymania może mieć to sens. Ale istnieje ryzyko, że ktoś zmodyfikuje stan encji w operacji odczytu. Chociaż mam nadzieje, że to nieporozumienie, które nie ma miejsca. Zdecydowanie lepsze okaże się wyciągnięcie encji w zapytaniu, ale ostatecznie przetransformowanie i zwrócenie obiektów DTO, czy tablic. Mając taki kontrakt, dzięki odpowiedniej enkapsulacji, w zasadzie nie jest istotne, co jest wewnątrz. Część prostych zapytań może oprzeć się o encje, a inne mogą być hydrowane do tablic. Te najbardziej wymagające mogą być nawet czystymi zapytania SQL.

To temat na inny materiał, ale wspominam o tym, bo to daje elastyczność osobnego modelu odczytu i zapisu. W dużym skrócie, chodzi o to by nie hydrować niepotrzebnie do encji, gdzie sama operacja jest kosztowna, a jeszcze Doctrine dodatkowo śledzi te obiekty i trzyma w pamięci. Wystarczające i bardziej efektywne będzie hydrowanie do tablic. Tak samo, jak w pierwszym podejściu będzie to lepsze rozwiązanie. W przypadku wspólnego modelu dla encji i domeny, opieranie na nim modelu odczytu też wymagało by dodania getterów, ale jak wspomniałem – nie rekomenduję.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Infrastructure\Doctrine\Repository;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Koddlo\Payment\Infrastructure\Doctrine\Entity\Payment;

/**
 * @extends ServiceEntityRepository<Payment>
 */
final class PaymentRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Payment::class);
    }

    public function save(Payment $entity): void
    {
        $this->_em->persist($entity);
        $this->_em->flush();
    }
}

I bohater tego podejścia, czyli transformer. Jego zadaniem jest tłumaczenie encji na model domenowy i odwrotnie. Zawiera dwie metody fromDomain() i toDomain(). Generalnie, przykładowy transformer nie ma raczej nic skomplikowanego. Są to proste w konstrukcji klasy. Trochę więcej roboty będzie w przypadku obiektów drzewiastych. Wówczas potrzeba będzie wstrzyknąć kilka repozytoriów i znaleźć też powiązane obiekty, a jeśli nie istnieją to je utworzyć.

Zapis warto zrobić to ramach opcji cascade persist, bo przecież agregaty są utrwalane w całości. Rzecz, o którą również trzeba zadbać to problem n+1. Przy samej transformacji, o ile istnieje wiele elementów potrzebnych do kolekcji to lepiej wyciągnąć je wszystkie w ramach jednego zapytania. Zamiast każdy osobno wewnątrz pętli. W sumie, jeżeli pola w modelu domenowym są nieedytowalne to nie trzeba ich uwzględniać w transformacji. W razie potrzeby zawsze można rozszerzyć.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Infrastructure\Transformer;

use Koddlo\Payment\Domain\PaymentId;
use Koddlo\Payment\Domain\Amount;
use Koddlo\Payment\Domain\Currency;
use Koddlo\Payment\Domain\Method;
use Koddlo\Payment\Domain\Payment;
use Koddlo\Payment\Domain\Status;
use Koddlo\Payment\Infrastructure\Doctrine\Entity\Payment as PaymentEntity;
use Koddlo\Payment\Infrastructure\Doctrine\Repository\PaymentRepository;

final class PaymentTransformer
{
    public function __construct(
        private PaymentRepository $repository
    ) {}

    public function fromDomain(Payment $payment): PaymentEntity
    {
        /** @var PaymentEntity|null $entity */
        $entity = $this->repository->find($payment->getId()->toString());
        if (null === $entity) {
            $entity = new PaymentEntity(
                $payment->getId()->toString(),
                $payment->getAmount()->toInt(),
                $payment->getAmount()->getCurrency()->value,
                $payment->getMethod()->value,
                $payment->getStatus()->value
            );
        } else {
            $entity->setAmount($payment->getAmount()->toInt());
            $entity->setCurrency($payment->getAmount()->getCurrency()->value);
            $entity->setMethod($payment->getMethod()->value);
            $entity->setStatus($payment->getStatus()->value);
        }
        $entity->setPaidAt($payment->getPaidAt());

        return $entity;
    }

    public function toDomain(PaymentEntity $entity): Payment
    {
        return Payment::restore(
            new PaymentId($entity->getId()),
            new Amount(
                $entity->getAmount(),
                Currency::from($entity->getCurrency())
            ),
            Method::from($entity->getMethod()),
            Status::from($entity->getStatus()),
            $entity->getPaidAt()
        );
    }
}

Finalnie, implementacja repozytorium domenowego działa w oparciu o repozytorium Doctrine i transformer. Taki zestaw pozwala dość elastycznie zarządzać modelem. Poniżej po raz kolejny kwestia wywołania tej części kodu w warstwie aplikacji. Niemalże to samo, co poprzednio. Jedyna różnica to inny mechanizm wytwarzania samej płatności.

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Infrastructure\Repository;

use Koddlo\Payment\Domain\Exception\PaymentNotFoundException;
use Koddlo\Payment\Domain\Payment;
use Koddlo\Payment\Domain\PaymentId;
use Koddlo\Payment\Domain\PaymentRepositoryInterface;
use Koddlo\Payment\Infrastructure\Doctrine\Entity\Payment as PaymentEntity;
use Koddlo\Payment\Infrastructure\Doctrine\Repository\PaymentRepository as PaymentDoctrineRepository;
use Koddlo\Payment\Infrastructure\Transformer\PaymentTransformer;

final class PaymentRepository implements PaymentRepositoryInterface
{
    public function __construct(
        private PaymentTransformer $transformer,
        private PaymentDoctrineRepository $repository
    ) {
    }

    public function save(Payment $payment): void
    {
        $this->repository->save(
            $this->transformer->fromDomain($payment)
        );
    }

    public function get(PaymentId $id): Payment
    {
        /** @var PaymentEntity|null $entity */
        $entity = $this->repository->find($id->toString());

        return null === $entity
            ? throw new PaymentNotFoundException()
            : $this->transformer->toDomain($entity);
    }
}

Warstwa aplikacji

<?php

declare(strict_types=1);

namespace Koddlo\Payment\Application\Command;

final readonly class RegisterPayment
{
    public function __construct(
        public string $id,
        public int $amount,
        public string $currency,
        public string $method
    ) {
    }
}
<?php

declare(strict_types=1);

namespace Koddlo\Payment\Application\Command;

use Koddlo\Payment\Domain\Amount;
use Koddlo\Payment\Domain\Currency;
use Koddlo\Payment\Domain\Method;
use Koddlo\Payment\Domain\Payment;
use Koddlo\Payment\Domain\PaymentId;
use Koddlo\Payment\Domain\PaymentRepositoryInterface;
use Koddlo\Shared\Application\Command\CommandHandlerInterface;
use Koddlo\Shared\Domain\DomainEventDispatcherInterface;

final class RegisterPaymentHandler implements CommandHandlerInterface
{
    public function __construct(
        private PaymentRepositoryInterface $repository,
        private DomainEventDispatcherInterface $eventDispatcher
    ) {
    }

    public function __invoke(RegisterPayment $command): void
    {
        $payment = Payment::create(
            new PaymentId($command->id),
            new Amount($command->amount, Currency::from($command->currency)),
            Method::from($command->method)
        );

        $this->repository->save($payment);
        $this->eventDispatcher->dispatch(...$payment->pullEvents());
    }
}

Podsumowując, ten mechanizm jest naprawdę elastyczny. Jest to pewna inwestycja, która może się zwrócić – ale nie musi. Nie ma tutaj mowy o jakimkolwiek wpływie persystencji na domenę. Można zapisywać dane nawet jako JSON, nie trzeba tworzyć własnych typów, używać Embeddables i tak dalej.

Skoro i tak samodzielnie trzeba przetłumaczyć obiekty, to po co ORM…

Nie jest tak, że Unit of Work i Identity Map tracą tutaj swoją funkcjonalność. Sam mechanizm śledzenia zmian, transakcji w pamięci i buforowania w pamięci też oddaje. Doctrine zagwarantuje, że nie trzeba dwa razy odpytywać bazy o ten sam obiekt, ale też wykona tylko potrzebne zapytania aktualizujące. Rozpoznawanie, co się zmieniło w danym obiekcie nie jest takie łatwe. Implementacja tego na własną rękę to także sporo pracy. Najprościej byłoby zawsze nadpisywać cały obiekt, ale znowu – po co jeśli nie dokonano żadnej zmiany albo dokonano tylko częściowej. Poza tym Doctrine przyda się też do modelu odczytu, dobrze zarządza połączeniem i kilka innych bajerów. Gdyby była potrzeba persystencji któregoś agregatu w oparciu o czyste zapytania SQL to też nie widzę problemu, ale nie jest to taki częsty przypadek. Raczej wchodzą tutaj w grę kwestie wydajnościowe, które dla pojedynczych obiektów nie powinny mieć znaczenia.

Rzecz warta wspomnienia to kwestia modularyzacji. Na samym początku, częstym sposobem integracji jest baza danych. Ma to swoje plusy i minusy, ale na pewno jest jednym z prostszych sposobów integracji – stąd też ochoczo wybieranym. Mając transformery można w prosty sposób czytać nawet z tych samych tabel dla innych agregatów. Zauważcie, że w przypadku podejścia pierwszego jest to niemożliwe, bo z jednej tabeli nie da się odtwarzać wielu encji. Nie twierdzę, że jest to dobra praktyka, ale na pewnym etapie może fajnie podnieść produktywność.

Jasne, wiele agregatów z tej samej tabeli może skończyć się porażką. Na przykład jeżeli mowa o dostępie do danych i ich potencjalnemu nadpisywaniu. W praktyce ratuje przed pewną replikacją danych, co wiąże się z bardziej skomplikowanymi mechanizmami jak asynchroniczne przetwarzanie zdarzeń i spójność ostateczna. Jeżeli każdy agregat ma swoją niezależną reprezentację w bazie danych to idealnie. Znowu wymaga pewnej gimnastyki, która może się okazać zbyteczna dla wielu projektów. Modularność na poziomie logicznym, czyli w kodzie to i tak już duży sukces. Moduły reprezentują konkretne jednostki dziedzinowe i model jest dużo bardziej adekwatny. Nie jest świadom, w jaki sposób jest persystowany. Co jest elastyczne, ale oczywiście trzeba mieć na uwadze, że istnieją ukryte sprzężenia właśnie w postaci bazy danych. Kiedy coś jest niejawne to potrafi się zemścić. Trzeba uważać. Fakt jest taki, że w dzisiejszym programowaniu źródła danych traktuje się bardziej jako magazyn danych. Nie ma tam miejsca na żadną logikę, a większość ograniczeń realizowanych jest na poziomie kodu.

Na co postawić?

Podejście z transformerami pozwala na dużo więcej, ale ma dodatkowy narzut. Sam do końca nie jestem przekonany, czy jest to zasadne. Mimo, że z powodzeniem stosuję także to podejście. Gdyby nie Doctrine i jego Data Mapper to w zasadzie nie ma dyskusji. Byłaby to jedyna sensowna opcja na reprezentację bogatej domeny. Ale Doctrine pozwala na prawie całkowite odseparowanie kwestii persystencji. I naprawdę, dla dużej części projektów okaże się to wystarczające. Bardziej należy skupić się na samym odkrywaniu i modelowaniu domeny, niż na kwestii utrwalania.

Dodatkowo, wychodząc od wspólnego modelu, nie jest powiedziane, że nie da się w trakcie życia aplikacji przerzucić na podejście z transformerami. W wielu projektach może sprawdzić się sensowne podejście hybrydowe. Może jest tak, że część agregatów jest odtwarzanych ze zdarzeń, część z encji Doctrine, a pozostała część operacji jest mocno CRUD i modyfikacje występują bezpośrednio w warstwie infrastruktury. Tak byłoby idealnie, ale to wiąże się z szerokim rozumieniem kilku podejść, a często chce się dążyć do spójności. Nawet kosztem trochę gorszego dopasowania rozwiązania do problemu.

Moim zdaniem oba rozwiązania są legitne. Ostatnio częściej pracuję z transformerami, ale nie jest to mój pierwszy wybór. To podejście ma spory narzut, a tak naprawdę zamyka mordy teoretykom. Jasne, płynie z tego wartość, co starałem się pokazać. Czy na tyle duża, że warto ponieść koszt? W większości projektów wyjście od modelu domenowego z mapowaniem będzie dobrym pomysłem. Wystarczy się przeprosić z kilkoma zależnościami, które w mojej opinii nie są tragiczne i nie warto walczyć o ich eliminację. Prawda jest taka, że transformery, w większości przypadków, mając już doświadczenie, pisze się w 5 minut. Tylko powielanie wielu tych pól w kilku miejscach jest uciążliwe. Zresztą jak każda próba wprowadzenia niezależności i dokładania kolejnych warstw.

W wielu aplikacjach widzę dużo większe problemy związane z całym DDD i ogólnie pojętą architekturą. Warto najpierw skupić się na właściwym modelowaniu, a dopiero potem na persystencji. Mimo wszystko, nie można odpuścić tego zagadnienia. Chciałem pokazać dwie popularne opcje, tak żeby rzucić trochę światła od tej strony. Jak powiedziałem, na koniec dnia nawet najlepszy model bez persystencji nie ma racji bytu.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.