Введение


О том, что такое DDD или же предметно-ориентированное проектирование информацию можно найти в интернете, например википедия.

Концепция DDD является довольно интересной и занимательной. Основная цель данного подхода в разработки ПО — создать программную модель, которая могла бы адекватно соответствовать реальной модели из предметной области (из вашего бизнеса). Также в модель закладывается ряд свойств, которые помогают ей быть гибкой и устойчивой к потенциальным изменениям под требования заказчика.

Дисклеймер


Данная статья не претендует на абсолютную правильность и полноту изложения материала, а лишь отражает личное мнение автора по данному вопросу, изложенном в сокращенной форме. Код также написан на скорую руку.

Основной акцент сделан НЕ в сторону описания всех элементов и концепций DDD, т.к. Э. Эванс посвятил этому целую книгу и в подобной статье я даже не пытаюсь охватить все принципы и подходы. Нет, тут в первую очередь продемонстрирована лишь техническая реализация интеграции Symfony и ORM Doctrine с такими элементами DDD, как сущности и репозитории. И уделено внимание к изоляции этих двух элементов от других уровней.

По сути вся статья посвящена тому, как извлечь данные через репозиторий и создать сущность.

Многоуровневая архитектура


В своем примере я рассмотрю три основных уровня, которые могут быть выделены в разрабатываемом приложении.

Domain. Уроверь предметной области


На данном уровне сосредоточен код, который отвечает только за логику предметной области. Сюда можно отнести все сущности, объекты значения, бизнес правила, понятия интерфейсов репозиториев, которые отвечают за хранения сущностей. На этом уровне содержится все алгоритмическая часть. Данный уровень самый важный и самый независимый. Он ничего не знает о том, какая используется база данных или под каким фреймворком вы разрабатываете. Данный уровень описывает через интерфейсы репозитории, в которых могут храниться сущности. Описывает, что у репозитория должны быть методы получения и сохранения сущности в каком-то хранилище, но никакой конкретной информацией о хранилище доменный уровень не обладает.

Application. Уровень приложения


Этот уровень описывает, как должно функционировать приложение. Возможно предполагается, что посетитель сайта заполняет какую-то форму, например, он оформил заказ и сервис уровня приложения должен получать информацию из формы оформления заказа (какие товары, сколько их и тд). Данный сервис должен носить функцию диспетчера, в нем не должно быть алгоритмической логики, она вся находится на уровне домена, он может «дергать» сервисы с уровня домена для выполнения конкретных задач. Также заметим, что этот уровень ничего не должен знать, о базе данных, фреймворке, о HTTP-запросах. Да, к нему приходят данные из формы с сайта, но он не знает как конкретно они были получены и собраны. Данный уровень должен быть довольно «тонким».

Infrastructure. Инфраструктурный уровень


Этот уровень знает о фреймворке, о базе данных, о брокерах очередей, о HTTP-запросах и умело взаимодействует со всем этим, а так же со всеми остальными уровнями. Он получает запросы от пользователя и передает их «приложению» для дальнейшей обработки. На этом уровне реализуются интерфейсы репозиториев, которые были описаны на уровне домена.

User Interface. Интерфейс пользователя


Э. Эванс (автор книги по DDD), рассматривает еще и уровень интерфейса пользователя, но я предпочитаю его объединять с инфраструктурным уровнем. На уровне интерфейса пользователя соответственно может быть код, отвечающий за это взаимодействие.

Также кажется допустимой возможность «склеить» уровень приложения с одним из уровней с доменом или инфраструктурой. Можно заметить, что уровней может быть и больше четырех, тут уже от вашей фантазии. Но в итоге получаем, что самые значимые и принципиально различимые это два уровня домена и инфраструктуры. Если же объекты с инфраструктурного уровня смогут просочится на уровень домена, то это сильно плохо. Вся идея изоляции проваливается и все разрабатываемое приложения начинает иметь сильные зависимости.

Зачем нужна такая многословная архитектура?


При правильной реализации основных принципов DDD появляется возможность простой миграции вашего приложения на любую другую инфраструктуру. Например, может быть легкая замена фреймворка. Возможно, просто миграция на его более новую версию или обновление отдельных систем, переход с одной ORM на другую. Но такой подход оправдывает себя в приложениях с долгим циклом жизни, которые развиваются и постоянно дорабатываются.

Основные элементы DDD


Сущности


В первом приближение это могут быть идентифицируемые объекты, т.е. то у чего его ID. Например, заказ, клиент, товар и тд. Часто сущности являются прямым представлением записей в базе данных и имеют соответствующие свойства: id, name, price и тд.

Объекты-значения


Объекты, которые идентифицировать не требуется. Например, имя, дата, цена, емейл. Данные объекты являются неизменяемыми, но это не значит, что мы не можем изменить цену у товара или емейл у клиента. Это означает, что мы при необходимости замены сначала создаем новый объект-значение, а заменяем им старое значение.

Службы


По сути в службах размещен весь основной управляющий код.

Репозитории


Хранилища для сущностей. С основными методами на подобие: получить, удалить, сохранить, получить по определенному значению и тд.

Фабрики


Удобно использовать для неявного создания сущностей.

В действительности элементов DDD несколько больше, но остановимся лишь на перечисленных.

Реализации. Практика


Принципы DDD можно вписать и использовать практически в любые приложения и фреймворки, но, как правило, основные затруднения встречаются при интеграции таких элементов как: сущности и репозитории. В зависимости от фреймворка на извлечение данных из базы, создание сущностей на их основе и повторном сохранении сущностей в базе данных, на все это может потребоваться довольно приличный объем кода. С использованием фремфорка Symfony и ORM Doctrine данная задача становится максимально простой и элегантной.

Domain


App\Domain\Entity\Client
<?php

declare(strict_types=1);

namespace App\Domain\Entity;

use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\ID;
use App\Domain\ValueObject\PersonName;
use App\Domain\ValueObject\RussianPhone;

class Client
{
    /**
     * @var ID
     */
    protected $id;

    /**
     * @var PersonName
     */
    protected $name;

    /**
     * @var Email
     */
    protected $email;

    /**
     * @var RussianPhone
     */
    protected $phone;

    /**
     * @var string
     */
    protected $address;

    /**
     * @return ID
     */
    public function getId(): ID
    {
        return $this->id;
    }

    /**
     * @return PersonName
     */
    public function getName(): PersonName
    {
        return $this->name;
    }

    /**
     * @param PersonName $name
     * @return $this
     */
    public function setName(PersonName $name): self
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return Email
     */
    public function getEmail(): Email
    {
        return $this->email;
    }

    /**
     * @param Email $email
     * @return $this
     */
    public function setEmail(Email $email): self
    {
        $this->email = $email;

        return $this;
    }

    /**
     * @return RussianPhone
     */
    public function getPhone(): RussianPhone
    {
        return $this->phone;
    }

    /**
     * @param RussianPhone $phone
     * @return $this
     */
    public function setPhone(RussianPhone $phone): self
    {
        $this->phone = $phone;

        return $this;
    }

    /**
     * @return string
     */
    public function getAddress(): string
    {
        return $this->address;
    }

    /**
     * @param string $address
     * @return $this
     */
    public function setAddress(string $address): self
    {
        $this->address = $address;

        return $this;
    }
}


В коде описан класс сущности клиента. В данной сущности используется всего пять свойств: ID, имя, емейл, телефон и адрес. Все свойства представлены в виде объектов-значений, кроме адреса, который имеет базовый примитив типа строка. И набор геттеров и сеттеров для установки и получения данных значений.

Рассмотрим свойства подробнее.

App\Domain\ValueObject\AbstractValueObject
<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

abstract class AbstractValueObject
{
    /**
     * @return mixed
     */
    abstract public function getValue();
}


App\Domain\ValueObject\ID
<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

class ID extends AbstractValueObject
{
    /**
     * @var int
     */
    protected int $id;

    /**
     * @param int $value
     */
    public function __construct(int $value)
    {
        $this->id = $value;
    }

    /**
     * {@inheritDoc}
     */
    public function getValue(): int
    {
        return $this->id;
    }
}


Интересно, что ID представлен не привычным числом в сущности, а именно объектом-значения. В конкретном примере базовый тип ID — целое число, но при желании ничего не мешает сделать его и строкой с минимальными изменениями в проекте. В данном примере имеется отступление от изначальной идеи Э. Эванса. Устанавливать вручную в качестве ID генерируемый уникальный идентификатор UUID. Т.к. сделан акцент в сторону реально используемой реляционной базы данных в данном приложении с автоматическим инкрементом для ID. Но не сложно бы было в будущем модифицировать данный класс и для поддержки UUID в виде строки.

App\Domain\ValueObject\PersonName
<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

class PersonName extends AbstractValueObject
{
    /**
     * @var string
     */
    protected string $firstName;

    /**
     * @var string
     */
    protected string $lastName;

    /**
     * @var string
     */
    protected string $middleName;

    /**
     * @var string
     */
    protected string $fullName;

    /**
     * @param string $name
     */
    public function __construct(string $name)
    {
        $this->fullName = $name;
        $this->normalizeName($name);
    }

    /**
     * @param string $name
     * @return void
     */
    protected function normalizeName(string $name): void
    {
        [$this->lastName, $this->firstName, $this->middleName] = explode(' ', $name);
    }

    /**
     * @return string
     */
    public function getFirstName(): string
    {
        return $this->firstName;
    }

    /**
     * @return string
     */
    public function getLastName(): string
    {
        return $this->lastName;
    }

    /**
     * @return string
     */
    public function getMiddleName(): string
    {
        return $this->middleName;
    }

    /**
     * @return string
     */
    public function getFullName(): string
    {
        return $this->fullName;
    }

    /**
     * {@inheritDoc}
     */
    public function getValue()
    {
        return $this->getFullName();
    }

    /**
     * @return string
     */
    public function __toString()
    {
        return $this->getFullName();
    }
}


Тут мы видим, что имя клиента — это не просто какая-то строка, а вполне осознанный объект, который может пригодится для дальнейшей работы. Тут выполняется нормализация имени и в дальнейшем можно получить из данного объекта дополнительную информацию в виде фамилии, имени и отчества. Вся эта логика реализована в одном объекте, что отлично согласовывается с принципом единой ответственности. При желании поведение всей системы может быть централизовано изменено только при редактировании одного класса. Данный класс может реализовываться через интерфейсы взаимодействия, например, с DaData для проверки и нормализации имен (ну или чем-то более быстрым).

App\Domain\ValueObject\Email
<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

use App\Domain\ValueObject\Exception\InvalidEmailException;

class Email extends AbstractValueObject
{
    /**
     * @var string
     */
    protected string $email;

    /**
     * @param string $email
     * @throws InvalidEmailException
     */
    public function __construct(string $email)
    {
        if (false === filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailException($email);
        }
        $this->email = $email;
    }

    /**
     * {@inheritDoc}
     */
    public function getValue(): string
    {
        return $this->email;
    }

    /**
     * @return string
     */
    public function __toString()
    {
        return $this->getValue();
    }
}


Для емейла выполняется валидация при его создании.

App\Domain\ValueObject\Phone
<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

use App\Domain\ValueObject\Exception\InvalidPhoneException;

abstract class Phone extends AbstractValueObject
{
    /**
     * @var string
     */
    protected string $phone;

    /**
     * @param string $phone
     */
    public function __construct(string $phone)
    {
        $this->validate($phone);

        $this->phone = $this->normalizeNumber($phone);
    }

    /**
     * @param string $phone
     * @return string
     */
    protected function clearPhone(string $phone): string
    {
        return str_replace(['+', '-', ' ', '(', ')'], '', $phone);
    }

    /**
     * @param string $phone
     * @return void
     * @throws InvalidPhoneException
     */
    protected function validate(string $phone): void
    {
        if (false === filter_var($this->clearPhone($phone), FILTER_SANITIZE_NUMBER_INT)) {
            throw new InvalidPhoneException($phone);
        }
    }

    /**
     * @param string $phone
     * @return string
     */
    abstract protected function normalizeNumber(string $phone): string;

    /**
     * {@inheritDoc}
     */
    public function getValue(): string
    {
        return $this->phone;
    }

    /**
     * @return string
     */
    public function __toString()
    {
        return $this->getValue();
    }
}


App\Domain\ValueObject\RussianPhone
<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

use App\Domain\ValueObject\Exception\InvalidPhoneNumberLengthException;
use App\Domain\ValueObject\Exception\PhoneNumberShouldStartWithException;

class RussianPhone extends Phone
{
    protected const NUMBER_LENGTH = 11;

    protected const FIRST_DIGIT = ['7', '8'];

    /**
     * {@inheritDoc}
     */
    protected function validate(string $phone): void
    {
        parent::validate($phone);

        $this->checkLength($phone);
        $this->checkFirstDigit($phone);
    }

    /**
     * {@inheritDoc}
     */
    protected function normalizeNumber(string $phone): string
    {
        $phone = $this->clearPhone($phone);
        $area = substr($phone, 1, 3);
        $prefix = substr($phone, 4, 3);
        $number = substr($phone, 7, 4);

        return sprintf('+7 (%s)-%s-%s', $area, $prefix, $number);
    }

    /**
     * @param string $phone
     * @return void
     * @throws InvalidPhoneNumberLengthException
     */
    protected function checkLength(string $phone): void
    {
        $length = strlen($this->clearPhone($phone));

        if (self::NUMBER_LENGTH !== $length) {
            throw new InvalidPhoneNumberLengthException($phone, self::NUMBER_LENGTH, $length);
        }
    }

    /**
     * @param string $phone
     * @throws PhoneNumberShouldStartWithException
     */
    protected function checkFirstDigit(string $phone): void
    {
        $firstChar = substr($this->clearPhone($phone), 0, 1);
        if (!in_array($firstChar, self::FIRST_DIGIT)) {
            throw new PhoneNumberShouldStartWithException($phone, self::FIRST_DIGIT);
        }
    }
}


Через механизмы наследования и полиморфизм можно довольно гибко менять поведение системы. Но у нашего конкретного клиента на текущий момент жестко прописана возможность иметь только российский телефонный номер (возможно, на текущий момент это такое бизнес требование).

Объекты-значения — это очень мощный инструмент для валидации и нормализации входящих данных. Но помимо этого при использовании интерфейсов присутствует возможность гибкой замены одних типов другими. Но с целью ускорения разработки можно не реализовывать все подряд в виде объектов-значений, а использовать базовые примитивы PHP.

App\Domain\Factory\ClientFactoryInterface
<?php

namespace App\Domain\Factory;

use App\Domain\Entity\Client;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\PersonName;
use App\Domain\ValueObject\RussianPhone;

interface ClientFactoryInterface
{
    public function make(): Client;

    /**
     * @param PersonName $name
     * @return $this
     */
    public function setName(PersonName $name): ClientFactoryInterface;

    /**
     * @param Email $email
     * @return $this
     */
    public function setEmail(Email $email): ClientFactoryInterface;

    /**
     * @param RussianPhone $phone
     * @return $this
     */
    public function setPhone(RussianPhone $phone): ClientFactoryInterface;

    /**
     * @param string $address
     * @return $this
     */
    public function setAddress(string $address): ClientFactoryInterface;
}


Интерфейс фабрики по созданию сущностей клиентов.

App\Domain\Repository\ClientRepositoryInterface
<?php

namespace App\Domain\Repository;

use App\Domain\Entity\Client;
use App\Domain\ValueObject\ID;

interface ClientRepositoryInterface
{
    /**
     * @param ID $id
     * @return Client|null
     */
    public function getById(ID $id): ?Client;

    /**
     * @param Client $client
     * @return Client
     */
    public function save(Client $client): Client;

    /**
     * @param Client $client
     * @return void
     */
    public function delete(Client $client): void;
}


Интерфейс репозитория для сохранения, получения, удаления сущностей клиентов.

Infrastructure


На инфраструктурном уровне начинается самая основная сложность. Для работы с базой данных используется ORM Doctrine, с ее помощью можно легко выполнять манипуляции с данными.

App\Infrastructure\Entity\Client
<?php

declare(strict_types=1);

namespace App\Infrastructure\Entity;

use App\Domain\Entity\Client as DomainClient;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\ID;
use App\Domain\ValueObject\PersonName;
use App\Domain\ValueObject\RussianPhone;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Infrastructure\Repository\ClientRepository")
 */
class Client extends DomainClient
{
    /**
     * @var int
     *
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    protected $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string")
     */
    protected $name;

    /**
     * @var string
     *
     * @ORM\Column(type="string")
     */
    protected $email;

    /**
     * @var string
     *
     * @ORM\Column(type="string")
     */
    protected $phone;

    /**
     * @var string
     *
     * @ORM\Column(type="string")
     */
    protected $address;

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

    public function getName(): PersonName
    {
        return new PersonName($this->name);
    }

    /**
     * @param PersonName $name
     * @return $this
     */
    public function setName(PersonName $name): DomainClient
    {
        $this->name = (string) $name;

        return $this;
    }

    public function getEmail(): Email
    {
        return new Email($this->email);
    }

    /**
     * @param Email $email
     * @return $this
     */
    public function setEmail(Email $email): DomainClient
    {
        $this->email = (string) $email;

        return $this;
    }

    public function getPhone(): RussianPhone
    {
        return new RussianPhone($this->phone);
    }

    /**
     * @param RussianPhone $phone
     * @return $this
     */
    public function setPhone(RussianPhone $phone): DomainClient
    {
        $this->phone = (string) $phone;

        return $this;
    }

    public function getAddress(): string
    {
        return $this->address;
    }

    /**
     * @param string $address
     * @return $this
     */
    public function setAddress(string $address): DomainClient
    {
        $this->address = $address;

        return $this;
    }
}


На инфраструктурном уровне реализуется доктриновская сущность клиента — потомок доменной сущности. Через перегруженные сеттеры и геттеры происходит трансформация типов данных из объектов-значений доменной сущности в доктриновские примитивы.

App\Infrastructure\Repository\ClientRepository
<?php

namespace App\Infrastructure\Repository;

use App\Domain\Entity\Client as DomainClient;
use App\Domain\Repository\ClientRepositoryInterface;
use App\Domain\ValueObject\ID;
use App\Infrastructure\Entity\Client;
use Doctrine\Common\Persistence\ManagerRegistry;

/**
 * @method Client|null find($id, $lockMode = null, $lockVersion = null)
 * @method Client|null findOneBy(array $criteria, array $orderBy = null)
 * @method Client[]    findAll()
 * @method Client[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class ClientRepository extends AbstractRepository implements ClientRepositoryInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Client::class);
    }

    /**
     *  {@inheritdoc}
     */
    public function getById(ID $id): ?DomainClient
    {
        return $this->find($id->getValue());
    }

    /**
     *  {@inheritdoc}
     *
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    public function save(DomainClient $client): DomainClient
    {
        if (!($client instanceof Client)) {
            throw new \LogicException('Exception instanceof');
        }

        $this->getEntityManager()->persist($client);
        $this->getEntityManager()->flush();

        return $client;
    }

    /**
     * {@inheritdoc}
     *
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    public function delete(DomainClient $client): void
    {
        if (!($client instanceof Client)) {
            throw new \LogicException('Exception instanceof');
        }

        $this->getEntityManager()->remove($client);
        $this->getEntityManager()->flush();
    }
}


Репозиторий имплементирует исходный доменовский интерфейс. Метод getById возвращает доменную сущность, как и было объявлено в исходном интерфейсе. Но в действительности на ее месте всегда оказывается потомок — доктриновская сущность.

А при сохранении и удалении репозиторий выполняет дополнительную проверку входного параметра $client, чтобы удостовериться, что в действительности используется полиморфизм и на вход поступает не оригинальная доменовская сущность клиента, а потомок — доктриновская сущность, только с ней репозиторий может продолжить работу.

App\Infrastructure\Factory\ClientFactory
<?php

declare(strict_types=1);

namespace App\Infrastructure\Factory;

use App\Domain\Entity\Client as DomainClient;
use App\Domain\Factory\ClientFactoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\PersonName;
use App\Domain\ValueObject\RussianPhone;
use App\Infrastructure\Entity\Client;

class ClientFactory implements ClientFactoryInterface
{
    /**
     * @var PersonName
     */
    protected $name;

    /**
     * @var Email
     */
    protected $email;

    /**
     * @var RussianPhone
     */
    protected $phone;

    /**
     * @var string
     */
    protected $address;

    protected function __construct()
    {
    }

    /**
     * @return static
     */
    public static function create(): ClientFactoryInterface
    {
        return new self();
    }

    /**
     * {@inheritDoc}
     */
    public function make(): DomainClient
    {
        return (new Client())
            ->setName($this->getName())
            ->setEmail($this->getEmail())
            ->setPhone($this->getPhone())
            ->setAddress($this->getAddress());
    }

    /**
     * @return PersonName
     */
    public function getName(): PersonName
    {
        return $this->name;
    }

    /**
     * @param PersonName $name
     * @return $this
     */
    public function setName(PersonName $name): ClientFactoryInterface
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return Email
     */
    public function getEmail(): Email
    {
        return $this->email;
    }

    /**
     * @param Email $email
     * @return $this
     */
    public function setEmail(Email $email): ClientFactoryInterface
    {
        $this->email = $email;

        return $this;
    }

    /**
     * @return RussianPhone
     */
    public function getPhone(): RussianPhone
    {
        return $this->phone;
    }

    /**
     * @param RussianPhone $phone
     * @return $this
     */
    public function setPhone(RussianPhone $phone): ClientFactoryInterface
    {
        $this->phone = $phone;

        return $this;
    }

    /**
     * @return string
     */
    public function getAddress(): string
    {
        return $this->address;
    }

    /**
     * @param string $address
     * @return $this
     */
    public function setAddress(string $address): ClientFactoryInterface
    {
        $this->address = $address;

        return $this;
    }
}


Данная фабрика используется для создания сущностей клиентов. Создание доменной сущности напрямую через конструктор для клиентского кода во многом не корректно. Как минимум, основной момент в том, что с экземпляром подобной оригинальной доменной сущности не сможет работать репозиторий. А фабрика опять же возвращает потомка — доктрировскую сущность, с которой все OK.

Application


На уровне приложения, в качестве примера, представлен совсем простой код, который по сути проксирует операции «чтения/записи» к репозиторию.

Уровень приложения высосан из пальца и концептуально не является верным.

App\Application\Service\ClientServiceInterface
<?php

namespace App\Application\Service;

use App\Domain\Entity\Client;
use App\Domain\ValueObject\ID;

interface ClientServiceInterface
{
    /**
     * @param Client $client
     * @return Client
     */
    public function save(Client $client): Client;

    /**
     * @param ID $ID
     * @return Client|null
     */
    public function get(ID $ID): ?Client;

    /**
     * @param Client $client
     * @return void
     */
    public function delete(Client $client): void;
}


App\Application\Service\ClientService
<?php

declare(strict_types=1);

namespace App\Application\Service;

use App\Domain\Entity\Client;
use App\Domain\Repository\ClientRepositoryInterface;
use App\Domain\ValueObject\ID;

class ClientService implements ClientServiceInterface
{
    /**
     * @var ClientRepositoryInterface
     */
    protected ClientRepositoryInterface $repository;

    public function __construct(ClientRepositoryInterface $repository)
    {
        $this->repository = $repository;
    }

    /**
     * {@inheritdoc}
     */
    public function save(Client $client): Client
    {
        return $this->repository->save($client);
    }

    /**
     * {@inheritdoc}
     */
    public function get(ID $ID): ?Client
    {
        return $this->repository->getById($ID);
    }

    /**
     * {@inheritdoc}
     */
    public function delete(Client $client): void
    {
        $this->repository->delete($client);
    }
}


В действительности жизненный цикл может происходить следующим образом. Сначала приходит HTTP-запрос с данными, он поступает в контроллер на инфраструктурном уровне или уровне пользовательского интерфейса, который в данном примере вовсе не представлен. Далее может быть создан объект-параметр для передачи информации из запроса с инфраструктурного уровня на уровень приложения. Никакие зависимости с инфраструктурного уровня о HTTP-запросе не должны протекать на уровень приложения, именно для этого следует использовать промежуточный объект-параметр или DTO.

Testing


Далее представлен возможный клиентский код:

App\Tests\ClientTest
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Application\Service\ClientServiceInterface;
use App\Domain\Entity\Client;
use App\Domain\Factory\ClientFactoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\PersonName;
use App\Domain\ValueObject\RussianPhone;
use Doctrine\ORM\EntityManager;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class ClientTest extends KernelTestCase
{
    /**
     * @var EntityManager
     */
    protected $entityManager;

    protected function setUp(): void
    {
        parent::bootKernel();

        $this->entityManager = self::$container
            ->get('doctrine')
            ->getManager();

        $this->entityManager->getConnection()->beginTransaction();
    }

    protected function tearDown(): void
    {
        $this->entityManager->getConnection()->rollBack();
        $this->entityManager->close();
        $this->entityManager = null;
        parent::tearDown();
    }

    public function test(): void
    {
        /** @var ClientServiceInterface $clientService */
        $clientService = self::$container
            ->get(ClientServiceInterface::class);

        /** @var ClientFactoryInterface $factory */
        $factory = self::$container
            ->get(ClientFactoryInterface::class);

        $client = $clientService->save(
            $factory
                ->setName(new PersonName('Иванов Иван Иванович'))
                ->setEmail(new Email('mail@mail.ru'))
                ->setPhone(new RussianPhone('+7-922-743-22-11'))
                ->setAddress('address')
                ->make()
        );

        $this->assertInstanceOf(Client::class, $client);

        $this->assertSame('Иванов Иван Иванович', (string)$client->getName());
        $this->assertSame('Иван', (string)$client->getName()->getFirstName());
        $this->assertSame('Иванов', (string)$client->getName()->getLastName());
        $this->assertSame('Иванович', (string)$client->getName()->getMiddleName());

        $id = $client->getId();

        $client = $clientService->get($id);

        $this->assertInstanceOf(Client::class, $client);

        $clientService->delete($client);

        $client = $clientService->get($id);

        $this->assertNull($client);
    }
}


Пользовательскому коду необходимо запросить два интерфейса: интерфейс фабрики по созданию клиентов и интерфейс сервера для реализаций функционала приложения по работе с клиентом. Никакие зависимости инфраструктурного уровня не распространяются на клиентский код.

Заключение


Использование Symfony совместно с ORM Doctrine позволяет организовать простой и изящный подход по извлечению сущностей и реализации репозиториев.

А вот, например, с фреймворком Laravel ситуация обстоит немного иначе и подобный подход для извлечения данных из репозиториев уже требует иной реализации.

Готовый код примера можно скачать тут.

Комментарии (8)


  1. mmasiukevich
    01.12.2019 03:34

    Принципы DDD не имеют ничего общего с тем, что описано в статье.


    /sarcasm
    Что до кода, как такового, то с анемичной моделью только DDD и строить, да. Это отлично вписывается в концепцию


  1. symbix
    01.12.2019 03:42
    +1

    А вы, простите, Эванса вообще читали? Какое отношение анемичные сущности с геттерами и сеттерами имеют к DDD? Где domain events? Где bounded contexts?


    Какой-то карго-культ. Вот посмотрят люди на это и подумают — какая ерунда этот ваш DDD, понаписали тонну бойлерплейта непонятно зачем. И будут правы, за тем исключением, что вот ЭТО — не DDD.


    1. korobovn Автор
      01.12.2019 11:05

      Акцент в статье сделан на другое. Bounded contexts вряд ли можно продемонстрировать в двух строчках кода даже при всем желании.


  1. makcumka2000
    01.12.2019 10:58

    В статье описан пример Service Layer Architecture, но не DDD.
    DDD — это единый язык между бизнесом и разработкой, ваш код должен отражать действия из реального мира, таким образом что в идеале ваш код должен читать доменный эксперт и все понимать.
    Если имя, телефон, и емейл являются обязательными для клиента, засуньте их в конструктор и избавьтесь от сеттеров.
    Зачем вам наследник от доменной модели? Все что вы описали можно сделать на основе дополнительных типов и embeded свойств, настроив доктрину таким образом, что при восстановлении в сущности будут уже нужные Value objects.
    Зачем вам наследование от базового ValueObject? Плюсов не вижу, а минусы есть.
    Уровень Application — это по сути ваши пользовательские сценарии, врядли у вас есть сценарий — сохрани пользователя. Скорее будет — измени пользователя, в который будут передаваться ID клиента и данные которые нужно поменять.
    Все таки в Application может быть алгоритмическая логика — там не может бизнес-логики.
    Application — должен принимать скаляры/Dto, а не бизнес сущности. А уже внутри получать эти сущности из репозиториев, выполнять над ними действия и сохранять.
    Метод гет из репозитория — должен строго возвращать сущность или кидать исключение. Иначе по всему коду будете проверять на null.


    1. korobovn Автор
      01.12.2019 11:24

      Наследник используется, чтобы не примешивать аннотации и неймспейсы доктрины к доменной сущности, что обеспечивает на мой взгляд более удачное и понятное разделение уровней. Через Embedded хороший способ, но для примера решил пойти более явным путем.

      Базовый ValueObject дает возможность ввести общий интерфейс, например, для получения значения. Какие вы находите в этом минусы?

      Да, про уровень приложения полностью согласен.


      1. mmasiukevich
        01.12.2019 12:36

        Аннотации — компромисс, допустимое зло.
        Никакого вреда они не несут. А вот их фанатичное выпиливание, равно как и размазывание тупо по папочкам — несёт колоссальное количество боли там, где её в принципе не должно быть.


  1. makcumka2000
    01.12.2019 12:04
    +1

    Если вам нужен общий интерфейс — так и используйте интерфейс, а не абстрактный класс. Можно даже пустой.
    В данном случае я имел в виду, что не всем ValueObjects нужен указанный метод grtValue. Даже для указанного в примере имени, что именно должен возвращать getValue? Если сконкатенированное фио — то это уже логика отображения, я бы ее сюда не стал примешивать. Для объектов из одного св-ва, например email, это подходит, для составных — нет.
    Насчёт аннотаций и неймспейсов — для этого можно использовать конфигурацию через xml/yaml. Мне лично нравится использование yaml конфигурации, но это вроде как обьявили deprecated.
    Получается в примере выше я всегда должен либо использовать фабрику, либо помнить о том, что я не могу создавать DomainClient напрямую, иначе получу исключение при сохранении. В дальнейшем если захотите отнаследоваться от Клиента и использовать тот же самый репозиторий — у вас этого сделать не получится.


    1. korobovn Автор
      01.12.2019 12:43

      Про ValueObjects соглашусь.

      Да, в примере вышло так, что явно создавать сущность будет некорректно. И это вполне осознанная идея данной статьи (допускаю, что она может быть не самой удачной).
      Но в классическом варианте реализации предполагается, что репозиторий должен уметь трансформировать доменовскую сущность в конечный запрос к базе данных. Т.е. в простом варианте это предполагает создание внутри репозитория доктриновской или иной ORM модели и наполнение ее из доменной сущности. Тоже самое и при восстановление, у нас имеется orm-модель и мы должны с ее помощью создать доменовский объект сущности. И хоть такой подход по сути более универсальный, но в плане разрастания кода это кажется очень объемным и неудобным. А в данном примере за счет такого наследования получается мы автоматом избавляемся от этой необходимости и сохранение/восстановление сущностей становится намного проще. С yaml конфигурированием соглашусь, это может быть хорошей альтернативой.