Всем привет! Давайте знакомиться ;) Я Аня, и я php разработчик. Основной стек - Magento. С недавних пор начала посматривать налево на Symfony и писать свои Pet Projects на этом фреймворке.

Мне всегда нравилось писать решения которые легко бы расширялись / адаптировались под требования бизнеса (заказчика). И мне всегда хотелось сделать это более 'правильно' и красиво. Так я и познакомилась с понятиями чистой архитектурой.

Мой пост ни в коем случае не претендует на самый правильный. Но позвольте мне здесь донести свои идеи. В комментариях буду рада услышать конструктивную критику к данному посту.

Для нетерпеливых, вот прямая ссылка на гитхаб

Содержание статьи (Теоретическая часть)

  1. Что такое DDD

  2. Что такое гексагональная архитектура

  3. Что такое чистая архитектура

  4. Плюсы и минусы "архитектурной" разработки

Содержание статьи (Практическая часть)

  1. Введение

  2. Разбиваем проект на фичи

  3. Работа с данными: DataManagerFeatureApi

  4. Манипуляция с данными: DoctrineDataFeature (implements DataManagerFeatureApi)

  5. CategoryFeatureApi и CategoryFeature

  6. PostFeatureApi и PostFeature

  7. FrontFeature

  8. Итоговый вариант

Прежде чем мы начнём разбирать как написать блог на Symfony с DDD и Clean Architecture, разберем основную теорию. И так, приступим…

Что такое DDD (Domain Driven Design | Domain Driven Development)

DDD (Domain Driven Design | Domain Driven Development) – это архитектурный подход, задача которого – это выделение бизнес логики (Domain) приложения. Отсюда и название – Domain Driven. Здесь также принимаются во внимание проектирование классов, паттерны, хорошие практики и тд, но это все внутри доменного слоя.

Доменный слой (Domain) не зависим от внешних библиотек, Domain не привязан к базе данных, к поисковому движку и никогда ничего не знает про детали реализации вашего приложения (например, какую БД использовать, вид кеша и тд). Также доменный слой не привязан к фреймворку, если вы используете, например, DDD на Symfony, то в идеале, перенесенный доменный слой на Laravel (либо другой фреймворк), должен сохранять свою работоспособность.

Иными словами, Domain – это сердце приложение, максимально защищенное от изменения извне. Здесь происходят самые важные процессы, касаемые бизнес-правил, то, без чего бизнес не может существовать.

Совсем кратко: Domain Driven Design | Domain Driven Development – это выделение бизнес логики и его изоляция от внешних факторов.

Что такое гексагональная архитектура (Hexagonal architecture)

Гексагональная архитектура – это вид архитектуры, который определяет способ общения элементов друг с другом путем разделения приложения на слои (layers), которые в свою очередь, используют так называемые порты для общения друг с другом.

Что такое порт в гексагональной архитектуре? Если говорить языком ООП – это интерфейс. Каждый слой использует порт-интерфейс другого слоя, а не конкретную реализацию.

Что же такое слои приложение (application’s layers)?

И так, слой – это группировка проекта, где каждая группа имеет свое конкретное предназначение. В зависимости от нужд проекта, могут появляться дополнительные слои, либо сокращаться количество слоев.

Существует несколько вариаций, но на мой взгляд, самым оптимальным решением является разбиение приложения на следующие слои:

Presentation Layer (слой представления) – здесь находится все, что отвечает за input/output (ввод/вывод), например, шаблоны, консольные команды, контроллеры. Весь input/output конвертируется в DTO Request (data transfer object) и передается на слой ниже, в Application Layer. Так же, данный слой занимается отображением DTO Response из Application Layer. Здесь только ввод и вывод, никакой логики данный слой в себе не содержит.

Application Layer (слой приложения) – прослойка между Domain и Presentation, здесь происходит получениe DTO Request из input/output (Presentaion Layer), первичная обработка / валидация ввода, маппинг DTO объектов в Domain Layer и наоборот, вызов бизнес логики из Domain Layer.

Иными словами, получили реквест, произвели маппинг в Domain Entity, вызвали интерактор / usecase, получили результат от Domain Layer, сделали мапинг в DTO Response и вернули на слой Presentation, где уже будет показан результат пользователю (если необходимо)

Domain Layer (доменный слой) – слой с бизнес логикой. Чуть более подробно разберем в разделе Clean Architecture. ВАЖНО: В идеале, доменный слой не знает про существование других слоев.

Infrastructure – слой, работающий со сторонними библиотеками, фреймворками и тд.

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

Важно отметить, что каждый слой имеет свои мапперы, которые преобразуют объект из текущего слоя в объект следующего слоя. Более детально мы разберем это на практике.

Что такое чистая архитектура (Clean Architecture)

Clean Architecture – была описана Робертом Мартином («Дядюшкой Бобом») - очень рекомендую его книги к прочтению. Данная архитектура основывается на гексагональной архитектуре, однако с небольшими дополнениями. Здесь вводятся такие понятия, как: Use Case, Interactor, Entity.

Что же такое Domain Entity простыми словами? Это сущности, без которых бизнес просто не может существовать. Например, давайте представим банк, и отбросим все детали реализации. Что будет являться domain entity для банка? Это наверняка будет UserEntity, AccountEntity и тд.

Что же такое Use Case простыми словами? По сути, это процессы, без которых не может существовать бизнес. На примере банка, Use Case будет являться открытие счета, создание вклада, закрытие счета, заказ карты и тд.

Что же тогда Intercator означает? Это механизм, объединяющий несколько Use Case. Говоря на языке ООП – это класс, в котором описаны несколько Use Case. Как правило, это делается для того, чтобы сделать код более компактным.

Я стараюсь придерживаться чистой архитектуры, она включает в себя все необходимое, что нужно для проектирования приложения с масштабируемым кодом.

Плюсы и минусы чистой архитектуры :

У данного подхода есть положительные и отрицательные стороны. Можно бесконечно долго рассуждать на тему архитектурных подходов, и выделить множество “за” и “против”, однако здесь я приведу самые основные пункты, которые, на мой взгляд, являются самыми важными.

Начнем с минусов:

- Проблема с кадрами – далеко не каждый разработчик знает про разделение приложения на слои, архитектуру, да и в целом, для чего это нужно. Отсюда вытекает пункт ниже

- Дороговизна в разработке - данный пункт можно разделить на 2 момента. Во-первых, такой подход требует как минимум грамотного архитектора, либо сеньера, который изначально имеет большой рейт за час. Во-вторых, на построение архитектуры и выполнение задач требуется больше времени, тк выделение доменного слоя и написание экосистемы для коммуникации с доменом требует бОльшего написания кода (на первых этапах).

- Много однотипного кода (не путать с копипастой!) - приготовьтесь писать множество кода, да и еще схожего друг с другом от фичи к фиче (и от слоя к слою). Да, api, вечные маппинги и тд иногда очень сильно нервируют.

- Постоянный самоконтроль – если на уровне языка не предусматривается разделение кода на пакеты (как в Java), нужно постоянно себя контролировать, чтобы не использовать классы/интерфейсы из других слоев/фич, минуя api.

Теперь поговорим о плюсах чистой архитектуры:

- Слабая зависимость кода – тк приложение разделено на слои, где каждый слой изолирован друг от друга, то изменение в одном слое не влияет на другой.

- Легко масштабировать – тк слои не зависят друг от друга, то при изменении бизнес процессов, проект можно легко масштабировать, либо же наоборот – убрать функционал, который больше не нужен.

- Легко тестировать – данный код хорошо поддается тестированию. За счет изолирования, кода по слоям, при изменении кода не нужно переписывать большое количество тестов.

- Дешево поддерживать – да, разрабатывать продукт с таким подходом дорого, однако, если смотреть наперспективу, то последующая поддержка проекта будет дешевле. Чего нельзя сказать о проектах, которые не придерживаются архитектурных подходов.


Когда не нужно использовать чистую архитектуру?

Чистая архитектура – это очень мощный инструмент для создания действительно расширяемого и стабильного приложения. Это не только теория / инструкция, но так же и образ мышления. Когда же использования чистой архитектуры не будет оправдывать себя? Я бы сказала, что она всегда себя оправдывает. Ведь хорошо написанное приложение еще никогда не вызывало негативной реакции! Однако не стоит забывать про коммерцию и бизнес. Не все клиенты, у которых проекты с маленьким жизненным циклом, готовы платить больше за хорошую архитектуру. Но это не означает, что маленькие проекты не заслуживают хорошего кода! =)

И так, теперь к практике:

Практика: Создание блога на Symfony с использованием чистой архитектуры (Clean Architecture)

Выше я писала про разделение приложения на слои. Однако довольно часто приложение разбивается не только на слои, но и на фичи. Каждая фича имеет такой же набор слоев, что были описаны выше. (допускается добавление/удаление определенных слоев в фичах). Мне больше нравится вариант разделения проекта по фичам.

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

Прежде, чем начнем, скажу сразу – я не симфони девелопер, мне нравится этот фреймворк и обкатывать свои проекты/идеи. Я знаю, что у симфони есть свой best practice , в котором описаны “папочки” проекта и что там должно быть. Но папочки – это не архитектура, не так ли? А фреймворк предлагает довольно легкую кастомизацию “папочек” под свои нужды. И вообще, фреймворк – это всего лишь деталь реализации.

Если кому-то проще сразу смотреть исходники, то оставлю еще раз тут ссылку на репозиторий - прямая ссылка на гитхаб

И так, приступим.

Разбиваем на фичи

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

Каждая фича будет иметь свой набор API и его реализовывать (не путать с REST). Это делается для того, что бы иметь возможность использовать так называемые “порты” для общения фич друг с другом и не быть привязанным к конкретной реализации. Теперь финальный список фич:

  • CategoryFeature - здесь будет работа с категориями

  • CategoryFeatureAPI - набор апи, открытый для сторонних модулей

  • PostFeature - здесь работа с постами

  • PostFeatureAPI - набор апи, открытый для сторонних модулей

  • DataManagerFeatureAPI - набор апи, позволяющий работать с DataStorage (это может быть база данных, текстовый файл и тд)

  • DoctrineDataFeature - реализовывает DataManagerFeatureAPI и работает с доктриной

  • FrontFeature - работа с фронтом приложения (шаблоны, контроллеры и тд). Не требует апи.

  • AdminFeature (не буду ее реализовывать в данном примере, будет домашним заданием)

Наглядная схема проекта:

Как это выглядит в самом проекте:

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

Работа с данными (DataManagerFeatureApi)

Я не хочу привязываться к доктрине, с которой работает симфони. Точнее, я хочу иметь возможность не только работы с ней, но и с другими механизамами работы с данными. Сегодня мы работаем с доктриной, а завтра переходим на csv файлы (да, кейс космический, но такое тоже может быть). Не переписывать же весь проект заново?

По этому нам нужен какой то набор интерфейсов, который будет описывать работу с нашими данными. Такой набор как правило выносится в API фичу. По этому мы создадим DataManagerFeatureApi, который будет описывать порты работы с данными.

И так, что будет внутри этой фичи:

  • DTORequest – описание входящих данных в модуль.

  • DTORequestFactory – описание фабрик

  • DTOResponse – описание респонс объектов для сторонних модулей / фич

  • Service – список “открытых” методов для сторонних модулей для манипуляции данными (прим, сохранение сущности).

Наглядный пример из проекта:

Описание интерфейсов прилагается:

CategoryDataRequestInterface - реквест объект для манипуляции с данными в DataStorage
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\DTORequest;

/**
 * @api
 * Interface CategoryDataRequestInterface
 * @package App\DataManagerFeatureApi\DTORequest
 *
 * Request object for a category creation
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 **/
interface CategoryDataRequestInterface
{
    /**
     * @return int|null
     */
    public function getId(): ?int;

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void;

    /**
     * @return string|null
     */
    public function getTitle(): ?string;

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void;

    /**
     * @return string|null
     */
    public function getContent(): ?string;

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void;

    /**
     * @return string|null
     */
    public function getSlug(): ?string;

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void;

    /**
     * @return bool
     */
    public function isActive(): bool;

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void;
}

PostDataRequestInterface - реквест объект для манипуляции с данными в DataStorage
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\DTORequest;

/**
 * @api
 * Interface PostDataRequestInterface
 * @package App\DataManagerFeatureApi\DTORequest
 *
 * Request object for a post creation
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface PostDataRequestInterface
{
    /**
     * @return int|null
     */
    public function getId(): ?int;

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void;

    /**
     * @return string|null
     */
    public function getTitle(): ?string;

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void;

    /**
     * @return string|null
     */
    public function getContent(): ?string;

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void;

    /**
     * @return string|null
     */
    public function getSlug(): ?string;

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void;

    /**
     * @return bool
     */
    public function isPublished(): bool;

    /**
     * @param bool $published
     */
    public function setPublished(bool $published): void;

    /**
     * @param int|null $id
     * @return void
     */
    public function setCategoryId(int $id = null): void;

    /**
     * @return int|null
     */
    public function getCategoryId(): ?int;
}

CategoryDataRequestFactoryInterface - просто фабрика для удобства создания объекта реквеста
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\DTORequestFactory;

use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;

/**
 * @api
 * Interface CategoryDataRequestFactoryInterface
 * @package App\DataManagerFeatureApi\DTORequestFactory
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 **/
interface CategoryDataRequestFactoryInterface
{
    /**
     * @return CategoryDataRequestInterface
     */
    public function create(): CategoryDataRequestInterface;
}

PostDataRequestFactoryInterface - просто фабрика для удобства создания объекта реквеста
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\DTORequestFactory;

use App\DataManagerFeatureApi\DTORequest\PostDataRequestInterface;

/**
 * @api
 * Interface PostDataRequestFactoryInterface
 * @package App\DataManagerFeatureApi\DTORequestFactory
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface PostDataRequestFactoryInterface
{
    /**
     * @return PostDataRequestInterface
     */
    public function create(): PostDataRequestInterface;
}

CategoryDataResponseInterface - респонс объект категорий, доступный другим фичам
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\DTOResponse;

/**
 * @api
 * Interface CategoryDataResponseInterface
 * @package App\DataManagerFeatureApi\DTOResponse
 *
 * Module response object. This object will be returned for other features usage.
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 **/
interface CategoryDataResponseInterface
{
    /**
     * @return int|null
     */
    public function getId(): ?int;

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void;

    /**
     * @return string|null
     */
    public function getTitle(): ?string;

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void;

    /**
     * @return string|null
     */
    public function getContent(): ?string;

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void;

    /**
     * @return string
     */
    public function getSlug(): string;

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void;

    /**
     * @return bool
     */
    public function isActive(): bool;

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void;
}

PostDataResponseInterface - респонс объект поста, доступный другим фичам
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\DTOResponse;

/**
 * @api
 * Interface PostDataResponseInterface
 * @package App\DataManagerFeatureApi\DTOResponse
 *
 * Module response object. This object will be returned for other features usage.
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface PostDataResponseInterface
{
    /**
     * @return int|null
     */
    public function getId(): ?int;

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void;

    /**
     * @return string|null
     */
    public function getTitle(): ?string;

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void;

    /**
     * @return string|null
     */
    public function getContent(): ?string;

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void;

    /**
     * @return string|null
     */
    public function getSlug(): ?string;

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void;

    /**
     * @return bool
     */
    public function isPublished(): bool;

    /**
     * @param bool $published
     */
    public function setPublished(bool $published): void;

    /**
     * @return string|null
     */
    public function getUpdatedAt(): ?string;

    /**
     * @param string|null $updatedAt
     * @return void
     */
    public function setUpdatedAt(?string $updatedAt = null): void;

    /**
     * @return string|null
     */
    public function getCreatedAt(): ?string;

    /**
     * @param string|null $createdAt
     * @return void
     */
    public function setCreatedAt(string $createdAt = null): void;

    /**
     * @param int|null $id
     * @return void
     */
    public function setCategoryId(int $id = null): void;

    /**
     * @return int|null
     */
    public function getCategoryId(): ?int;
}

CategoryDataServiceInterface - описание методов для работы с данными категорий
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\Service;

use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;
use App\DataManagerFeatureApi\DTOResponse\CategoryDataResponseInterface;

/**
 * @api
 * Interface CategoryDataServiceInterface
 * @package App\DataManagerFeatureApi\Service
 *
 * Api service for categories management
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 **/
interface CategoryDataServiceInterface
{
    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array;

    /**
     * @param int $categoryId
     * @return CategoryDataResponseInterface|null
     */
    public function getById(int $categoryId): ?CategoryDataResponseInterface;

    /**
     * @param CategoryDataRequestInterface $dtoRequest
     * @return CategoryDataResponseInterface
     */
    public function save(CategoryDataRequestInterface $dtoRequest): CategoryDataResponseInterface;

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void;

    /**
     * @param string $slug
     * @return CategoryDataResponseInterface|null
     */
    public function getBySlug(string $slug): ?CategoryDataResponseInterface;
}

PostDataServiceInterface - описание методов для работы с данными для постов
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DataManagerFeatureApi\Service;

use App\DataManagerFeatureApi\DTORequest\PostDataRequestInterface;
use App\DataManagerFeatureApi\DTOResponse\PostDataResponseInterface;

/**
 * @api
 * Interface PostDataServiceInterface
 * @package App\DataManagerFeatureApi\Service
 *
 * Api service for posts management
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface PostDataServiceInterface
{
    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array;

    /**
     * @param int $postId
     * @return PostDataResponseInterface|null
     */
    public function getById(int $postId): ?PostDataResponseInterface;

    /**
     * @param PostDataRequestInterface $dtoRequest
     * @return PostDataResponseInterface
     */
    public function save(PostDataRequestInterface $dtoRequest): PostDataResponseInterface;

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void;

    /**
     * @param string $slug
     * @return PostDataResponseInterface|null
     */
    public function getBySlug(string $slug): ?PostDataResponseInterface;
}

Теперь перейдем к непосредственной манипуляции с данными. Я буду работать через доктрину.

DoctrineDataFeature (implements DataManagerFeatureApi)

Вы можете создать EloquentFeature, как пример, если не хотите работать с доктриной. Вы можете иметь несколько DataManagerFeatures и переключаться между ними используя DI (dependecy injection). В этом и заключается прелесть чистой архитектуры с разбивкой по фичам.

Данная фича у нас реализует DataManagerFeatureApi. Прошу заметить, что реализация API модулей находится в Application слое каждой фичи. Именно данный слой является следующий после input/output.

Как я уже говорила, каждая фича имеет право не иметь все слои, описанные выше. В этой фиче идет работа с манипуляцией данных, нам не нужен здесь Presentation layer.

Список слоев:

  • Application – обработка “requested data” и возврат респонса. Классы с данного слоя могут быть использованны сторонними модулями используя API feature (в нашем случае DataManagerFeatureApi )

  • Domain – защищенные сущности от внешнего мира

  • Infrastructure – имплементация работы с доктриной

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

Давайте рассмотрим код в деталях.

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


Domain layer

Domain layer (Entity) - описание сущностей доктрины. Важно помнить, что все, что находится в домене ни в коем случае не отдается наружу! Тк эта фича у нас создана для работы с доктриной, то и сущности создаются с привязкой к ней.

Category
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Domain\Entity;

use App\DoctrineDataFeature\Infrastructure\Repository\CategoryRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

/**
 * Class Category
 * Doctrine entity determination
 *
 * @package App\DoctrineDataFeature\Infrastructure\Entity\Doctrine
 */
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $content = null;

    #[ORM\Column(length: 255, unique: true)]
    private ?string $slug = null;

    #[ORM\Column]
    private bool $isActive = false;

    /**
     * @param int|null $id
     */
    public function setId(?int $id): void
    {
        $this->id = $id;
    }

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string $title
     * @return $this
     */
    public function setTitle(string $title): self
    {
        $this->title = $title;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     * @return $this
     */
    public function setContent(?string $content): self
    {
        $this->content = $content;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string $slug
     * @return $this
     */
    public function setSlug(string $slug): self
    {
        $this->slug = $slug;
        return $this;
    }

    /**
     * @return bool
     */
    public function getIsActive(): bool
    {
        return $this->isActive;
    }

    /**
     * @param bool $active
     * @return $this
     */
    public function setActive(bool $active): self
    {
        $this->isActive = $active;
        return $this;
    }
}

Post
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Domain\Entity;

use App\DoctrineDataFeature\Infrastructure\Repository\PostRepository;
use DateTime;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

/**
 * Class Post
 * Doctrine entity determination
 *
 * @package App\DoctrineDataFeature\Domain\Entity
 */
#[ORM\Entity(repositoryClass: PostRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $content = null;

    #[ORM\Column(length: 255, unique: true)]
    private ?string $slug = null;

    #[ORM\Column]
    private bool $isPublished = false;

    #[ORM\Column(type: "datetime", nullable: true)]
    private ?DateTimeInterface $createdAt = null;

    #[ORM\Column(type: "datetime", nullable: true)]
    private ?DateTimeInterface $updatedAt = null;

    #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'posts')]
    private Category|null $category = null;

    /**
     * @param int|null $id
     */
    public function setId(?int $id): void
    {
        $this->id = $id;
    }

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string $title
     * @return $this
     */
    public function setTitle(string $title): self
    {
        $this->title = $title;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     * @return $this
     */
    public function setContent(?string $content): self
    {
        $this->content = $content;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string $slug
     * @return $this
     */
    public function setSlug(string $slug): self
    {
        $this->slug = $slug;
        return $this;
    }

    /**
     * @return bool
     */
    public function isPublished(): bool
    {
        return $this->isPublished;
    }

    /**
     * @param bool $published
     * @return $this
     */
    public function setPublished(bool $published): self
    {
        $this->isPublished = $published;
        return $this;
    }

    /**
     * @return Category|null
     */
    public function getCategory(): ?Category
    {
        return $this->category;
    }

    /**
     * @param Category|null $category
     * @return Post
     */
    public function setCategory(?Category $category): self
    {
        $this->category = $category;
        return $this;
    }

    /**
     * @return DateTimeInterface|null
     */
    public function getCreatedAt(): ?DateTimeInterface
    {
        return $this->createdAt;
    }

    /**
     * @param DateTimeInterface|null $timestamp
     * @return $this
     */
    public function setCreatedAt(?DateTimeInterface $timestamp): self
    {
        $this->createdAt = $timestamp;
        return $this;
    }

    /**
     * @return DateTimeInterface|null
     */
    public function getUpdatedAt(): ?DateTimeInterface
    {
        return $this->updatedAt;
    }

    /**
     * @param DateTimeInterface|null $timestamp
     * @return $this
     */
    public function setUpdatedAt(?DateTimeInterface $timestamp): self
    {
        $this->updatedAt = $timestamp;
        return $this;
    }

    #[ORM\PrePersist]
    #[ORM\PreUpdate]
    /**
     * @return void
     */
    protected function updateTimestamps(): void
    {
        $dateTimeNow = new DateTime('now');

        $this->setUpdatedAt($dateTimeNow);

        if ($this->getCreatedAt() === null) {
            $this->setCreatedAt($dateTimeNow);
        }
    }
}

Domain layer (Repository) - важно! в домене никогда не реализуются репозитории. За реализацию отвечате инфраструктурный слой.

CategoryRepositoryInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Domain\Repository;

use App\DoctrineDataFeature\Domain\Entity\Category;

/**
 * Interface CategoryRepositoryInterface
 * @package App\DoctrineDataFeature\Domain\Repository
 *
 * Domain layer NEVER implements repository. Domain doesn't know anything about data storages.
 * Domain uses behavior description, repository must be implemented in Infrastructure layer according to a source
 * (eg. DB, Session and so on)
 **/
interface CategoryRepositoryInterface
{
    /**
     * @param Category $category
     * @return Category
     */
    public function save(Category $category): Category;

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array;

    /**
     * @param int $id
     * @return object|null
     */
    public function getById(int $id): ?object;

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void;

    /**
     * @param object $category
     * @return void
     */
    public function delete(object $category): void;

    /**
     * @param string $slug
     * @return object|null
     */
    public function getBySlug(string $slug): ?object;
}

PostRepositoryInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Domain\Repository;

use App\DoctrineDataFeature\Domain\Entity\Post;

/**
 * Interface PostRepositoryInterface
 * @package App\DoctrineDataFeature\Domain\Repository
 *
 * Domain layer NEVER implements repository. Domain doesn't know anything about data storages.
 * Domain uses behavior description, repository must be implemented in Infrastructure layer according to a source
 * (eg. DB, Session and so on)
 */
interface PostRepositoryInterface
{
    /**
     * @param Post $post
     * @return Post
     */
    public function save(Post $post): Post;

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array;

    /**
     * @param int $id
     * @return object|null
     */
    public function getById(int $id): ?object;

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void;

    /**
     * @param object $post
     * @return void
     */
    public function delete(object $post): void;

    /**
     * @param string $slug
     * @return object|null
     */
    public function getBySlug(string $slug): ?object;
}

Давайте спустимся еще на уровень ниже и опишем инфрастуктурный слой.


Infrastructure layer

Infrastructure layer (Repository) - реализация репозиториев

CategoryRepository
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Infrastructure\Repository;

use App\DoctrineDataFeature\Domain\Entity\Category;
use App\DoctrineDataFeature\Domain\Repository\CategoryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Class CategoryRepository
 * Work directly with data storage
 *
 * @package App\DoctrineDataFeature\Infrastructure\Persistence\Doctrine
 *
 * @method Category|null find($id, $lockMode = null, $lockVersion = null)
 * @method Category|null findOneBy(array $criteria, array $orderBy = null)
 * @method Category[]    findAll()
 * @method Category[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 **/
class CategoryRepository extends ServiceEntityRepository implements CategoryRepositoryInterface
{
    /**
     * @param ManagerRegistry $registry
     */
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Category::class);
    }

    /**
     * @param Category $category
     * @return Category
     */
    public function save(Category $category): Category
    {
        if ($category->getId()) {
            $this->_em->merge($category);
        } else {
            $this->_em->persist($category);
        }

        $this->_em->flush();

        return $category;
    }

    /**
     * @return object[]
     */
    public function getList(array $criteria = null): array
    {
        if (!$criteria) {
            return $this->findAll();
        }

        return $this->findBy($criteria);
    }

    /**
     * @param int $id
     * @return Category|null
     */
    public function getById(int $id): ?Category
    {
        return $this->find($id);
    }

    /**
     * @param string $slug
     * @return object|null
     * @throws NonUniqueResultException
     */
    public function getBySlug(string $slug): ?object
    {
        $qb = $this->createQueryBuilder('q')
            ->where('q.slug = :slug')
            ->setParameter('slug', $slug);

        $query = $qb->getQuery();

        return $query->setMaxResults(1)->getOneOrNullResult();
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $category = $this->find($id);

        if (!$category) {
            throw new NotFoundHttpException(sprintf("The category with ID '%s' doesn't exist", $id));
        }

        $this->delete($category);
    }

    /**
     * @param object $category
     * @return void
     */
    public function delete(object $category): void
    {
        if (!$category instanceof Category) {
            throw new InvalidArgumentException(
                sprintf('You can only pass %s entity to this repository.', Category::class)
            );
        }

        $this->_em->remove($category);
        $this->_em->flush();
    }
}

PostRepository
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Infrastructure\Repository;

use App\DoctrineDataFeature\Domain\Entity\Post;
use App\DoctrineDataFeature\Domain\Repository\PostRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Class PostRepository
 * Work directly with data storage
 *
 * @package App\DoctrineDataFeature\Infrastructure\Repository
 *
 * @method Post|null find($id, $lockMode = null, $lockVersion = null)
 * @method Post|null findOneBy(array $criteria, array $orderBy = null)
 * @method Post[]    findAll()
 * @method Post[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class PostRepository extends ServiceEntityRepository implements PostRepositoryInterface
{
    /**
     * @param ManagerRegistry $registry
     */
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Post::class);
    }

    /**
     * @param Post $post
     * @return Post
     */
    public function save(Post $post): Post
    {
        if ($post->getId()) {
            $this->_em->merge($post);
        } else {
            $this->_em->persist($post);
        }

        $this->_em->flush();

        return $post;
    }

    /**
     * @return object[]
     */
    public function getList(array $criteria = null): array
    {
        if (!$criteria) {
            return $this->findAll();
        }

        return $this->findBy($criteria);
    }

    /**
     * @param int $id
     * @return Post|null
     */
    public function getById(int $id): ?Post
    {
        return $this->find($id);
    }

    /**
     * @param string $slug
     * @return object|null
     * @throws NonUniqueResultException
     */
    public function getBySlug(string $slug): ?object
    {
        $qb = $this->createQueryBuilder('q')
            ->where('q.slug = :slug')
            ->setParameter('slug', $slug);

        $query = $qb->getQuery();

        return $query->setMaxResults(1)->getOneOrNullResult();
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $post = $this->find($id);

        if (!$post) {
            throw new NotFoundHttpException(sprintf("The post with ID '%s' doesn't exist", $id));
        }

        $this->delete($post);
    }

    /**
     * @param object $post
     * @return void
     */
    public function delete(object $post): void
    {
        if (!$post instanceof Post) {
            throw new InvalidArgumentException(
                sprintf('You can only pass %s entity to this repository.', Post::class)
            );
        }

        $this->_em->remove($post);
        $this->_em->flush();
    }
}

После описания непосредственно домена и работы (в нашем случае) с базой данных, приступим к реализации взаимодействия модуля с реквест объектами.


Application layer

Application layer (ApiService) - описание сервисов, которые доступны для сторонних модулей (фич). Здесь на вход принимается ResponseDTO и возвращается RequestDTO

CategoryService
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\ApiService;

use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;
use App\DataManagerFeatureApi\DTOResponse\CategoryDataResponseInterface;
use App\DataManagerFeatureApi\Service\CategoryDataServiceInterface;
use App\DoctrineDataFeature\Application\DataMapper\DataMapperInterface;
use App\DoctrineDataFeature\Domain\Repository\CategoryRepositoryInterface;

/**
 * Class CategoryService
 * @package App\DoctrineDataFeature\Application\ApiService
 *
 * This class is for external usage (outside this feature) only
 * Use CategoryDataServiceInterface for data manipulating and return DTOResponse here
 *
 * Don't return Domain entity outside the feature!
 **/
class CategoryService implements CategoryDataServiceInterface
{
    private DataMapperInterface $dataMapper;
    private CategoryRepositoryInterface $categoryRepository;

    /**
     * Use DI for injecting appropriate objects here
     * @param DataMapperInterface $dataMapper
     * @param CategoryRepositoryInterface $categoryRepository
     */
    public function __construct(
        DataMapperInterface $dataMapper,
        CategoryRepositoryInterface $categoryRepository
    ) {
        $this->dataMapper = $dataMapper;
        $this->categoryRepository = $categoryRepository;
    }

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array
    {
        $list = [];
        $result = $this->categoryRepository->getList($criteria);

        foreach ($result as $item) {
            $list[] = $this->dataMapper->toResponse($item);
        }

        return $list;
    }

    /**
     * @param int $categoryId
     * @return CategoryDataResponseInterface|null
     */
    public function getById(int $categoryId): ?CategoryDataResponseInterface
    {
        $entity = $this->categoryRepository->getById($categoryId);
        return $entity ? $this->dataMapper->toResponse($entity) : null;
    }

    /**
     * @param CategoryDataRequestInterface $dtoRequest
     * @return CategoryDataResponseInterface
     */
    public function save(CategoryDataRequestInterface $dtoRequest): CategoryDataResponseInterface
    {
        $entity = $this->dataMapper->toEntity($dtoRequest);
        $this->categoryRepository->save($entity);

        return $this->dataMapper->toResponse($entity);
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $this->categoryRepository->deleteById($id);
    }

    /**
     * @param string $slug
     * @return CategoryDataResponseInterface|null
     */
    public function getBySlug(string $slug): ?CategoryDataResponseInterface
    {
        $entity = $this->categoryRepository->getBySlug($slug);
        return $entity ? $this->dataMapper->toResponse($entity) : null;
    }
}

PostService
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\ApiService;

use App\DataManagerFeatureApi\DTORequest\PostDataRequestInterface;
use App\DataManagerFeatureApi\DTOResponse\PostDataResponseInterface;
use App\DataManagerFeatureApi\Service\PostDataServiceInterface;
use App\DoctrineDataFeature\Application\DataMapper\DataMapperInterface;
use App\DoctrineDataFeature\Domain\Repository\PostRepositoryInterface;

/**
 * Class PostService
 * @package App\DoctrineDataFeature\Application\ApiService
 *
 * This class is for external usage (outside this feature) only
 * Use CategoryDataServiceInterface for data manipulating and return DTOResponse here
 *
 * Don't return Domain entity outside the feature!
 */
class PostService implements PostDataServiceInterface
{
    private DataMapperInterface $dataMapper;
    private PostRepositoryInterface $postRepository;

    /**
     * @param DataMapperInterface $dataMapper
     * @param PostRepositoryInterface $postRepository
     */
    public function __construct(
        DataMapperInterface $dataMapper,
        PostRepositoryInterface $postRepository
    ) {
        $this->dataMapper = $dataMapper;
        $this->postRepository = $postRepository;
    }

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array
    {
        $list = [];
        $result = $this->postRepository->getList($criteria);

        foreach ($result as $item) {
            $list[] = $this->dataMapper->toResponse($item);
        }

        return $list;
    }

    /**
     * @param int $postId
     * @return PostDataResponseInterface|null
     */
    public function getById(int $postId): ?PostDataResponseInterface
    {
        $entity = $this->postRepository->getById($postId);
        return $entity ? $this->dataMapper->toResponse($entity) : null;
    }

    /**
     * @param PostDataRequestInterface $dtoRequest
     * @return PostDataResponseInterface
     */
    public function save(PostDataRequestInterface $dtoRequest): PostDataResponseInterface
    {
        $entity = $this->dataMapper->toEntity($dtoRequest);
        $this->postRepository->save($entity);

        return $this->dataMapper->toResponse($entity);
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $this->postRepository->deleteById($id);
    }

    /**
     * @param string $slug
     * @return PostDataResponseInterface|null
     */
    public function getBySlug(string $slug): ?PostDataResponseInterface
    {
        $entity = $this->postRepository->getBySlug($slug);
        return $entity ? $this->dataMapper->toResponse($entity) : null;
    }
}

Application layer (DataMapper) - здесь маппинг объектов в респонс или домен

DataMapperInterface - только для внутреннего использования
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DataMapper;

use App\DoctrineDataFeature\Application\DTORequest\DataRequestInterface;
use App\DoctrineDataFeature\Application\DTOResponse\DataResponseInterface;

/**
 * Interface DataMapperInterface
 * This is common interface for data mappers.
 *
 * @package App\DoctrineDataFeature\Application\EntityMapper
 **/
interface DataMapperInterface
{
    /**
     * Map DTORequest object to Infrastructure entity
     * @param DataRequestInterface $request
     * @return object
     */
    public function toEntity(DataRequestInterface $request): object;

    /**
     * Map entity to DTOResponse
     * @param object $entity
     * @return DataResponseInterface
     */
    public function toResponse(object $entity): DataResponseInterface;
}

CategoryMapper
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DataMapper;

use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;
use App\DoctrineDataFeature\Application\DTORequest\DataRequestInterface;
use App\DoctrineDataFeature\Application\DTOResponse\DataResponseInterface;
use App\DoctrineDataFeature\Application\DTOResponseFactory\CategoryResponseFactoryInterface;
use App\DoctrineDataFeature\Domain\Entity\Category;
use InvalidArgumentException;

/**
 * Class CategoryMapper
 * @package App\DoctrineDataFeature\Application\DTOEntityMapper
 **/
class CategoryMapper implements DataMapperInterface
{
    private CategoryResponseFactoryInterface $categoryResponseFactory;

    /**
     * @param CategoryResponseFactoryInterface $categoryResponseFactory
     */
    public function __construct(CategoryResponseFactoryInterface $categoryResponseFactory)
    {
        $this->categoryResponseFactory = $categoryResponseFactory;
    }

    /**
     * Map DTORequest object to Infrastructure entity
     * @param DataRequestInterface $request
     * @return Category
     */
    public function toEntity(DataRequestInterface $request): Category
    {
        if (!$request instanceof CategoryDataRequestInterface) {
            throw new InvalidArgumentException(
                sprintf('You can pass %s only to this mapper.', CategoryDataRequestInterface::class)
            );
        }

        $doctrineEntity = new Category();

        $doctrineEntity->setId($request->getId());
        $doctrineEntity->setActive($request->isActive());
        $doctrineEntity->setContent($request->getContent());
        $doctrineEntity->setSlug($request->getSlug());
        $doctrineEntity->setTitle($request->getTitle());

        return $doctrineEntity;
    }

    /**
     * Map entity to DTOResponse
     * @param object $entity
     * @return DataResponseInterface
     */
    public function toResponse(object $entity): DataResponseInterface
    {
        if (!$entity instanceof Category) {
            throw new InvalidArgumentException(
                sprintf('You can only pass %s entity to this mapper.', Category::class)
            );
        }

        $response = $this->categoryResponseFactory->create();

        $response->setId($entity->getId());
        $response->setActive($entity->getIsActive());
        $response->setContent($entity->getContent());
        $response->setSlug($entity->getSlug());
        $response->setTitle($entity->getTitle());

        return $response;
    }
}

PostMapper
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DataMapper;

use App\DataManagerFeatureApi\DTORequest\PostDataRequestInterface;
use App\DoctrineDataFeature\Application\DTORequest\DataRequestInterface;
use App\DoctrineDataFeature\Application\DTOResponse\DataResponseInterface;
use App\DoctrineDataFeature\Application\DTOResponseFactory\PostResponseFactoryInterface;
use App\DoctrineDataFeature\Domain\Entity\Category;
use App\DoctrineDataFeature\Domain\Entity\Post;
use App\DoctrineDataFeature\Domain\Repository\CategoryRepositoryInterface;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Class PostMapper
 * @package App\DoctrineDataFeature\Application\DataMapper
 */
class PostMapper implements DataMapperInterface
{
    private PostResponseFactoryInterface $postResponseFactory;
    private CategoryRepositoryInterface $categoryRepository;

    /**
     * @param PostResponseFactoryInterface $postResponseFactory
     * @param CategoryRepositoryInterface $categoryRepository
     */
    public function __construct(
        PostResponseFactoryInterface $postResponseFactory,
        CategoryRepositoryInterface $categoryRepository
    ) {
        $this->postResponseFactory = $postResponseFactory;
        $this->categoryRepository = $categoryRepository;
    }

    /**
     * Map DTORequest object to Infrastructure entity
     * @param DataRequestInterface $request
     * @return object
     */
    public function toEntity(DataRequestInterface $request): object
    {
        if (!$request instanceof PostDataRequestInterface) {
            throw new InvalidArgumentException(
                sprintf('You can pass %s only to this mapper.', PostDataRequestInterface::class)
            );
        }

        $doctrineEntity = new Post();

        $doctrineEntity->setId($request->getId());
        $doctrineEntity->setPublished($request->isPublished());
        $doctrineEntity->setContent($request->getContent());
        $doctrineEntity->setSlug($request->getSlug());
        $doctrineEntity->setTitle($request->getTitle());

        if ($categoryId = $request->getCategoryId()) {
            $category = $this->getCategory($categoryId);
            $doctrineEntity->setCategory($category);
        }

        return $doctrineEntity;
    }

    /**
     * @param int $categoryId
     * @return Category
     */
    private function getCategory(int $categoryId): Category
    {
        $category = $this->categoryRepository->getById($categoryId);

        if (!$category) {
            throw new NotFoundHttpException(sprintf("The category with ID '%s' doesn't exist", $category));
        }

        return $category;
    }

    /**
     * Map entity to DTOResponse
     * @param object $entity
     * @return DataResponseInterface
     */
    public function toResponse(object $entity): DataResponseInterface
    {
        if (!$entity instanceof Post) {
            throw new InvalidArgumentException(
                sprintf('You can only pass %s entity to this mapper.', Post::class)
            );
        }

        $response = $this->postResponseFactory->create();

        $response->setId($entity->getId());
        $response->setPublished($entity->isPublished());
        $response->setContent($entity->getContent());
        $response->setSlug($entity->getSlug());
        $response->setTitle($entity->getTitle());
        $response->setCategoryId($entity->getCategory()?->getId());

        return $response;
    }
}

Application layer (DTORequest) - реализация реквест объектов

DataRequestInterface - только для внутреннего использования
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTORequest;

/**
 * Interface DataRequestInterface
 * Marker for DTO request objects
 * @package App\DoctrineDataFeature\Application\DTORequest
 **/
interface DataRequestInterface
{

}

CategoryRequest
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTORequest;

use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;

/**
 * Class CategoryUpdate
 * Request object for a category creation
 * @package App\DoctrineDataFeature\Application\DTORequest
 **/
class CategoryRequest implements CategoryDataRequestInterface, DataRequestInterface
{
    private ?int $id = null;
    private ?string $title = null;
    private ?string $content = null;
    private ?string $slug = null;
    private bool $isActive = false;

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void
    {
        $this->id = $id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void
    {
        $this->content = $content;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void
    {
        $this->slug = $slug;
    }

    /**
     * @return bool
     */
    public function isActive(): bool
    {
        return $this->isActive;
    }

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void
    {
        $this->isActive = $active;
    }
}

PostRequest
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTORequest;

use App\DataManagerFeatureApi\DTORequest\PostDataRequestInterface;

/**
 * Class PostRequest
 * Request object for a post creation
 * @package App\DoctrineDataFeature\Application\DTORequest
 */
class PostRequest implements PostDataRequestInterface, DataRequestInterface
{
    private ?int $id = null;
    private ?string $title = null;
    private ?string $content = null;
    private ?string $slug = null;
    private bool $isPublished = false;
    private ?int $categoryId = null;

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void
    {
        $this->id = $id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void
    {
        $this->content = $content;
    }

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

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void
    {
        $this->slug = $slug;
    }

    /**
     * @return bool
     */
    public function isPublished(): bool
    {
        return $this->isPublished;
    }

    /**
     * @param bool $published
     */
    public function setPublished(bool $published): void
    {
        $this->isPublished = $published;
    }

    /**
     * @param int|null $id
     * @return void
     */
    public function setCategoryId(int $id = null): void
    {
       $this->categoryId = $id;
    }

    /**
     * @return int|null
     */
    public function getCategoryId(): ?int
    {
       return $this->categoryId;
    }
}

Application layer (DTORequestFactory) - реализация фабрик

CategoryRequestFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTORequestFactory;

use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;
use App\DataManagerFeatureApi\DTORequestFactory\CategoryDataRequestFactoryInterface;
use App\DoctrineDataFeature\Application\DTORequest\CategoryRequest;

/**
 * Class CategoryCreateFactory
 * @package App\DoctrineDataFeature\Application\DTORequestFactory
 **/
class CategoryRequestFactory implements CategoryDataRequestFactoryInterface
{
    /**
     * @return CategoryDataRequestInterface
     */
    public function create(): CategoryDataRequestInterface
    {
        return new CategoryRequest();
    }
}

PostRequestFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTORequestFactory;

use App\DataManagerFeatureApi\DTORequest\PostDataRequestInterface;
use App\DataManagerFeatureApi\DTORequestFactory\PostDataRequestFactoryInterface;
use App\DoctrineDataFeature\Application\DTORequest\PostRequest;

/**
 * Class PostRequestFactory
 * @package App\DoctrineDataFeature\Application\DTORequestFactory
 */
class PostRequestFactory implements PostDataRequestFactoryInterface
{
    /**
     * @return PostDataRequestInterface
     */
    public function create(): PostDataRequestInterface
    {
        return new PostRequest();
    }
}

Application layer (DTOResponse) - реализация респонс объектов

DataResponseInterface - только для внутреннего использования
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTOResponse;

/**
 * Interface DataResponseInterface
 * Marker for DTO request objects
 * @package App\DoctrineDataFeature\Application\DTOResponse
 **/
interface DataResponseInterface
{

}

CategoryResponse

<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTOResponse;

use App\DataManagerFeatureApi\DTOResponse\CategoryDataResponseInterface;

/**
 * Class Category
 * Module response object. This object will be returned for other features usage.
 * @package App\DoctrineDataFeature\Application\DTOResponse
 **/
class CategoryResponse implements CategoryDataResponseInterface, DataResponseInterface
{
    private ?int $id = null;
    private ?string $title = null;
    private ?string $content = null;
    private ?string $slug = null;
    private bool $isActive = false;

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void
    {
        $this->id = $id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void
    {
        $this->content = $content;
    }

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

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void
    {
        $this->slug = $slug;
    }

    /**
     * @return bool
     */
    public function isActive(): bool
    {
        return $this->isActive;
    }

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void
    {
        $this->isActive = $active;
    }
}

PostResponse
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTOResponse;

use App\DataManagerFeatureApi\DTOResponse\PostDataResponseInterface;
use DateTimeInterface;

/**
 * Class PostResponse
 * Module response object. This object will be returned for other features usage.
 * @package App\DoctrineDataFeature\Application\DTOResponse
 */
class PostResponse implements PostDataResponseInterface, DataResponseInterface
{
    private ?int $id = null;
    private ?string $title = null;
    private ?string $content = null;
    private ?string $slug = null;
    private bool $isPublished = false;
    private ?int $categoryId = null;
    private ?string $createdAt = null;
    private ?string $updatedAt = null;

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void
    {
        $this->id = $id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void
    {
        $this->content = $content;
    }

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

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void
    {
        $this->slug = $slug;
    }

    /**
     * @return bool
     */
    public function isPublished(): bool
    {
        return $this->isPublished;
    }

    /**
     * @param bool $published
     */
    public function setPublished(bool $published): void
    {
        $this->isPublished = $published;
    }

    /**
     * @return string|null
     */
    public function getUpdatedAt(): ?string
    {
        return date("Y-m-d", $this->updatedAt);
    }

    /**
     * @param string|null $updatedAt
     * @return void
     */
    public function setUpdatedAt(string $updatedAt = null): void
    {
        $this->updatedAt = $updatedAt;
    }

    /**
     * @return string
     */
    public function getCreatedAt(): string
    {
        return date("Y-m-d", $this->createdAt);
    }

    /**
     * @param string|null $createdAt
     * @return void
     */
    public function setCreatedAt(string $createdAt = null): void
    {
        $this->createdAt = $createdAt;
    }

    /**
     * @param int|null $id
     * @return void
     */
    public function setCategoryId(int $id = null): void
    {
        $this->categoryId = $id;
    }

    /**
     * @return int|null
     */
    public function getCategoryId(): ?int
    {
        return $this->categoryId;
    }
}

Application layer (DTOResponseFactory) - реализация фабрик

CategoryResponseFactoryInterface - интерфейс только для использования внутри фичи
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTOResponseFactory;

use App\DataManagerFeatureApi\DTOResponse\CategoryDataResponseInterface;

/**
 * Interface CategoryResponseFactoryInterface
 * @package App\DoctrineDataFeature\Application\DTOResponseFactory
 **/
interface CategoryResponseFactoryInterface
{
    /**
     * @return CategoryDataResponseInterface
     */
    public function create(): CategoryDataResponseInterface;
}

CategoryResponseFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTOResponseFactory;

use App\DataManagerFeatureApi\DTOResponse\CategoryDataResponseInterface;
use App\DoctrineDataFeature\Application\DTOResponse\CategoryResponse;

/**
 * Class CategoryResponseFactory
 * @package App\DoctrineDataFeature\Application\DTOResponseFactory
 **/
class CategoryResponseFactory implements CategoryResponseFactoryInterface
{
    /**
     * @return CategoryDataResponseInterface
     */
    public function create(): CategoryDataResponseInterface
    {
        return new CategoryResponse();
    }
}

PostResponseFactoryInterface - интерфейс только для использования внутри фичи
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTOResponseFactory;

use App\DataManagerFeatureApi\DTOResponse\PostDataResponseInterface;

/**
 * Interface PostResponseFactoryInterface
 * @package App\DoctrineDataFeature\Application\DTOResponseFactory
 */
interface PostResponseFactoryInterface
{
    /**
     * @return PostDataResponseInterface
     */
    public function create(): PostDataResponseInterface;
}

PostResponseFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\DoctrineDataFeature\Application\DTOResponseFactory;

use App\DataManagerFeatureApi\DTOResponse\PostDataResponseInterface;
use App\DoctrineDataFeature\Application\DTOResponse\PostResponse;

/**
 * Class PostResponseFactory
 * @package App\DoctrineDataFeature\Application\DTOResponseFactory
 */
class PostResponseFactory implements PostResponseFactoryInterface
{
    /**
     * @return PostDataResponseInterface
     */
    public function create(): PostDataResponseInterface
    {
        return new PostResponse();
    }
}

Т.к. симфони имеет свои привязки к папкам, а мы уже создали DoctrineEntity в нестандартном фолдере, необходимо здесь config/packages/doctrine.yaml в "orm" прописать следующее:

    orm:
        auto_generate_proxy_classes: true
        enable_lazy_ghost_objects: true
        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
        auto_mapping: true
        mappings:
            doctrine_data_manager_feature:
                is_bundle: false
                type: attribute
                dir: '%kernel.project_dir%/src/DoctrineDataFeature/Domain/Entity'
                prefix: 'App\DoctrineDataFeature\Domain\Entity\'
                alias: doctrine_data_manager_feature

И так, у нас уже есть модуль, который умеет работать с данными. Наверняка, вы зададите вопрос "А зачем нам тогда нужны отдельный фичи для категорий и постов, когда мы и так уже сможем создать/изменить/удалить категорию и пост?".

Ответ прост - DoctirneManagerFeature (а так же все, что реализует DataManagerFeatureApi) представляет из себя простое хранилище данных, которое умеет просто сохранять и доставать данные. Здесь нет бизнес логики, нет первичной валидации и тд. Задача этого модуля - это работа с дата сторадж. Почему нельзя сюда добавить бизнес логику? Потому что, во-первых, мы привяжемся к доктрине, во-вторых, если мы захотим поменять дата сторадж, тогда нам придется дублировать бизнес логику в фичу, которая работает с другим дата сторадж. При этом, если какой-то процесс в бизнес логике поменяется, то придется изменять код во всех фичах. Наш подход позволяет быть более изолированным и гибким.

CategoryFeatureApi и CategoryFeature

Api для модулей будут схожими между собой по структуре и значению. Выше я описывала более детально DataManagerFeatureApi, здесь я просто приведу код для CategoryFeatureApi с небольшими комментариями

CategoryCreateRequestInterface - реквест объект интерфейс для создания категории
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeatureApi\DTORequest;

/**
 * @api
 * Interface CategoryCreateRequestInterface
 * @package App\CategoryFeatureApi\DTORequest
 *
 * Request object for a category creation
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface CategoryCreateRequestInterface
{
    /**
     * @return string|null
     */
    public function getTitle(): ?string;

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void;

    /**
     * @return string|null
     */
    public function getContent(): ?string;

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void;

    /**
     * @return string|null
     */
    public function getSlug(): ?string;

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void;

    /**
     * @return bool
     */
    public function isActive(): bool;

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void;
}

CategoryUpdateRequestInterface - реквест объект интерфейс для обновления категории
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeatureApi\DTORequest;

/**
 * @api
 * Interface CategoryUpdateRequestInterface
 * @package App\CategoryFeatureApi\DTORequest
 *
 * Request object for a category updates
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface CategoryUpdateRequestInterface
{
    /**
     * @return int|null
     */
    public function getId(): ?int;

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void;

    /**
     * @return string|null
     */
    public function getTitle(): ?string;

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void;

    /**
     * @return string|null
     */
    public function getContent(): ?string;

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void;

    /**
     * @return string|null
     */
    public function getSlug(): ?string;

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void;

    /**
     * @return bool
     */
    public function isActive(): bool;

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void;
}

CategoryCreateDTOFactoryInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeatureApi\DTORequestFactory;

use App\CategoryFeatureApi\DTORequest\CategoryCreateRequestInterface;

/**
 * @api
 * Interface CategoryCreateDTOFactoryInterface
 * @package App\CategoryFeatureApi\DTORequestFactory
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface CategoryCreateDTOFactoryInterface
{
    /**
     * @return CategoryCreateRequestInterface
     */
    public function create(): CategoryCreateRequestInterface;
}

CategoryUpdateDTOFactoryInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeatureApi\DTORequestFactory;

use App\CategoryFeatureApi\DTORequest\CategoryUpdateRequestInterface;

/**
 * @api
 * Interface CategoryUpdateDTOFactoryInterface
 * @package App\CategoryFeatureApi\DTORequestFactory
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface CategoryUpdateDTOFactoryInterface
{
    /**
     * @return CategoryUpdateRequestInterface
     */
    public function create(): CategoryUpdateRequestInterface;
}

CategoryDTOInterface - респонс объект будет всегда один, не зависимо от типа реквеста
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeatureApi\DTOResponse;

/**
 * @api
 * Interface CategoryDTOInterface
 * @package App\CategoryFeatureApi\DTOResponse
 *
 * Module response object. This object will be returned for other features usage.
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 **/
interface CategoryDTOInterface
{
    /**
     * @return int|null
     */
    public function getId(): ?int;

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void;

    /**
     * @return string|null
     */
    public function getTitle(): ?string;

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void;

    /**
     * @return string|null
     */
    public function getContent(): ?string;

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void;

    /**
     * @return string
     */
    public function getSlug(): string;

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void;

    /**
     * @return bool
     */
    public function isActive(): bool;

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void;
}

CategoryServiceInterface - набор открытых манипуляций с категорией
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeatureApi\Service;

use App\CategoryFeatureApi\DTORequest\CategoryUpdateRequestInterface as UpdateDTORequest;
use App\CategoryFeatureApi\DTORequest\CategoryCreateRequestInterface as CreateDTORequest;
use App\CategoryFeatureApi\DTOResponse\CategoryDTOInterface as ResponseDTO;

/**
 * @api
 * Interface CategoryServiceInterface
 * @package App\CategoryFeatureApi\Service
 *
 * Api service for categories management
 *
 * WARNING! API DOESN'T know anything about realization!
 * This interfaces provides public access for other features to a feature realization.
 */
interface CategoryServiceInterface
{
    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array;

    /**
     * @param int $categoryId
     * @return ResponseDTO|null
     */
    public function getById(int $categoryId): ?ResponseDTO;

    /**
     * @param string $slug
     * @return ResponseDTO|null
     */
    public function getBySlug(string $slug): ?ResponseDTO;

    /**
     * @return ResponseDTO
     */
    public function initNewCategory(): ResponseDTO;

    /**
     * @param CreateDTORequest $dtoRequest
     * @return ResponseDTO
     */
    public function create(CreateDTORequest $dtoRequest): ResponseDTO;

    /**
     * @param UpdateDTORequest $dtoRequest
     * @return ResponseDTO
     */
    public function update(UpdateDTORequest $dtoRequest): ResponseDTO;

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void;
}

В проекте это будет выглядеть также, как и для дата фичи

Реализация CategoryFeature

Структура будет схожа, но здесь уже будут добавлены новые объекты. Обо всем ниже.

Так же начнем работу с доменного слоя. Сначала мы выделяем бизнес слой, а потом уже определяем взаимодействие с ним.


Domain layer

CategoryFeature (Domain layer)

В доменном слое у нас появляются Интеракторы (Interactor) и Value object. По поводу интеракторов было написано выше, они объединяют в группу usecase (т.е. набор бизнес логики). Наши DomainEntity буду использовать immutable value objects. Делается это для того, чтобы доменную модель никто не смог изменить. Подробнее о ValueObject можно почитать в интернете. А мы продолжаем:

Domain layer (Entity)

Category - domain entity. обратите внимание на использование ValueObjects
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\Entity;

use App\CategoryFeature\Domain\ValueObject\ContentValue;
use App\CategoryFeature\Domain\ValueObject\IdValue;
use App\CategoryFeature\Domain\ValueObject\SlugValue;
use App\CategoryFeature\Domain\ValueObject\ActiveValue;
use App\CategoryFeature\Domain\ValueObject\TitleValue;

/**
 * Class Category
 * @package App\CategoryFeature\Domain\Entity
 *
 * Domain Entity doesn't connect to any DB or other storages.
 * Represents an element of business logic.
 * Domain entities must not be revealed outside
 *
 * Use immutable value objects here
 */
class Category
{
    private IdValue $id;
    private TitleValue $title;
    private ContentValue $content;
    private SlugValue $slug;
    private ActiveValue $isActive;

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

    /**
     * @param IdValue $id
     */
    public function setId(IdValue $id): void
    {
        $this->id = $id;
    }

    /**
     * @return TitleValue
     */
    public function getTitle(): TitleValue
    {
        return $this->title;
    }

    /**
     * @param TitleValue $title
     */
    public function setTitle(TitleValue $title): void
    {
        $this->title = $title;
    }

    /**
     * @return ContentValue
     */
    public function getContent(): ContentValue
    {
        return $this->content;
    }

    /**
     * @param ContentValue $content
     */
    public function setContent(ContentValue $content): void
    {
        $this->content = $content;
    }

    /**
     * @return SlugValue
     */
    public function getSlug(): SlugValue
    {
        return $this->slug;
    }

    /**
     * @param SlugValue $slug
     */
    public function setSlug(SlugValue $slug): void
    {
        $this->slug = $slug;
    }

    /**
     * @return ActiveValue
     */
    public function isActive(): ActiveValue
    {
        return $this->isActive;
    }

    /**
     * @param ActiveValue $active
     */
    public function setActive(ActiveValue $active): void
    {
        $this->isActive = $active;
    }
}

Domain layer (Factory)

CategoryFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\Factory;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\ValueObject\ContentValue;
use App\CategoryFeature\Domain\ValueObject\IdValue;
use App\CategoryFeature\Domain\ValueObject\SlugValue;
use App\CategoryFeature\Domain\ValueObject\ActiveValue;
use App\CategoryFeature\Domain\ValueObject\TitleValue;

/**
 * Class CategoryFactory
 * @package App\CategoryFeature\Domain\Factory
 */
class CategoryFactory
{
    /**
     * @param int|null $id
     * @param string|null $title
     * @param string|null $content
     * @param string|null $slug
     * @param bool $isActive
     * @return Category
     */
    public function create(
        int    $id = null,
        string $title = null,
        string $content = null,
        string $slug = null,
        bool   $isActive = false,
    ): Category
    {
        $category = new Category();

        $idValue = new IdValue($id);
        $category->setId($idValue);

        $titleValue = new TitleValue($title);
        $category->setTitle($titleValue);

        $contentValue = new ContentValue($content);
        $category->setContent($contentValue);

        $slugValue = new SlugValue($slug);
        $category->setSlug($slugValue);

        $statusValue = new ActiveValue($isActive);
        $category->setActive($statusValue);

        return $category;
    }
}

Domain layer (Repository)

CategoryRepositoryInterface - не забываем, что в домене нет реализации репозитория, только интерфейс
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\Repository;

use App\CategoryFeature\Domain\Entity\Category;

/**
 * Interface CategoryRepositoryInterface
 * @package App\CategoryFeature\Domain\Repository
 *
 * Domain layer NEVER implements repository. Domain doesn't know anything about data storages.
 * Domain uses behavior description, repository must be implemented in Infrastructure layer according to a source
 * (eg. DB, Session and so on)
 */
interface CategoryRepositoryInterface
{
    /**
     * @param Category $category
     * @return Category
     */
    public function save(Category $category): Category;

    /**
     * @param int $id
     */
    public function deleteById(int $id): void;

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array;

    /**
     * @param int $id
     * @return Category|null
     */
    public function getById(int $id): ?Category;

    /**
     * @param string $slug
     * @return Category|null
     */
    public function getBySlug(string $slug): ?Category;
}

Domain layer (Interactor) - я выделила три интерактора, которые объединяют в себе общие задачи. Интерактор удаления, сохранения (куда входит update and create методы), интерактор загрузки (loadById и тд). Т.к. бизнес логика не должна изменяться извне, не забываем интеракторы объявлять как final

CategoryDeleteInteractor
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\Interactor;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\Repository\CategoryRepositoryInterface;
use DomainException;

/**
 * Class CategoryDeleteInteractor
 * @package App\CategoryFeature\Domain\Interactor
 *
 * Interactor represents a union of use cases. 1 use case = 1 business logic action.
 * e.g. deleteById() method represents delete category by id use case.
 * Interactor holds use cases for the sake of decreasing complexity of the code and decreasing dependencies for classes
 * which will need several use cases.
 *
 * WARNING! Interactors must not be changed or inherited.
 * Business logic can't be changed by 3-d party modules and layers
 */
final class CategoryDeleteInteractor
{
    private CategoryRepositoryInterface $categoryRepository;

    /**
     * @param CategoryRepositoryInterface $categoryRepository
     */
    public function __construct(CategoryRepositoryInterface $categoryRepository)
    {
        $this->categoryRepository = $categoryRepository;
    }

    /**
     * @param int $id
     * @return void
     * @throws DomainException
     */
    public function deleteById(int $id): void
    {
        $category = $this->categoryRepository->getById($id);

        if (null == $category) {
            throw new DomainException(sprintf("Category with id %s does not exist", $id));
        }

        $this->categoryRepository->deleteById($id);
    }

    /**
     * @param Category $category
     * @return void
     * @throws DomainException
     */
    public function delete(Category $category): void
    {
        $categoryId = $category->getId()->getValue();

        if (null == $categoryId) {
            throw new DomainException("Can't remove category without Id");
        }

        $this->categoryRepository->deleteById($categoryId);
    }
}

CategoryLoadInteractor
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\Interactor;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\Factory\CategoryFactory;
use App\CategoryFeature\Domain\Repository\CategoryRepositoryInterface;

/**
 * Class CategoryLoadInteractor
 * @package App\CategoryFeature\Domain\Interactor
 *
 * Interactor represents a union of use cases. 1 use case = 1 business logic action.
 * e.g. loadById() method represents load category by id use case.
 * Interactor holds use cases for the sake of decreasing complexity of the code and decreasing dependencies for classes
 * which will need several use cases.
 *
 * WARNING! Interactors must not be changed or inherited.
 * Business logic can't be changed by 3-d party modules and layers
 */
final class CategoryLoadInteractor
{
    private CategoryRepositoryInterface $categoryRepository;
    private CategoryFactory $categoryFactory;

    /**
     * @param CategoryRepositoryInterface $categoryRepository
     * @param CategoryFactory $categoryFactory
     */
    public function __construct(
        CategoryRepositoryInterface $categoryRepository,
        CategoryFactory $categoryFactory
    ) {
        $this->categoryRepository = $categoryRepository;
        $this->categoryFactory = $categoryFactory;
    }

    /**
     * @param array|null $criteria
     * @return array
     */
    public function loadAll(array $criteria = null): array
    {
        return $this->categoryRepository->getList($criteria);
    }

    /**
     * @param int $id
     * @return Category|null
     */
    public function loadById(int $id): ?Category
    {
        return $this->categoryRepository->getById($id);
    }

    /**
     * @return Category
     */
    public function loadEmptyCategory(): Category
    {
        return $this->categoryFactory->create();
    }

    /**
     * @param string $slug
     * @return Category|null
     */
    public function loadBySlug(string $slug): ?Category
    {
        return $this->categoryRepository->getBySlug($slug);
    }
}

CategorySaveInteractor
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\Interactor;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\Repository\CategoryRepositoryInterface;
use DomainException;

/**
 * Class CategorySaveInteractor
 * @package App\CategoryFeature\Domain\Interactor
 *
 * Interactor represents a union of use cases. 1 use case = 1 business logic action.
 * e.g. update() method represents update category use case.
 * Interactor holds use cases for the sake of decreasing complexity of the code and decreasing dependencies for classes
 * which will need several use cases.
 *
 * WARNING! Interactors must not be changed or inherited.
 * Business logic can't be changed by 3-d party modules and layers
 */
final class CategorySaveInteractor
{
    private CategoryRepositoryInterface $categoryRepository;

    /**
     * @param CategoryRepositoryInterface $categoryRepository
     */
    public function __construct(CategoryRepositoryInterface $categoryRepository)
    {
        $this->categoryRepository = $categoryRepository;
    }

    /**
     * @param Category $category
     * @return Category
     */
    public function update(Category $category): Category
    {
        if (!$category->getSlug()->getValue()) {
            throw new DomainException("Category must have a slug");
        }

        $categoryId = $category->getId()->getValue();
        $existedCategory = $this->categoryRepository->getById($categoryId);

        if (null == $existedCategory) {
            throw new DomainException(sprintf("Category with id %s does not exist", $categoryId));
        }

        return $this->categoryRepository->save($category);
    }

    /**
     * @param Category $category
     * @return Category
     */
    public function create(Category $category): Category
    {
        $slug = $category->getSlug()->getValue();

        if (!$slug) {
            throw new DomainException("Category must have a slug");
        }

        $existedCategory = $this->categoryRepository->getBySlug($slug);

        if (null != $existedCategory) {
            throw new DomainException(sprintf("Category slug '%s' already exists", $slug));
        }

        return $this->categoryRepository->save($category);
    }
}

Domain layer (ValueObject) - о них писала выше, здесь только код

ValueObjectInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\ValueObject;

/**
 * Interface ValueObjectInterface
 * @package App\CategoryFeature\Domain\ValueObject
 */
interface ValueObjectInterface
{
    /**
     * @return mixed
     */
    public function getValue();

    /**
     * @return string
     */
    public function __toString(): string;
}

ActiveValue
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\ValueObject;

/**
 * Class StatusValue
 * Warning: data value object must be immutable
 *
 * @package App\CategoryFeature\Domain\ValueObject
 */
class ActiveValue implements ValueObjectInterface
{
    private bool $status;

    /**
     * @param bool $status
     */
    public function __construct(bool $status)
    {
        $this->status = $status;
    }

    /**
     * @return bool
     */
    public function getValue(): bool
    {
        return $this->status;
    }

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

ContentValue
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\ValueObject;

/**
 * Class ContentValue
 * Warning: data value object must be immutable
 *
 * @package App\CategoryFeature\Domain\ValueObject
 */
class ContentValue implements ValueObjectInterface
{
    private ?string $content;

    /**
     * @param string|null $content
     */
    public function __construct(string $content = null)
    {
        $this->content = $content;
    }

    /**
     * @return string|null
     */
    public function getValue(): ?string
    {
        return $this->content;
    }

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

IdValue
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\ValueObject;

/**
 * Class IdValue
 * Warning: data value object must be immutable
 *
 * @package App\CategoryFeature\Domain\ValueObject
 */
class IdValue implements ValueObjectInterface
{
    private ?int $id;

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

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

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

SlugValue
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\ValueObject;

/**
 * Class SlugValue
 * Warning: data value object must be immutable
 *
 * @package App\CategoryFeature\Domain\ValueObject
 */
class SlugValue implements ValueObjectInterface
{
    private ?string $slug;

    /**
     * @param string|null $slug
     */
    public function __construct(string $slug = null)
    {
        $this->slug = $slug;
    }

    /**
     * @return string|null
     */
    public function getValue(): ?string
    {
        return $this->slug;
    }

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

TitleValue
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Domain\ValueObject;

/**
 * Class TitleValue
 * Warning: data value object must be immutable
 *
 * @package App\CategoryFeature\Domain\ValueObject
 */
class TitleValue implements ValueObjectInterface
{
    private ?string $title;

    /**
     * @param string|null $title
     */
    public function __construct(string $title = null)
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getValue(): ?string
    {
        return $this->title;
    }

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


Infrastructure layer

CategoryFeature (Infrastructure layer)

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

Infrastructure layer (DataMapper) - здесь мапим объект в доменную сущность, либо же в респонс объект для ДатаСторадж.

CategoryDomainMapperInterface - только для внутреннего использования
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Infrastructure\DataMapper;

use App\CategoryFeature\Domain\Entity\Category;
use App\DataManagerFeatureApi\DTOResponse\CategoryDataResponseInterface;

/**
 * Interface CategoryDomainMapperInterface
 * Map data response object to a domain object
 * @package App\CategoryFeature\Infrastructure\DataMapper
 **/
interface CategoryDomainMapperInterface
{
    /**
     * @param CategoryDataResponseInterface $dataResponse
     * @return Category
     */
    public function map(CategoryDataResponseInterface $dataResponse): Category;
}

CategoryDomainMapper
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Infrastructure\DataMapper;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\Factory\CategoryFactory as CategoryDomainFactory;
use App\DataManagerFeatureApi\DTOResponse\CategoryDataResponseInterface;

/**
 * Class CategoryDomainMapper
 * @package App\CategoryFeature\Infrastructure\DataMapper
 **/
class CategoryDomainMapper implements CategoryDomainMapperInterface
{
    private CategoryDomainFactory $categoryDomainFactory;

    /**
     * @param CategoryDomainFactory $categoryDomainFactory
     */
    public function __construct(CategoryDomainFactory $categoryDomainFactory)
    {
        $this->categoryDomainFactory = $categoryDomainFactory;
    }

    /**
     * Map data response object to a domain object
     * @param CategoryDataResponseInterface $dataResponse
     * @return Category
     */
    public function map(CategoryDataResponseInterface $dataResponse): Category
    {
        return $this->categoryDomainFactory->create(
            $dataResponse->getId(),
            $dataResponse->getTitle(),
            $dataResponse->getContent(),
            $dataResponse->getSlug(),
            $dataResponse->isActive()
        );
    }
}

CategoryRequestMapperInterface - только для внутреннего использования
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Infrastructure\DataMapper;

use App\CategoryFeature\Domain\Entity\Category;
use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;

/**
 * Interface CategoryRequestMapperInterface
 * Map domain object to a data object for sending to DataManagerFeature
 * @package App\CategoryFeature\Infrastructure\DataMapper
 **/
interface CategoryRequestMapperInterface
{
    /**
     * @param Category $domainEntity
     * @return CategoryDataRequestInterface
     */
    public function map(Category $domainEntity): CategoryDataRequestInterface;
}

CategoryRequestMapper
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Infrastructure\DataMapper;

use App\CategoryFeature\Domain\Entity\Category;
use App\DataManagerFeatureApi\DTORequest\CategoryDataRequestInterface;
use App\DataManagerFeatureApi\DTORequestFactory\CategoryDataRequestFactoryInterface;

/**
 * Class CategoryRequestMapper
 * @package App\CategoryFeature\Infrastructure\DataMapper
 **/
class CategoryRequestMapper implements CategoryRequestMapperInterface
{
    private CategoryDataRequestFactoryInterface $categoryDataRequestFactory;

    /**
     * @param CategoryDataRequestFactoryInterface $categoryDataRequestFactory
     */
    public function __construct(CategoryDataRequestFactoryInterface $categoryDataRequestFactory)
    {
        $this->categoryDataRequestFactory = $categoryDataRequestFactory;
    }

    /**
     * Map domain object to a data object for sending to DataManagerFeature
     * @param Category $domainEntity
     * @return CategoryDataRequestInterface
     */
    public function map(Category $domainEntity): CategoryDataRequestInterface
    {
        $requestModel = $this->categoryDataRequestFactory->create();

        $requestModel->setId($domainEntity->getId()->getValue());
        $requestModel->setTitle($domainEntity->getTitle()->getValue());
        $requestModel->setSlug($domainEntity->getSlug()->getValue());
        $requestModel->setContent($domainEntity->getContent()->getValue());
        $requestModel->setActive($domainEntity->isActive()->getValue());

        return $requestModel;
    }
}

Infrastructure layer (Repository)

CategoryRepository
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Infrastructure\Repository;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\Repository\CategoryRepositoryInterface;
use App\CategoryFeature\Infrastructure\DataMapper\CategoryRequestMapperInterface;
use App\CategoryFeature\Infrastructure\DataMapper\CategoryDomainMapperInterface;
use App\DataManagerFeatureApi\Service\CategoryDataServiceInterface;

/**
 * Class CategoryRepository
 *
 * We save data in another feature by doctrine.
 * For that we need to convert a Category Domain Entity to a Data Manager request object,
 * send that request object for saving to the Doctrine feature, get response object from the Doctrine Data Manager
 * and convert that object back to a Domain entity.
 *
 * This approach allows you not to be depended on a concrete data storage.
 * You can use Doctrine / Csv / Elastic if needed.
 * Just create a separate module for each realisation and implement DataManagerFeatureApi.
 * PS: don't forget to change interface realisation in construct.
 *
 * @package App\CategoryFeature\Infrastructure\Repository
 */
class CategoryRepository implements CategoryRepositoryInterface
{
    private CategoryDomainMapperInterface $categoryDomainMapper;
    private CategoryRequestMapperInterface $categoryRequestDataMapper;
    private CategoryDataServiceInterface $categoryDataService;

    /**
     * @param CategoryDomainMapperInterface $domainMapper
     * @param CategoryRequestMapperInterface $requestMapper
     * @param CategoryDataServiceInterface $categoryDataService
     */
    public function __construct(
        CategoryDomainMapperInterface  $domainMapper,
        CategoryRequestMapperInterface $requestMapper,
        CategoryDataServiceInterface   $categoryDataService
    ) {
        $this->categoryDomainMapper = $domainMapper;
        $this->categoryRequestDataMapper = $requestMapper;
        $this->categoryDataService = $categoryDataService;
    }

    /**
     * @param Category $category
     * @return Category
     */
    public function save(Category $category): Category
    {
        $requestDTO = $this->categoryRequestDataMapper->map($category);
        $responseDTO = $this->categoryDataService->save($requestDTO);

        return $this->categoryDomainMapper->map($responseDTO);
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $this->categoryDataService->deleteById($id);
    }

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array
    {
        $result = $this->categoryDataService->getList($criteria);
        $list = [];

        foreach ($result as $item) {
            $list[] = $this->categoryDomainMapper->map($item);

        }

        return $list;
    }

    /**
     * @param int $id
     * @return Category|null
     */
    public function getById(int $id): ?Category
    {
        $responseDTO = $this->categoryDataService->getById($id);
        return $responseDTO ? $this->categoryDomainMapper->map($responseDTO) : null;
    }

    /**
     * @param string $slug
     * @return Category|null
     */
    public function getBySlug(string $slug): ?Category
    {
        $responseDTO = $this->categoryDataService->getBySlug($slug);
        return $responseDTO ? $this->categoryDomainMapper->map($responseDTO) : null;
    }
}


Application layer

CategoryFeature (Application layer)

Теперь реализуем взаимодействие доменного слоя со входящими данными (реквест объектами) и респонсом.

Application layer (ApiService)

CategoryService - реализация публичных методов для работы с категорией
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\ApiService;

use App\CategoryFeature\Application\DTODomainMapper\CategoryMapperInterface as CategoryDomainMapper;
use App\CategoryFeature\Application\DTORequest\CategoryRequestDTOInterface;
use App\CategoryFeature\Application\DTORequestValidator\CategoryValidatorInterface;
use App\CategoryFeature\Application\DTOResponseMapper\CategoryMapperInterface as CategoryDTOResponseMapper;
use App\CategoryFeature\Application\Model\CategoryManagerInterface;
use App\CategoryFeatureApi\DTORequest\CategoryUpdateRequestInterface as UpdateRequest;
use App\CategoryFeatureApi\DTORequest\CategoryCreateRequestInterface as CreateRequest;
use App\CategoryFeatureApi\DTOResponse\CategoryDTOInterface as ResponseDTO;
use App\CategoryFeatureApi\Service\CategoryServiceInterface;
use Exception;

/**
 * Class CategoryService
 * @package App\CategoryFeature\Application\ApiService
 *
 * This class is for external usage (outside this feature) only
 * Use CategoryManagerInterface for data manipulating and return DTOResponse here
 *
 * Don't return Domain entity outside the feature!
 */
class CategoryService implements CategoryServiceInterface
{
    private CategoryDTOResponseMapper $categoryDtoResponseMapper;
    private CategoryDomainMapper $categoryDomainMapper;
    private CategoryManagerInterface $categoryManager;
    private CategoryValidatorInterface $categoryValidator;

    /**
     * @param CategoryDTOResponseMapper $categoryDtoResponseMapper
     * @param CategoryDomainMapper $categoryDomainMapper
     * @param CategoryManagerInterface $categoryManager
     * @param CategoryValidatorInterface $categoryValidator
     */
    public function __construct(
        CategoryDTOResponseMapper $categoryDtoResponseMapper,
        CategoryDomainMapper $categoryDomainMapper,
        CategoryManagerInterface $categoryManager,
        CategoryValidatorInterface $categoryValidator
    ) {
        $this->categoryDtoResponseMapper = $categoryDtoResponseMapper;
        $this->categoryDomainMapper = $categoryDomainMapper;
        $this->categoryManager = $categoryManager;
        $this->categoryValidator = $categoryValidator;
    }

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array
    {
        $list = [];
        $categories = $this->categoryManager->getList($criteria);

        foreach ($categories as $category) {
            $list[] = $this->categoryDtoResponseMapper->map($category);
        }

        return $list;
    }

    /**
     * @param int $categoryId
     * @return ResponseDTO|null
     */
    public function getById(int $categoryId): ?ResponseDTO
    {
        $category = $this->categoryManager->getById($categoryId);
        return $category ? $this->categoryDtoResponseMapper->map($category) : null;
    }

    /**
     * @param string $slug
     * @return ResponseDTO|null
     */
    public function getBySlug(string $slug): ?ResponseDTO
    {
        $category = $this->categoryManager->getBySlug($slug);
        return $category ? $this->categoryDtoResponseMapper->map($category) : null;
    }

    /**
     * @return ResponseDTO
     */
    public function initNewCategory(): ResponseDTO
    {
        $category = $this->categoryManager->initNewCategory();
        return $this->categoryDtoResponseMapper->map($category);
    }

    /**
     * @param CreateRequest $dtoRequest
     * @return ResponseDTO
     * @throws Exception
     */
    public function create(CreateRequest $dtoRequest): ResponseDTO
    {
        $this->validateRequest($dtoRequest);

        $domainEntity = $this->categoryDomainMapper->mapCreateRequest($dtoRequest);
        $createdCategory = $this->categoryManager->create($domainEntity);

        return $this->categoryDtoResponseMapper->map($createdCategory);
    }

    /**
     * @param UpdateRequest $dtoRequest
     * @return ResponseDTO
     * @throws Exception
     */
    public function update(UpdateRequest $dtoRequest): ResponseDTO
    {
        $this->validateRequest($dtoRequest);

        $domainEntity = $this->categoryDomainMapper->mapUpdateRequest($dtoRequest);
        $updatedCategory = $this->categoryManager->update($domainEntity);

        return $this->categoryDtoResponseMapper->map($updatedCategory);
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $this->categoryManager->deleteById($id);
    }

    /**
     * @param CategoryRequestDTOInterface $requestDTO
     * @return void
     * @throws Exception
     */
    protected function validateRequest(CategoryRequestDTOInterface $requestDTO): void
    {
        $errors = $this->categoryValidator->validate($requestDTO);
        $message = "";

        foreach ($errors as $property => $errorMsgs) {
            $message .= "The $property is not valid. Message: " . implode("," , $errorMsgs) . " \n";
        }

        if ($message) {
            throw new Exception($message);
        }
    }
}

Application layer (DTODomainMapper)

CategoryMapperInterface - только для использования внутри фичи
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTODomainMapper;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeatureApi\DTORequest\CategoryCreateRequestInterface;
use App\CategoryFeatureApi\DTORequest\CategoryUpdateRequestInterface;

/**
 * Interface CategoryMapperInterface
 * Map DTO request to a Domain entity
 *
 * @package App\CategoryFeature\Application\DTODomainMapper
 **/
interface CategoryMapperInterface
{
    /**
     * @param CategoryUpdateRequestInterface $dtoRequest
     * @return Category
     */
    public function mapUpdateRequest(CategoryUpdateRequestInterface $dtoRequest): Category;

    /**
     * @param CategoryCreateRequestInterface $dtoRequest
     * @return Category
     */
    public function mapCreateRequest(CategoryCreateRequestInterface $dtoRequest): Category;
}

CategoryMapper - мапим реквест в доменную сущность
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTODomainMapper;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\Factory\CategoryFactory as CategoryDomainFactory;
use App\CategoryFeatureApi\DTORequest\CategoryCreateRequestInterface;
use App\CategoryFeatureApi\DTORequest\CategoryUpdateRequestInterface;

/**
 * Class CategoryMapper
 * @package App\CategoryFeature\Application\DTODomainMapper
 **/
class CategoryMapper implements CategoryMapperInterface
{
    private CategoryDomainFactory $categoryDomainFactory;

    /**
     * @param CategoryDomainFactory $categoryDomainFactory
     */
    public function __construct(CategoryDomainFactory $categoryDomainFactory)
    {
        $this->categoryDomainFactory = $categoryDomainFactory;
    }

    /**
     * Map DTO request to a Domain entity
     * @param CategoryUpdateRequestInterface $dtoRequest
     * @return Category
     */
    public function mapUpdateRequest(CategoryUpdateRequestInterface $dtoRequest): Category
    {
        return $this->categoryDomainFactory->create(
            $dtoRequest->getId(),
            $dtoRequest->getTitle(),
            $dtoRequest->getContent(),
            $dtoRequest->getSlug(),
            $dtoRequest->isActive()
        );
    }

    /**
     * Map DTO request to a Domain entity
     * @param CategoryCreateRequestInterface $dtoRequest
     * @return Category
     */
    public function mapCreateRequest(CategoryCreateRequestInterface $dtoRequest): Category
    {
        return $this->categoryDomainFactory->create(
            null,
            $dtoRequest->getTitle(),
            $dtoRequest->getContent(),
            $dtoRequest->getSlug(),
            $dtoRequest->isActive()
        );
    }
}

Application layer (DTORequest) - Использую атрибуты для первичной валидации ввода. Т.е. проверка на типы/пустоту и тд. Не реализуем валидацию бизнес процессов здесь

CategoryRequestDTOInterface - только для внутреннего использования
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTORequest;

/**
 * Interface CategoryRequestDTOInterface
 * @package App\CategoryFeature\Application\DTORequest
 * Marker for Category DTO Requests
 */
interface CategoryRequestDTOInterface
{

}

CategoryCreate - реквест для создания категории.
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTORequest;

use App\CategoryFeatureApi\DTORequest\CategoryCreateRequestInterface;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class CategoryCreate
 * Request object for a category creation
 *
 * @package App\CategoryFeature\Application\DTORequest
 */
class CategoryCreate implements CategoryCreateRequestInterface, CategoryRequestDTOInterface
{
    #[Assert\NotBlank]
    #[Assert\Type("string")]
    private ?string $title = null;

    #[Assert\Type("string")]
    private ?string $content = null;

    #[Assert\Type("string")]
    private ?string $slug = null;

    #[Assert\Type("bool")]
    private bool $isActive = false;

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void
    {
        $this->content = $content;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void
    {
        $this->slug = $slug;
    }

    /**
     * @return bool
     */
    public function isActive(): bool
    {
        return $this->isActive;
    }

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void
    {
        $this->isActive = $active;
    }
}

CategoryUpdate
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTORequest;

use App\CategoryFeatureApi\DTORequest\CategoryUpdateRequestInterface;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class CategoryUpdate
 * Request object for a category updates
 * @package App\CategoryFeature\Application\DTORequest
 */
class CategoryUpdate implements CategoryUpdateRequestInterface, CategoryRequestDTOInterface
{
    #[Assert\NotBlank]
    #[Assert\Type("int")]
    private ?int $id = null;

    #[Assert\NotBlank]
    #[Assert\Type("string")]
    private ?string $title = null;

    #[Assert\Type("string")]
    private ?string $content = null;

    #[Assert\Type("string")]
    private ?string $slug = null;

    #[Assert\Type("bool")]
    private bool $isActive = false;

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void
    {
        $this->id = $id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void
    {
        $this->content = $content;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void
    {
        $this->slug = $slug;
    }

    /**
     * @return bool
     */
    public function isActive(): bool
    {
        return $this->isActive;
    }

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void
    {
        $this->isActive = $active;
    }
}

Application layer (DTORequestFactory) - реализация фабрик

CategoryCreateFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTORequestFactory;

use App\CategoryFeature\Application\DTORequest\CategoryCreate;
use App\CategoryFeatureApi\DTORequest\CategoryCreateRequestInterface;
use App\CategoryFeatureApi\DTORequestFactory\CategoryCreateDTOFactoryInterface;

/**
 * Class CategoryCreateFactory
 * @package App\CategoryFeature\Application\DTORequestFactory
 */
class CategoryCreateFactory implements CategoryCreateDTOFactoryInterface
{
    /**
     * @return CategoryCreateRequestInterface
     */
    public function create(): CategoryCreateRequestInterface
    {
        return new CategoryCreate();
    }
}

CategoryUpdateFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTORequestFactory;

use App\CategoryFeature\Application\DTORequest\CategoryUpdate;
use App\CategoryFeatureApi\DTORequest\CategoryUpdateRequestInterface;
use App\CategoryFeatureApi\DTORequestFactory\CategoryUpdateDTOFactoryInterface;

/**
 * Class CategoryUpdateFactory
 * @package App\CategoryFeature\Application\DTORequestFactory
 */
class CategoryUpdateFactory implements CategoryUpdateDTOFactoryInterface
{
    /**
     * @return CategoryUpdateRequestInterface
     */
    public function create(): CategoryUpdateRequestInterface
    {
        return new CategoryUpdate();
    }
}

Application layer (DTORequestValidator) - валидаторы для реквеста. Т.к. у симфони есть встроенный механизм для валидации объектов, то я сделала обвертку для этого объекта.

CategoryValidatorInterface - для внутреннего использования
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTORequestValidator;

use App\CategoryFeature\Application\DTORequest\CategoryRequestDTOInterface;

/**
 * Interface CategoryValidatorInterface
 * Primary request validation (input). Other validation must be in interactors.
 * Interactors are responsible for business logic validation.
 *
 * @package App\CategoryFeature\Application\DTORequestValidator
 */
interface CategoryValidatorInterface
{
    /**
     * @param CategoryRequestDTOInterface $dto
     * @return array
     */
    public function validate(CategoryRequestDTOInterface $dto): array;
}

CategoryValidator
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTORequestValidator;

use App\CategoryFeature\Application\DTORequest\CategoryRequestDTOInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Class CategoryValidator
 * @package App\CategoryFeature\Application\DTORequestValidator
 */
class CategoryValidator implements CategoryValidatorInterface
{
    private ValidatorInterface $validator;

    /**
     * @param ValidatorInterface $validator
     */
    public function __construct(ValidatorInterface $validator)
    {
        $this->validator = $validator;
    }

    /**
     * Primary request validation (input). Other validation must be in interactors.
     * Interactors are responsible for business logic validation.
     *
     * @param CategoryRequestDTOInterface $dto
     * @return array
     */
    public function validate(CategoryRequestDTOInterface $dto): array
    {
        $violations = [];
        $violationList = $this->validator->validate($dto);

        foreach ($violationList as $violation) {
            $violations[$violation->getPropertyPath()][] = $violation->getMessage();
        }

        return $violations;
    }
}

Application layer (DTOResponse)

Category
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTOResponse;

use App\CategoryFeatureApi\DTOResponse\CategoryDTOInterface;

/**
 * Class Category
 * Module response object. This object will be returned for other features usage.
 *
 * @package App\CategoryFeature\Application\DTOResponse;
 **/
class Category implements CategoryDTOInterface
{
    private ?int $id = null;
    private ?string $title = null;
    private ?string $content = null;
    private ?string $slug = null;
    private bool $isActive = false;

    /**
     * @return int|null
     */
    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @param int|null $id
     */
    public function setId(int $id = null): void
    {
        $this->id = $id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string|null $title
     */
    public function setTitle(string $title = null): void
    {
        $this->title = $title;
    }

    /**
     * @return string|null
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     */
    public function setContent(string $content = null): void
    {
        $this->content = $content;
    }

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

    /**
     * @param string|null $slug
     */
    public function setSlug(string $slug = null): void
    {
        $this->slug = $slug;
    }

    /**
     * @return bool
     */
    public function isActive(): bool
    {
        return $this->isActive;
    }

    /**
     * @param bool $active
     */
    public function setActive(bool $active): void
    {
        $this->isActive = $active;
    }
}

Application layer (DTOResponseFactory) - для внутреннего использования

CategoryFactoryInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTOResponseFactory;

use App\CategoryFeatureApi\DTOResponse\CategoryDTOInterface;

/**
 * Interface CategoryFactoryInterface
 * @package App\CategoryFeatureApi\DTOResponseFactory
 **/
interface CategoryFactoryInterface
{
    /**
     * @return CategoryDTOInterface
     */
    public function create(): CategoryDTOInterface;
}

CategoryFactory
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTOResponseFactory;

use App\CategoryFeature\Application\DTOResponse\Category;
use App\CategoryFeatureApi\DTOResponse\CategoryDTOInterface;

/**
 * Class CategoryFactory
 * @package App\CategoryFeature\Application\DTOResponseFactory
 **/
class CategoryFactory implements CategoryFactoryInterface
{
    /**
     * @return CategoryDTOInterface
     */
    public function create(): CategoryDTOInterface
    {
        return new Category();
    }
}

Application layer (DTOResponseMapper) - для внутреннего использования

CategoryMapperInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTOResponseMapper;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeatureApi\DTOResponse\CategoryDTOInterface as CategoryResponseDTO;

/**
 * Interface CategoryMapperInterface
 * Map domain entity to a Response DTO object.
 * Don't return domain entity outside the module.
 *
 * @package App\CategoryFeature\Application\DTOResponseMapper
 **/
interface CategoryMapperInterface
{
    /**
     * @param Category $category
     * @return CategoryResponseDTO
     */
    public function map(Category $category): CategoryResponseDTO;
}

CategoryMapper
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\DTOResponseMapper;

use App\CategoryFeature\Application\DTOResponseFactory\CategoryFactoryInterface;
use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeatureApi\DTOResponse\CategoryDTOInterface as CategoryResponseDTO;

/**
 * Class CategoryMapper
 * @package App\CategoryFeature\Application\DTOResponseMapper
 **/
class CategoryMapper implements CategoryMapperInterface
{
    private CategoryFactoryInterface $categoryDTOResponseFactory;

    /**
     * @param CategoryFactoryInterface $categoryDTOResponseFactory
     */
    public function __construct(CategoryFactoryInterface $categoryDTOResponseFactory)
    {
        $this->categoryDTOResponseFactory = $categoryDTOResponseFactory;
    }

    /**
     * Map domain entity to a Response DTO object.
     * Don't return domain entity outside the module.
     *
     * @param Category $category
     * @return CategoryResponseDTO
     */
    public function map(Category $category): CategoryResponseDTO
    {
        $dtoResponse = $this->categoryDTOResponseFactory->create();

        $dtoResponse->setActive($category->isActive()->getValue());
        $dtoResponse->setId($category->getId()->getValue());
        $dtoResponse->setContent($category->getContent()->getValue());
        $dtoResponse->setSlug($category->getSlug()->getValue());
        $dtoResponse->setTitle($category->getTitle()->getValue());

        return $dtoResponse;
    }
}

Application layer (Model) - для внутреннего использования

CategoryManagerInterface
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\Model;

use App\CategoryFeature\Domain\Entity\Category;

/**
 * Interface CategoryManagerInterface
 * @package App\CategoryFeature\Application\Model
 **/
interface CategoryManagerInterface
{
    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array;

    /**
     * @param int $categoryId
     * @return Category|null
     */
    public function getById(int $categoryId): ?Category;

    /**
     * @param string $slug
     * @return Category|null
     */
    public function getBySlug(string $slug): ?Category;

    /**
     * @return Category
     */
    public function initNewCategory(): Category;

    /**
     * @param Category $category
     * @return Category
     */
    public function create(Category $category): Category;

    /**
     * @param Category $category
     * @return Category
     */
    public function update(Category $category): Category;

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void;
}

CategoryManager
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\CategoryFeature\Application\Model;

use App\CategoryFeature\Domain\Entity\Category;
use App\CategoryFeature\Domain\Interactor\CategoryDeleteInteractor;
use App\CategoryFeature\Domain\Interactor\CategoryLoadInteractor;
use App\CategoryFeature\Domain\Interactor\CategorySaveInteractor;

/**
 * Class CategoryManager
 * @package App\CategoryFeature\Application\Model
 *
 * This class is for internal usage (inside this feature) only
 **/
class CategoryManager implements CategoryManagerInterface
{
    private CategorySaveInteractor $categorySaveInteractor;
    private CategoryDeleteInteractor $categoryDeleteInteractor;
    private CategoryLoadInteractor $categoryLoadInteractor;

    /**
     * @param CategorySaveInteractor $categorySaveInteractor
     * @param CategoryDeleteInteractor $categoryDeleteInteractor
     * @param CategoryLoadInteractor $categoryLoadInteractor
     */
    public function __construct(
        CategorySaveInteractor $categorySaveInteractor,
        CategoryDeleteInteractor $categoryDeleteInteractor,
        CategoryLoadInteractor $categoryLoadInteractor,
    ) {
        $this->categorySaveInteractor = $categorySaveInteractor;
        $this->categoryDeleteInteractor = $categoryDeleteInteractor;
        $this->categoryLoadInteractor = $categoryLoadInteractor;
    }

    /**
     * @param array|null $criteria
     * @return array
     */
    public function getList(array $criteria = null): array
    {
        return $this->categoryLoadInteractor->loadAll($criteria);
    }

    /**
     * @param int $categoryId
     * @return Category|null
     */
    public function getById(int $categoryId): ?Category
    {
        return $this->categoryLoadInteractor->loadById($categoryId);
    }

    /**
     * @param string $slug
     * @return Category|null
     */
    public function getBySlug(string $slug): ?Category
    {
        return $this->categoryLoadInteractor->loadBySlug($slug);
    }

    /**
     * @return Category
     */
    public function initNewCategory(): Category
    {
        return $this->categoryLoadInteractor->loadEmptyCategory();
    }

    /**
     * @param Category $category
     * @return Category
     */
    public function create(Category $category): Category
    {
        return $this->categorySaveInteractor->create($category);
    }

    /**
     * @param Category $category
     * @return Category
     */
    public function update(Category $category): Category
    {
        return $this->categorySaveInteractor->update($category);
    }

    /**
     * @param int $id
     * @return void
     */
    public function deleteById(int $id): void
    {
        $this->categoryDeleteInteractor->deleteById($id);
    }
}

И так, у нас готова фича для категории!

PostFeature и PostFeatureApi

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

Теперь, когда у нас готовы фичи, перейдем фронтовой части.

FrontFeature

Здесь идет реализация фронтовой части приложения. Как писалось выше, не все фичи нуждаются во всех слоях. Так, такие слои, как Application, Domain, Infrastructure здесь не нужны. Здесь нам нужен только один слой - Presentation. Для отображения данных будут использоваться CategoryServiceInterface и PostServiceInterface, которые мы описали в Api фичах для каждого модуля. Здесь у нас будут контроллеры, шаблоны.

Presentation layer

Presentation layer (Controller) - я преверженец того, что 1 контроллер = 1 экшен. Симфони позволяет использовать ADR pattern, соответсвенно каждый контроллер использует метод __invoke() и отвечает только за 1 экшен. Подробнее можно почитать тут

Приступим к описанию контроллеров. Для каждой сущности своя папка, где храняться контроллеры. Если хотите создать страницу, где будет выводиться список категорий, то можно создать Category\ListController. (думаю, логика тут ясна).

CategoryViewController
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\FrontFeature\Presentation\Controller\Category;

use App\CategoryFeatureApi\Service\CategoryServiceInterface;
use App\PostFeatureApi\Service\PostServiceInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * Class ViewController
 * @package App\FrontFeature\Presentation\Controller\Category
 *
 * WARNING! Presentation layer can communicate ONLY with Application layer or shared API.
 * Presentation layer doesn't know anything about domain
 */
#[Route('/category', name: 'frontend_feature_category_')]
class ViewController extends AbstractController
{
    private CategoryServiceInterface $categoryService;
    private PostServiceInterface $postService;

    /**
     * @param PostServiceInterface $postService
     * @param CategoryServiceInterface $categoryService
     */
    public function __construct(
        PostServiceInterface $postService,
        CategoryServiceInterface $categoryService
    ) {
        $this->categoryService = $categoryService;
        $this->postService = $postService;
    }

    #[Route('/{slug}', name: 'view', methods: ["GET"])]
    public function __invoke(string $slug): Response
    {
        $category = $this->categoryService->getBySlug($slug);

        if (null === $category) {
            throw $this->createNotFoundException();
        }

        return $this->render('@frontend_feature_templates/baseTheme/layout/category/view.html.twig', [
            'category' => $category,
            'menuItems' => $this->categoryService->getList(["isActive" => true]),
            'seoTitle' => $category->getTitle(),
            'seoDescription' => $category->getTitle(),
            'postList' => $this->postService->getList(['category' => $category->getId(), 'isPublished' => true]),
        ]);
    }
}

HomeViewController
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\FrontFeature\Presentation\Controller\Home;

use App\CategoryFeatureApi\Service\CategoryServiceInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * Class ViewController
 * @package App\FrontFeature\Presentation\Controller\Home
 *
 * WARNING! Presentation layer can communicate ONLY with Application layer or shared API.
 * Presentation layer doesn't know anything about domain
 */
class ViewController extends AbstractController
{
    private const PAGE_TITLE = "Symfony Blog: Learn Clean Architecture Together";
    private const PAGE_DESCRIPTION = "Learn Clean Architecture Together";

    private CategoryServiceInterface $categoryService;

    /**
     * @param CategoryServiceInterface $categoryService
     */
    public function __construct(CategoryServiceInterface $categoryService)
    {
        $this->categoryService = $categoryService;
    }

    #[Route('/', name: 'home', methods: ["GET"])]
    public function __invoke(): Response
    {
        $categoryList = $this->categoryService->getList(["isActive" => true]);

        return $this->render('@frontend_feature_templates/baseTheme/layout/home/view.html.twig', [
            'categoryList' => $categoryList,
            'seoTitle' => self::PAGE_TITLE,
            'seoDescription' => self::PAGE_DESCRIPTION,
            'menuItems' => $categoryList
        ]);
    }
}

PostViewController
<?php
/**
 * NOTICE OF LICENSE
 *
 * This source file is subject to the GNU General Public License v3 (GPL 3.0)
 * It is available through the world-wide-web at this URL:
 * https://www.gnu.org/licenses/gpl-3.0.en.html
 *
 * @license    https://www.gnu.org/licenses/gpl-3.0.en.html GNU General Public License v3 (GPL 3.0)
 */

declare(strict_types=1);

namespace App\FrontFeature\Presentation\Controller\Post;

use App\CategoryFeatureApi\Service\CategoryServiceInterface;
use App\PostFeatureApi\Service\PostServiceInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * Class ViewController
 * @package App\FrontFeature\Presentation\Controller\Post
 *
 * WARNING! Presentation layer can communicate ONLY with Application layer or shared API.
 * Presentation layer doesn't know anything about domain
 */
#[Route('/post', name: 'frontend_feature_post_')]
class ViewController extends AbstractController
{
    private PostServiceInterface $postService;
    private CategoryServiceInterface $categoryService;

    /**
     * @param CategoryServiceInterface $categoryService
     * @param PostServiceInterface $postService
     */
    public function __construct(
        CategoryServiceInterface $categoryService,
        PostServiceInterface $postService
    ) {
        $this->postService = $postService;
        $this->categoryService = $categoryService;
    }

    #[Route('/{slug}', name: 'view', methods: ["GET"])]
    public function __invoke(string $slug): Response
    {
        $post = $this->postService->getBySlug($slug);

        if (null === $post) {
            throw $this->createNotFoundException();
        }

        return $this->render('@frontend_feature_templates/baseTheme/layout/post/view.html.twig', [
            'post' => $post,
            'menuItems' => $this->categoryService->getList(["isActive" => true]),
            'seoTitle' => $post->getTitle(),
            'seoDescription' => $post->getTitle(),
        ]);
    }
}

Обратите внимание, что при рендере шаблона стоит "[dog]frontend_feature_templates". Забегая наперед скажу, что тк мы не используем стандартные фолдеры симфони, то требуется указать, где смотреть twig шаблоны. Для этого, нужно зайти в конфиг config/packages/twig.yaml и добавить пути:

twig:
    default_path: '%kernel.project_dir%/templates'
    paths:
        'src/FrontFeature/Presentation/view': 'frontend_feature_templates' #new folder

when@test:
    twig:
        strict_variables: true

Также наши контроллеры не будут работать, тк находятся в другой директории. Для этого идем в config/routes.yaml :

#controllers:
#    resource:
#        path: ../src/Controller/
#        namespace: App\Controller
#    type: attribute

frontend_feature_controllers:
    resource: '../src/FrontFeature/Presentation/Controller/'
    type: attribute
    trailing_slash_on_root: false

Presentation layer (view) - я также постаралась отделить темы друг от друга. Основную тему поместила во view/baseTheme. Я не использую здесь Encore, тк этот блог написан в целях изучения чистой архитектуры. По этому, я максимально упростила тему. Вы сможете сами настроить фронт по своему усмотрению.

Presentation layer (view/baseTheme/block) - здесь основные блоки темы

footer.html.twig
<p>This is blog footer</p>

head.html.twig
<head>
    <meta charset="UTF-8">
    <title>
        {% block seoTitle %}{{ seoTitle }}{% endblock %}
    </title>
    <meta name="description" content="{% block seoDescription %}{{ seoDescription }}{% endblock %}">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
    <link rel="icon"
          href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>"
    >

    {% block stylesheets %}
        {{ encore_entry_link_tags('app') }}
    {% endblock %}

    {% block javascripts %}
        {{ encore_entry_script_tags('app') }}
    {% endblock %}
</head>

<style>
    .bd-placeholder-img {
        font-size: 1.125rem;
        text-anchor: middle;
        -webkit-user-select: none;
        -moz-user-select: none;
        user-select: none;
    }

    @media (min-width: 768px) {
        .bd-placeholder-img-lg {
            font-size: 3.5rem;
        }
    }

    .b-example-divider {
        height: 3rem;
        background-color: rgba(0, 0, 0, .1);
        border: solid rgba(0, 0, 0, .15);
        border-width: 1px 0;
        box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
    }

    .b-example-vr {
        flex-shrink: 0;
        width: 1.5rem;
        height: 100vh;
    }

    .bi {
        vertical-align: -.125em;
        fill: currentColor;
    }

    .nav-scroller {
        position: relative;
        z-index: 2;
        height: 2.75rem;
        overflow-y: hidden;
    }

    .nav-scroller .nav {
        display: flex;
        flex-wrap: nowrap;
        padding-bottom: 1rem;
        margin-top: -1px;
        overflow-x: auto;
        text-align: center;
        white-space: nowrap;
        -webkit-overflow-scrolling: touch;
    }

    /* stylelint-disable selector-list-comma-newline-after */

    .blog-header {
        border-bottom: 1px solid #e5e5e5;
    }

    .blog-header-logo {
        font-family: "Playfair Display", Georgia, "Times New Roman", serif/*rtl:Amiri, Georgia, "Times New Roman", serif*/;
        font-size: 2.25rem;
    }

    .blog-header-logo:hover {
        text-decoration: none;
    }

    h1, h2, h3, h4, h5, h6 {
        font-family: "Playfair Display", Georgia, "Times New Roman", serif/*rtl:Amiri, Georgia, "Times New Roman", serif*/;
    }

    .display-4 {
        font-size: 2.5rem;
    }
    @media (min-width: 768px) {
        .display-4 {
            font-size: 3rem;
        }
    }

    .flex-auto {
        flex: 0 0 auto;
    }

    .h-250 { height: 250px; }
    @media (min-width: 768px) {
        .h-md-250 { height: 250px; }
    }

    /* Pagination */
    .blog-pagination {
        margin-bottom: 4rem;
    }

    /*
     * Blog posts
     */
    .blog-post {
        margin-bottom: 4rem;
    }
    .blog-post-title {
        font-size: 2.5rem;
    }
    .blog-post-meta {
        margin-bottom: 1.25rem;
        color: #727272;
    }

    /*
     * Footer
     */
    .blog-footer {
        padding: 2.5rem 0;
        color: #727272;
        text-align: center;
        background-color: #f9f9f9;
        border-top: .05rem solid #e5e5e5;
    }
    .blog-footer p:last-child {
        margin-bottom: 0;
    }
</style>

header.html.twig
<div class="row flex-nowrap justify-content-between align-items-center">
    <div class="col-12 text-center">
        <a class="blog-header-logo text-dark" href="/">Clean Architecture Blog</a>
    </div>
</div>

Presentation layer (view/baseTheme/layout) - здесь основные макеты для страниц

category/view.html.twig
{% extends '@frontend_feature_templates/baseTheme/layout/base.html.twig' %}

{% block pageTitle %}
    {{category.title}}
{% endblock %}

{% block main %}
    <div class="row g-5">
        <div class="col-md-12">
            <article class="blog-post">
                {{ category.content }}
            </article>
        </div>
    </div>
    <div class="row mb-2">
        {% if postList %}
            {% for post in postList %}
                <div class="col-md-6">
                    <div class="row g-0 border rounded overflow-hidden flex-md-row mb-4 shadow-sm h-md-250 position-relative">
                        <div class="col p-4 d-flex flex-column position-static">
                            <h3 class="mb-0">{{ post.title }}</h3>
                            <a
                                    href="{{ path('frontend_feature_post_view', {slug: post.slug}) }}"
                                    class="stretched-link"
                            >
                                Continue reading
                            </a>
                        </div>
                    </div>
                </div>
            {% endfor %}
        {% else %}
            <p>No posts yet =(</p>
        {% endif %}
    </div>
{% endblock %}

home/view.html.twig
{% extends '@frontend_feature_templates/baseTheme/layout/base.html.twig' %}

{% block pageTitle %}
    Welcome! Glad To See You Here!
{% endblock %}

{% block main %}
    <div class="row mb-2">
        {% for category in categoryList %}
            <div class="col-md-6">
                <div class="row g-0 border rounded overflow-hidden flex-md-row mb-4 shadow-sm h-md-250 position-relative">
                    <div class="col p-4 d-flex flex-column position-static">
                        <h3 class="mb-0">{{ category.title }}</h3>
                        <a
                                href="{{ path('frontend_feature_category_view', {slug: category.slug}) }}"
                                class="stretched-link"
                        >
                            Continue reading
                        </a>
                    </div>
                </div>
            </div>
        {% endfor %}
    </div>
{% endblock %}

post/view.html.twig
{% extends '@frontend_feature_templates/baseTheme/layout/base.html.twig' %}

{% block pageTitle %}
    {{post.title}}
{% endblock %}

{% block main %}
    <div class="row g-5">
        <div class="col-md-8">
            <article class="blog-post">
                <p class="blog-post-meta">{{ post.updatedAt }}</p>
                {{ post.content }}
            </article>
        </div>
        <div class="col-md-4">
            <div class="position-sticky" style="top: 2rem;">
                <div class="p-4">
                    <h4 class="fst-italic">Categories</h4>
                    <ol class="list-unstyled mb-0">
                        {% for item in menuItems %}
                            <li>
                                <a
                                    class="p-2 link-secondary"
                                    href="{{ path('frontend_feature_category_view', {slug: item.slug}) }}"
                                >
                                    {{ item.title }}
                                </a>
                            </li>
                        {% endfor %}
                    </ol>
                </div>
            </div>
        </div>
    </div>
{% endblock %}

base.html.twig - базовый макет, от которого наследуются остальные
<!DOCTYPE html>
<html lang="en">
    {%
        include '@frontend_feature_templates/baseTheme/block/head.html.twig'
        with {
            'seoTitle': (seoTitle is defined) ? seoTitle : 'Default Title',
            'seoDescription': (seoDescription is defined) ? seoDescription : 'Default Description'
        }
    %}
    <body>
        {% block body %}
            <div class="container">
                <header class="{% block headerClass %}blog-header lh-1 py-3{% endblock %}">
                    {% block header %}
                        {% include '@frontend_feature_templates/baseTheme/block/header.html.twig' %}
                    {% endblock %}
                </header>

                <div class="{% block blockNavWrapperClass %}nav-scroller py-1 mb-2{% endblock %}">
                    {% block nav %}
                        <nav class="nav d-flex justify-content-between">
                            {% for item in menuItems %}
                                <a
                                    class="p-2 link-secondary"
                                    href="{{ path('frontend_feature_category_view', {slug: item.slug}) }}">
                                    {{ item.title }}
                                </a>
                            {% endfor %}
                        </nav>
                    {% endblock %}
                </div>
            </div>

            <main class="{% block blockMainWrapperClass %}container{% endblock %}">
                <div class="p-4 p-md-5 mb-4 rounded text-bg-dark">
                    <div class="col-md-12 px-0">
                        <h1 class=" fst-italic">{% block pageTitle %}{% endblock %}</h1>
                    </div>
                </div>
                {% block main %}{% endblock %}
            </main>

            <footer class="{% block blockFooterWrapperClass %}blog-footer{% endblock %}">
                {% block footer %}
                    {% include '@frontend_feature_templates/baseTheme/block/footer.html.twig' %}
                {% endblock %}
            </footer>
        {% endblock %}
    </body>
</html>


Итоговый вариант

Последний штрих - это поправить config/services.yaml. Не забудьте добавить сервисы и прописать DI:

    App\CategoryFeature\:
        resource: '../src/CategoryFeature/'
        exclude:
            - '../src/CategoryFeature/Domain'

    App\CategoryFeatureApi\:
        resource: '../src/CategoryFeatureApi/'

    App\DoctrineDataFeature\:
        resource: '../src/DoctrineDataFeature/'
        exclude:
            - '../src/DoctrineDataFeature/Domain'

    App\DataManagerFeatureApi\:
        resource: '../src/DataManagerFeatureApi/'

    App\PostFeatureApi\:
        resource: '../src/PostFeatureApi/'

    App\PostFeature\:
        resource: '../src/PostFeature/'

    ##### DI Area #####
    App\DoctrineDataFeature\Application\ApiService\CategoryService:
        arguments:
            $dataMapper: '@App\DoctrineDataFeature\Application\DataMapper\CategoryMapper'
            $categoryRepository: '@App\DoctrineDataFeature\Infrastructure\Repository\CategoryRepository'

    App\DoctrineDataFeature\Application\ApiService\PostService:
        arguments:
            $dataMapper: '@App\DoctrineDataFeature\Application\DataMapper\PostMapper'
            $postRepository: '@App\DoctrineDataFeature\Infrastructure\Repository\PostRepository'

И так, когда все фичи реализованы, нам осталось только наполнить сайт контентом и посмотреть результат. Давайте заполним сайт фейковыми данными, и посмотрим, как это будет выглядеть.

Создадим пару категорий:

use App\CategoryFeatureApi\Service\CategoryServiceInterface;
use App\CategoryFeatureApi\DTORequestFactory\CategoryCreateDTOFactoryInterface;

private CategoryServiceInterface $categoryService;
private CategoryCreateDTOFactoryInterface $categoryCreateDTOFactory;


$categoryRequest = $this->createRequestFactory->create();
$categoryRequest->setTitle("My first category");
$categoryRequest->setSlug("test-slug");
$categoryRequest->setContent("Dummy category content");
$categoryRequest->setActive(true);

$this->categoryService->create($categoryRequest);

и тд.

Создадим пару постов:

use App\PostFeatureApi\DTORequestFactory\PostCreateDTOFactoryInterface;
use App\PostFeatureApi\Service\PostServiceInterface;


private PostCreateDTOFactoryInterface $postCreateDTOFactory;
private PostServiceInterface $postService;

$postRequest = $this->postCreateDTOFactory->create();
$postRequest->setSlug("my-test-slug");
$postRequest->setTitle("My dummy title");
$postRequest->setContent("This is dummy content");
$postRequest->setPublished(true);
$postRequest->setCategoryId(1);

$this->postService->create($postRequest);

и тд.

Проверяем результат:

Главная страница
Главная страница
Страница категории
Страница категории
Страница с постом
Страница с постом

На этом все. У нас получились независимые фичи друг от друга, мы также не зависим от базы данных. Общение происходит по средствам АПИ. И в целом, если захочется перенести код на другой фреймворк, то это будет сделать относительно просто и практически без изменений.

Полные исходники здесь - прямая ссылка на гитхаб

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


  1. vasyakolobok77
    00.00.0000 00:00
    +8

    Идеи и подходы описаны верно, но показалось, что реализованы не совсем.

    Ваш Category одновременно и domain объект и db entity. Вы вроде хотите абстрагироваться от конкретной реализации хранения, но при этом сам domain объект напичкан аннотациями ORM. В идеале это надо тоже разделить в соответствующие слои.

    Другой момент, DDD предполагает наличие "умных" domain объектов, в которых реализована вся основная бизнес логика приложения. В чистой архитектуре часто, хотя конечно же не обязательно, логику описывают в UseCase'ах / Interactor'ах. В вашем случае от DDD ничего нет, или я что-то упустил.


    1. anny_anny Автор
      00.00.0000 00:00
      -1

      Мне кажется, что вы немного не так поняли. Есть фича CategoryFeature, там доменная модель категории никак не связана ни с доктриной, ни с ее аннотациями. Там вообще используются ValueObject. Это отдельная фича для работы с категорией.

      То, что вы пишите, наверное, вы имеете ввиду модель категории из DoctirneDataFeature, где конечно же доменная модель будет "напичкана" доктриновскими аннотациями, тк предназначение этой фичи - это работа с доктриной и только с ней, соответсвенно доменные модели связаны с доктриной.

      Модель категории не является и domain и doctrine, тк это абсолютно две разные фичи. Как раз я и сделала абстрогирование, вы можете написать не DoctrineDataFeature, а, например, CsvDataFeature и описать доменную модель контретно для этого дата сторадж так, как вам нужно. При этом CategoryFeature ничего не знает о том, где храняться данные. Там просто используется "порт" для работы с дата сторадж.

      Иными словами, дата сторадж вынесен отдельно, работа с категорией тоже отдельно. При изменении дата стораджа сама CategoryFeature не страдает, все что нужно - это переопределить в di фичу дата стораджа, которую нужно использовать


      1. hello_my_name_is_dany
        00.00.0000 00:00
        +8

        По сути вы выделили инфраструктурный код в отдельный "Feature", где тоже есть разделение на слои зачем-то. Это как сделать один микросервис, чтобы все остальные через него работали с СУБД. В этом нет никакого смысла, если вы и так делите на слои. Абстракция ради абстракции - не Clean Architecture.


      1. vasyakolobok77
        00.00.0000 00:00
        +4

        Каюсь, из поста не понял, что это разные сущности, одинаковые имена ввели в заблуждение. Вообще, если работаем по чистой архитектуре, ваш домен должен быть центральным местом приложения, вокруг которого добавляются feature, и коллизий имен быть не должно.

        Другой момент, идею с ValueObject вы возвели в абсолют и все поля бизнес-объектов сделали через классы обертки. Но ваши классы обертки лишены логики, особого смысла в них нет. ValueObject-ом стоит делать то, что имеет логику обработки, например, Email. Вы можете парировать, что сейчас логики нет, а в будущем обязательно появится, но среды разработки имеют отличные механизмы рефакторинга, и вы легко сможете заменить типы в будущем. Тем более, что в репозитории методы запроса у вас принимают не ValueObject-ы, а внутреннее их содержимое (int, string и т.п.).


    1. anny_anny Автор
      00.00.0000 00:00

      UseCase и Интеракторы так же приведены в статье, и они так же есть в репозитории https://github.com/annysmolyan/symfony-ddd-clean-architecture-blog/tree/main/source/src/CategoryFeature/Domain/Interactor (если мы рассматриваем CategoryFeature), Для DoctrineDataFeature я не использовала интеракторы/usecase, тк это просто хранилище данных и вся основная логика должна быть в CategoryFeature, а не датаСтораджФичи. Конечно, вы можете ввести интеракторы и в датаСторадж фиче, если считаете нужным. Я этого для данного примера не делала


  1. q_shaman_p
    00.00.0000 00:00
    +5

    Теория прям хорошая, отдельный плюс за то что написала разницу между юзкейсами и интеракторами(хотя в реализации почему-то противоречит своему определению, и реализует anemic-service), практика на мой взгляд переусложнена. В центре стоит некоторый менеджер, который является централизованным местом управления (что является анти-паттерном по Эвансу), и за счет разделения модулей кода "по фичам", эти модули будут стихийно появляться, исчезать, переезжать друг к другу, и будет вечная путаница, где искать нужный кусок кода. К какой же фиче оно относится в текущий момент архитектурного видения проекта?
    Классическая заявка на оверинженеринг в примере "а вдруг мы перейдем с доктрины на csv".


  1. BasicWolf
    00.00.0000 00:00
    +2

    @anny_anny, отличная и очень объёмная работа!
    Есть несколько замечаний, надеюсь не будут лишними. Выше в комментариях @vasyakolobok77 спросил у вас про анемичные доменные модели. Вам не кажется, что ваши доменные модели противоречат написанному в статье? Ведь если всё что в есть в доменной модели - это геттеры и сеттеры, в чём её смысл? Если всё что нужно - передавать данные от API до Data-layer'a и обратно, не проще было бы связать контроллер напрямую с портом данных? Вы при этом ничего не нарушаете, т.к. зависимости остаются направленными в центр!

    Кстати, когда программисты осваивают DDD и Гексагональную архитектуру, анемичные модели и тонны маппингов встречаются очень часто! А знаете почему? Сейчас скажу страшную вещь - DDD вообще не имеет никакого отношения к Гексагональной архитектуре!.

    Вы наверняка читали статью Алистара Кокбёрна, которая популяризовала этот термин. Кокбёрн упоминает "presentation layer" единожды, в контексте "слоя API". Но ВСЁ, что лежит по внутреннюю сторону портов называется "Application". Без каких-либо application layer, domain layer, infrastructure layer и т.д. Просто DDD очень удачно вписывается в Гексагональную архитектуру - отсюда и вытекает идея, что они идут рука в руку (я кстати сам до недавных пор был в этом абсолютно уверен!).


    1. michael_v89
      00.00.0000 00:00
      +1

      Ведь если всё что в есть в доменной модели — это геттеры и сеттеры, в чём её смысл?

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


      1. BasicWolf
        00.00.0000 00:00

        - Ну уж извините, Паганель! - вмешался майор. - Вы никогда не заставите меня поверить, что дикие звери полезны. Какая от них польза?
        - Какая польза? - воскликнул Паганель. - Да хотя бы та, что они необходимы и для классификации: все эти разряды, семейства, роды, виды...
        (Жюль Верн, "Дети капитана Гранта").

        Если у вашей сущности нет поведения, то это либо Value-object, либо анемичная Entity-object, у которой и с которой не проводятся бизнес-действия. Если такая модель служит проводящей прослойкой между публичным API и SPI/driven-портами - нет в ней смысла.


        1. michael_v89
          00.00.0000 00:00
          +1

          либо анемичная Entity-object

          Естественно, я же отвечал на комментарий про них.


          с которой не проводятся бизнес-действия

          Это неправда. Заказ можно создать независимо от того, анемичная у вас модель или нет. Создание заказа это бизнес-действие.


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


          Если у вашей сущности нет поведения

          А почему вы считаете, что "поведение" это именно методы внутри сущности?
          "Поведение" это изменение свойств при сохранении identity. Если у вас заказ при каждом изменении будет получать новый id, то это не будет поведением заказа, даже если бизнес-логика будет в классе сущности.


          нет в ней смысла

          Я написал, в чем смысл, в предыдущем комментарии. Непонятно, почему вы игнорируете аргументы и повторяете одно и то же.


          Также ответьте на такой вопрос. У нас есть бизнес-требование "После создания заказа отправить письмо на электронную почту пользователя". Раз это бизнес-требование, значит это часть бизнес-логики. Как вы будете отправлять его из сущности, как будете пробрасывать зависимости, которые это делают?


          1. abyrvalg
            00.00.0000 00:00

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


            1. michael_v89
              00.00.0000 00:00

              Задача сущности, а точнее, агрегата, оперирующего своими сущностями — соблюсти целостность и непротиворечивость данных.

              Эм, нет. Когда мы при анализе предметной области выделяем сущности, мы не думаем про целостность и непротиворечивость данных, мы просто выделяем набор ее свойств.
              Про агрегаты как раз хорошее уточнение. То есть инварианты для OrderItem могут соблюдаться в другом классе Order, вне сущности OrderItem. Почему тогда инварианты для сущности Order не могут быть вне неё, в OrderService?


              Для разных сценариев могут быть разные требования. Например админу в админке можно не заполнять какие-то поля, а пользователю нельзя. Если все возможные действия во всех вариациях пихать в сущность, она превращается в God-object.


              Если вам нужны какие-то побочные действия по результату создания заказа — киньте событие.

              Тогда бизнес-логика будет не только в сущности. Почему бизнес-требование "При создании отправить письмо" может быть реализовано вне сущности, а бизнес-требование "При создании установить текущую дату создания" не может? Они оба относятся к бизнес-логике.


              Если другому домену оно интересно, он его поймает

              Это не другой домен, это тот же самый домен заказов. Отправка письма это часть бизнес-требований по созданию заказа, а не требований к работе какого-то другого компонента.


          1. BasicWolf
            00.00.0000 00:00

            Заказ можно создать независимо от того, анемичная у вас модель или нет. Создание заказа это бизнес-действие.
            Бумажный документ сам себя не заполняет и не проверяет правильность своего заполнения.

            Интересное замечание. Но ведь "создание заказа" - это совершенно разные операции в зависимости от контекста. Рассмотрим модель, в которой "созданый заказ" означает, что клиент заказал товар, но пока его не оплатил. Если клиент захочет изменить заказ - добавить товар, неужто вы напишите что-то вроде

            client.add_item_to_order(order, item)
            

            Или, всё-таки

            order.add_item(item)
            

            А почему вы считаете, что "поведение" это именно методы внутри сущности?

            Потому что за поведением гораздо проще наблюдать и управлять, когда оно описано рядом с данными, от которых и которые от него зависят. Если же оно раскидано по коду, то будет гораздо сложнее понять поведение модели в целом, да и от инкапсуляции данных не останется и следа:

            class Client:
                def add_item_to_order(self, order, item):
                    order.items.append(item)  # ой, а с чего это вдруг Client знает о внутренностях Order?
            

            Вы говорите о типизации - я согласен! Но ведь типизация может быть утиной. Она может быть описана в интерфейсе. Мой же комментарий, в первую очередь - об избыточности Гексагональной архитектуры и DDD в приложении типа "Что получил в POST , то и записал в базу данных, что лежит в базе данных, то и вернул в GET".

            У нас есть бизнес-требование "После создания заказа отправить письмо на электронную почту пользователя". Как вы будете отправлять его из сущности, как будете пробрасывать зависимости, которые это делают?

            События домена. Что-то вроде:

            order, domain_events = shopping_cart.create_order()
            event_dispatcher.dispatch(domain_events)
            

            Здесь, domain_events будет содержать одно (или более!) событий:

            [OrderCreatedEvent(id, client_id, items), ...]
            


            1. michael_v89
              00.00.0000 00:00
              +1

              Если клиент захочет изменить заказ — добавить товар, неужто вы напишите что-то вроде
              client.add_item_to_order(order, item)
              order.add_item(item)

              Нет) Ни то, ни другое. Действие по добавлению товара в заказ совершает не пользователь. Пользователь лишь отправляет запрос на добавление. Без компьютерной системы он бы позвонил оператору или подошел к продавцу и попросил "Давайте добавим этот товар". А продавец бы сказал "Ок" или "Извините, не получится, заказ уже учтен в журнале заказов". При этом это не решение самого продавца, он лишь выполняет утвержденные инструкции по добавлению товара в заказ. Их может выполнять любой другой продавец, который их изучил. Вот эта абстрактная инструкция это и есть метод сервиса. Поэтому я напишу вот так:


              orderService.addItem(order, product)

              Или вернее даже так:


              addItemCommand = AddItemCommand.fromRequest(request);
              validate(addItemCommand);
              orderService.addItem(order, addItemCommand);

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

              Не понимаю, как из этого следует, что поведение это методы внутри сущности.
              С тем, что валить всё в кучу это удобно, я не согласен, ну да ладно.


              Если же оно раскидано по коду, то будет гораздо сложнее понять поведение модели в целом

              Но нам как правило не надо понимать поведение в целом, нам надо понимать поведение в каком-то конкретном действии. В том и смысл, если другому программисту понадобится добавить новое действие бизнес-логики с новыми правилами, он его просто добавит, ему не надо изучать весь код модели и проверять, не сломает ли он чего.


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


              ой, а с чего это вдруг Client знает о внутренностях Order?

              Ни с чего, в подходе с анемичной моделью так не пишут, бизнес-логики нет ни в каких сущностях. Логика добавления товара в заказ находится в OrderService. При этом там вполне можно написать и так:


              order.addItem(item)
              entityManager.save(order);

              Тут вот кстати появляется вопрос, где в вашем подходе запускать транзакцию и вызывать entityManager.save(). Не в контроллере же, неужели тоже в сущности?


              да и от инкапсуляции данных не останется и следа

              Ага, тут появляется другой момент. Инкапсулировать (в смысле скрывать) от вызывающего кода нужно детали реализации. Но сам по себе набор свойств сущности это не детали реализации. Детали реализации это например когда вы наружу выставляете int, а внутри сущности храните его как this.data[6,7,8,9]. Если бы свойства сущности были ее деталями реализации, вы бы никогда их не обнаружили при анализе предметной области.


              Если вы помещаете бизнес-логику внутрь сущности, вы смешиваете высокоуровневую логику и детали реализации свойств сущности. То есть в реализации бизнес-действия вы не сможете писать this.some_id, вы должны будете писать this.data[6,7,8,9].


              Но ведь типизация может быть утиной. Она может быть описана в интерфейсе.

              Я не очень понимаю, как это связано с вопросом "Зачем нужны анемичные сущности". Зачем ее делать утиной, когда можно сделать нормальной.


              События домена. Что-то вроде:

              Ага, я ждал такой ответ. Тогда бизнес-логика создания будет не только в сущности. Почему бизнес-требование "При создании отправить письмо" может быть реализовано вне сущности, а бизнес-требование "При создании установить текущую дату создания" не может?


              1. BasicWolf
                00.00.0000 00:00

                orderService.addItem(order, addItemCommand);

                Отлично! Копнём на один уровень глубже - что же будет происходить в этом методе? И вы сами отвечаете

                При этом там вполне можно написать и так:

                order.addItem(item)
                entityManager.save(order);
                

                Так я и о том же. Сервис (application service, не domain service) не несёт в себе бизнес-логики. Он связывает сущности и остальную инфраструктуру, но он не несёт в себе бизнес правил.

                Тут вот кстати появляется вопрос, где в вашем подходе запускать транзакцию и вызывать entityManager.save(). Не в контроллере же, неужели тоже в сущности?

                В Application Service, который реализует определённые use case-ы. T.e.:

                class OrderService(
                    AddItemToOrderUseCase,
                    ...
                )
                    get_or_create_order: GetOrCreateOrderPort
                    save_order: SaveOrderPort
                
                    @transactional
                    def add_item_to_order(self, order_id, item):
                        order = self.get_or_create_order(order_id)
                        order.add_item(item)
                        self.save_order(order)
                

                Но сам по себе набор свойств сущности это не детали реализации

                А что же? Если сервис будет напрямую модифицировать поля объекта - значит от уже завязан на их реализацию и наоборот.

                Почему бизнес-требование "При создании отправить письмо" может быть реализовано вне сущности, а бизнес-требование "При создании установить текущую дату создания" не может?

                Потому что "создание" в первом и втором случае - разные вещи. Я предполагаю, что письмо можно отправить лишь после того, как "заказ будет принят", т.е. будет тем или иным образом сохранён в системе. Значит "при создании заказа отправить письмо" - сформулировано не верно и надо обновлять Ubiquitous Language на который опираются все участники разработки продукта :)


                1. michael_v89
                  00.00.0000 00:00
                  +1

                  Сервис (application service, не domain service) не несёт в себе бизнес-логики.

                  Я говорю про domain service, он вызывается из контроллера. Application service в этой схеме не нужен.


                  В Application Service, который реализует определённые use case-ы.

                  Так это уже реализация бизнес-логики. И email оттуда же можно отправлять. То есть ваша версия от amenic model отличается только расположением сеттеров, все остальные важные действия находятся вне сущности, в том числе действия с несколькими сущностями, которые надо выполнить в транзакции.


                  Если сервис будет напрямую модифицировать поля объекта — значит от уже завязан на их реализацию и наоборот.

                  Он завязан на сущность, на то он и сервис с бизнес-логикой, но не на реализацию. Он не знает, что вы внутри храните свойство some_id как this.data[6,7,8,9] или как this.data['some_id'], он будет обращаться к свойству order.some_id. Или к геттеру order.getSomeId() если в вашем языке нельзя настроить обработчик обращения к свойству.


                  Потому что "создание" в первом и втором случае — разные вещи.

                  Нет, на уровне бизнес-требований это одна вещь, порядок создания заказа.


                  и надо обновлять Ubiquitous Language на который опираются все участники разработки продукта

                  Вот как раз в том и дело, что на уровне бизнеса это называется "Отправить письмо при создании заказа". Бизнес не знает, что вы там куда сохраняете, для него это часть процесса создания заказа.


                  Я предполагаю, что письмо можно отправить лишь после того, как "заказ будет принят"

                  Конечно, сначала сохраняем, потом отправляем письмо. Но это все равно логика создания заказа. Можно еще отправлять сообщение в другую систему, при чем только при определенных условиях, например если заказаны предметы определенной категории. Это тоже обсуждается на уровне бизнес-требований. Поэтому вынесение этого в обработчик сообщений это размазывание бизнес-логики по разным классам.


                  1. BasicWolf
                    00.00.0000 00:00

                    Я говорю про domain service, он вызывается из контроллера. Application service в этой схеме не нужен.

                    С точки зрения гексагональной архитектуры вы жёстко связываете адаптер и application. Адаптер должен общаться с приложением через интерфейс.

                    Так это уже реализация бизнес-логики. И email оттуда же можно отправлять.

                    Почти. Application Service - это координатор, он скорее дирижирует бизнес-логикой.

                    Он завязан на сущность, на то он и сервис с бизнес-логикой, но не на реализацию.

                    Говоря о реализации я имею ввиду, что сервису приходится напрямую связываться с полем. Но эту связь можно опустить, если у модели будут методы, отражающие бизнес-операции.

                    Нет, на уровне бизнес-требований это одна вещь, порядок создания заказа.

                    Вот, а теперь вы говорите о "порядке" создания заказа. Значит "создание заказа" состоит из нескольких шагов, и отправка письма - один из таких шагов. А первый шаг, пусть будет называться "сохранение заказа", или "запись заказа в книгу заказов". Но это - не одна сплошная операция, а связанная последовательность операций (если хотите - Сага).

                    Поэтому вынесение этого в обработчик сообщений это размазывание бизнес-логики по разным классам.

                    Безусловно, пихать всё в один класс - ещё то извращение. Технически реализуемо - можно, если передавать функцию отправки письма из Application Service-a в доменную модель или сервис. У Хорикова на эту тему хорошая статья Domain model purity vs. Domain model completeness.

                    Но я всё-таки вернусь к дискуссии об использовании Domain Service вкупе с анемичной моделью против богатой доменной модели. Не поленился заглянуть в Синюю книгу. Вот что пишет Эванс:

                    It can be harder to distinguish application SERVICES from domain SERVICES. The application layer is responsible for ordering the notification. The domain layer is responsible for determining if a threshold was met— though this task probably does not call for a SERVICE, because it would fit the responsibility of an "account" object.

                    Здесь, как видите, Эванс явно указывает на реализацию логики в доменной модели, а не в сервисе. Однако далее:

                    On the other hand, a feature that can transfer funds from one account to another is a domain SERVICE because it embeds significant business rules (crediting and debiting the appropriate accounts, for example) and because a "funds transfer" is a meaningful banking term. In this case, the SERVICE does not do much on its own; it would ask the two Account objects to do most of the work. But to put the "transfer" operation on the Account object would be awkward, because the operation involves two accounts and some global rules.

                    Сервис домена задействуется по той причине, что "проводка" проходит между двумя счетами и при этом используются какие-то глобальные правила, за которые каждый из счетов по-отдельности не отвечает.

                    Этого показалось недостаточно, пришлось открыть Красную книгу.

                    Under what conditions would an operation not belong on an existing Entity or Value Object? It is difficult to give an exhaustive list of reasons, but I’ve listed a few here. You can use a Domain Service to:
                    * Perform a significant business process
                    * Transform a domain object from one composition to another
                    * Calculate a Value requiring input from more than one domain object
                    ...
                    It’s a very common one, and that kind of operation can require two, and possibly many, different Aggregates or their composed parts as input. And when it is just plain clumsy to place the method on any one Entity or Value, it works out best to define a Service.

                    Это в целом то же, что говорил Эванс. Только Вернон явно указывает на when it is just plain clumsy to place the method on any one Entity.... Clumsy - неуклюжий, неловкий, неповоротливый, топорный, но для кода, мы скорее скажем "неуместный".
                    Т.е. по Вернону писать

                    orderService.addItem(order, addItemCommand);
                    

                    имеет смысл тогда, когда

                    order.addItem(item)
                    

                    - неуместно.

                    И здесь мы расходимся во мениях. Ну чтож, плюрализм мнений - благо! Засим откланяюсь.


                    1. michael_v89
                      00.00.0000 00:00

                      Адаптер должен общаться с приложением через интерфейс.

                      На практике проброс какого бы то ни было сервиса в контроллер по интерфейсу создает только сложности в поддержке.
                      Проблем с пробросом сервиса с бизнес-логикой в контроллер в моей практике никогда не было.


                      Application Service — это координатор, он скорее дирижирует бизнес-логикой.

                      По вашему описанию это выглядит как лишняя сущность. Бизнес-логика это набор конкретных действий "Делаем это, делаем это, делаем то". Тот код, где они перечисляются, и есть бизнес-логика. Делать для него еще какие-то обертки нет необходимости.


                      Говоря о реализации я имею ввиду, что сервису приходится напрямую связываться с полем.

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


                      Но эту связь можно опустить, если у модели будут методы, отражающие бизнес-операции.

                      Я про это писал, в реализации этих методов у вас будет смешивание разных уровней абстракции — абстрактная бизнес-логика и детали реализации свойств сущности.


                      Значит "создание заказа" состоит из нескольких шагов, и отправка письма — один из таких шагов.

                      Конечно, я про это сразу написал.
                      "После создания заказа отправить письмо на электронную почту пользователя".


                      Но это — не одна сплошная операция, а связанная последовательность операций

                      Да я вроде и не говорил, что это одна сплошная операция. Вы спорите о чем-то своем.
                      В бизнес-требованиях описаны шаги, в коде содержится модель этих шагов. Есть инструкция "Создание заказа", в коде есть метод "createOrder", в инструкции указаны шаги "Сделать это, сделать то", в реализации метода "createOrder" есть вызовы "doThis(); doThat();". Это бизнес-логика создания заказа. Есть шаг "сохранить данные в базу", есть "отправить письмо". Детали реализации шага "сохранить данные в базу" это уже не бизнес-логика, у бизнеса нет требований какие шаги надо делать для сохранения данных, он в этом не разбирается.


                      Безусловно, пихать всё в один класс — ещё то извращение.

                      Не в один класс, а в один метод. Все шаги по созданию заказа должны вызываться из одной точки входа. А детали реализации этих шагов вы вполне можете разнести по разным классам. Но в коде будет место, где они все перечислены, как в инструкции. Поменялась инструкция — ищем точку входа, меняем в соответствии с инструкцией. Не надо лазить по всему коду и искать обработчики событий.
                      Более того, с бизнес-логикой в сервисах нет требования иметь один сервис для сущности. Для пользовательской части будет один сервис, для админки другой, с разными входными данными и разными правилами валидации. Требования для редактирования заказа пользователем и администратором совершенно разные, нет смысла их пихать в один класс сущности. Админку и сайт могут разрабатывать разные команды, и им не надо будет разбираться в деталях реализации друг друга.


                      У Хорикова на эту тему хорошая статья

                      У него там описана некая "trilemma", где все варианты имеют недостатки, и появляется она из-за того что надо пробрасывать зависимости в сущность. А с анемичной моделью она решается очень просто — надо просто решить, что границы домена это сервисы, а не модели, и контроль за состоянием сущности находится в них. Тогда модели остаются чистыми, а зависимости пробрасываются только в конструктор сервиса и являются деталями реализации бизнес-логики, а не частью интерфейса бизнес-методов.


                      Сервис домена задействуется по той причине, что "проводка" проходит между двумя счетами и при этом используются какие-то глобальные правила, за которые каждый из счетов по-отдельности не отвечает.

                      Вот как раз существование таких ситуаций и показывает, что логика в классах сущностей это неправильный подход. "Логика в сервисах" покрывает все возможные ситуации, а "логика в сущностях" не все.
                      Любое бизнес-действие это глобальное правило, оно утверждается бизнесом и существует просто как инструкция, не привязанная к какой-то сущности.


                      1. BasicWolf
                        00.00.0000 00:00

                        На практике проброс какого бы то ни было сервиса в контроллер по интерфейсу создает только сложности в поддержке.
                        Проблем с пробросом сервиса с бизнес-логикой в контроллер в моей практике никогда не было.

                        А если, один и тот же use case вызывается не только HTTP контроллером, но например по сообщению из очереди, или через определённые интервалы?
                        И как вы тестируете подобную конструкцию? Как изолируете и замещаете (mocking) слои в юнит-тестах?

                        По вашему описанию это (Application service) выглядит как лишняя сущность. Бизнес-логика это набор конкретных действий "Делаем это, делаем это, делаем то". Тот код, где они перечисляются, и есть бизнес-логика. Делать для него еще какие-то обертки нет необходимости.

                        Да ну? Надо срочно Вернону передать, чего это он надумал об Application Service-ах и говорит о них с первых страниц Красной книги? А потом ещё посвящает им целую главу!

                        The Application Services are the direct clients of the domain model. These are responsible for task coordination of use case flows, one service method per flow. When using an ACID database, the Application Services also control transactions, ensuring that model state transitions are atomically persisted.
                        ... Keep Application Services thin, using them only to coordinate tasks on the model.

                        В Синей книге конечно всё куда менее разжёванно, но тем не менее:

                        For example, if the banking application can convert and export our
                        transactions into a spreadsheet file for us to analyze, that export is an application service. There is no meaning of "file formats" in the domain of banking, and there are no business rules involved.
                        On the other hand, a feature that can transfer funds from one account to another is a domain service because it embeds significant business rules.

                        Но ещё лучше об этом написано в "другой Красной книге", ака Patterns, Principles and Practices of Domain-Driven Design [Skott Millet, Nick Tune]. Глава 25я, "Commands: Application Service Patterns for Processing Business Use Cases":

                        As a starting point, you can think of application services as having two general responsibilities. First, they are responsible for infrastructural concerns: managing transactions, sending e‐mails, and similar technical tasks. In addition, application services have to coordinate with the domain to carry out full business use cases. Carrying out these responsibilities correctly helps prevent domain logic from being obfuscated or incorrectly located in application services.

                        Хм, тут я немного засомневался, может мы просто говорим об одном и том же, но называем их разными именами? Или вы всё-таки говорите о HTTP-контроллерах?

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

                        Ах вот оно что! Но доступно для чего? Для каких целей? Мне не хочется, чтобы кто угодно мог писать что попало в мои поля. Я здесь не о геттерах и сеттерах, а допустим о коллекции Order.items у которой тип ArrayList и которую вы предлагаете выставлять напоказ. Где гарантии, что туда не запишут какую-нибудь белиберду? А ведь это можно сделать, т.к.
                        Order.items.add(...) - и здесь нет ни одного инварианта.
                        НО! При этом я бы оставил публичное поле (геттер) Order.items, возвращающий read-only коллекцию.

                        Да я вроде и не говорил, что это одна сплошная операция.

                        Пардон, невнимательно читал.

                        Это бизнес-логика создания заказа. Есть шаг "сохранить данные в базу", есть "отправить письмо"

                        Отправить письмо - да, бизнес-логика. Сохранить данные - да. "В базу" - реализация :) И да, это всё один use case.

                        Тогда модели остаются чистыми, а зависимости пробрасываются только в конструктор сервиса и являются деталями реализации бизнес-логики, а не частью интерфейса бизнес-методов.

                        Я вспомнил эту статью всвязи с

                        Inject out-of-process dependencies into the domain model — Keeps performance and domain model completeness, but at the expense of domain model purity.

                        Т.е. теоретически модель может также отвечать за отправку писем, причём напрямую, а не через события. Для этого достаточно передать ей функцию/коллбэк "отправь письмо".

                        def create_order(email_sender: Function):
                            ...
                            email_sender.send("Congratulations, your order have been created!")
                        

                        Выглядит вырвиглазно.

                        Вот как раз существование таких ситуаций и показывает, что логика в классах сущностей это неправильный подход. "Логика в сервисах" покрывает все возможные ситуации, а "логика в сущностях" не все.

                        Вот вы прочли статью того же Хорикова. При всей многогранности и сложности, Domain Service не предлагается как решение в принципе (хотя в обсуждении к статье спрашивают, почему бы их не использовать? http://disq.us/p/2b0xejg)

                        Но ладно Хориков. Во ВСЕХ книгах, приведённых выше, анемичная модель рассматривается как негативная практика. Исключение делается в функциональном подходе, но не в ООП.

                        Тот же Миллет:

                        A common opinion that many DDD practitioners share is that entities should be behavior oriented. This means that an entity’s interface should expose expressive methods that communicate domain behaviors instead of exposing state. More generally, this is closely related to the OOP principle of “Tell Don’t Ask.”

                        A Фаулер написал на эту тему ещё в 2003м году: https://www.martinfowler.com/bliki/AnemicDomainModel.html


                      1. michael_v89
                        00.00.0000 00:00

                        А если, один и тот же use case вызывается не только HTTP контроллером, но например по сообщению из очереди

                        Ну а в чем тут проблема, пробрасываем этот же сервис в обработчик сообщений и вызываем.


                        И как вы тестируете подобную конструкцию?

                        Я пишу на PHP, там не нужен интерфейс чтобы замокать класс. $this->createMock(SomeService::class).


                        Надо срочно Вернону передать

                        Я сам специалист в области программирования, и могу быть не согласен с Верноном. Значения имеют аргументы, а не кто именно сказал что-то.


                        When using an ACID database, the Application Services also control transactions

                        Я транзакции помещаю в сервис, который называю "сервис с бизнес-логикой", возможно он имеет в виду такие сервисы. Тогда непонятно, зачем нужен дополнительно еще Domain Service. Вы имеете в виду, что он будет вызываться между запуском и коммитом транзакции? Если мы запустили транзакцию, мы должны точно знать, что происходит внутри нее, то есть реализацию. Неправильно вызывать метод какого-то интерфейса, может там реализация в сеть лезет.


                        Или вы всё-таки говорите о HTTP-контроллерах?

                        Контроллеры это не сервисы. Контроллеры преобразовывают HTTP-запрос в runtime-данные для вызова сервиса, и runtime-данные результата сервиса в HTTP-ответ.


                        Но доступно для чего? Для каких целей?

                        Для обращения к ним в бизнес-логике, на чтение и на запись.


                        Мне не хочется, чтобы кто угодно мог писать что попало в мои поля.

                        Почему кто угодно? Будет писать только сервис с бизнес-логикой.


                        Где гарантии, что туда не запишут какую-нибудь белиберду?

                        Гарантии находятся в сервисе с бизнес-логикой. Критерии "белиберда или нет" зависят от use-кейса, а не от сущности. А если пихать все use-кейсы в сущность, она превратится в God-object.


                        Сервис пишете вы, другие должны использовать этот сервис. Либо они пишут свой сервис для своих use-кейсов, и тогда у них есть свои требования к заполнению сущности, которые вы заранее знать не могли.


                        Отвечая на ваш незаданный вопрос "А что мешает создать сущность напрямую помимо сервиса?", который обычно задают. Ничего не мешает, так же как ничего не мешает другому программисту скопировать ваш класс сущности, поменять там правила соблюдения инвариантов, и сохранять в базу его вместо вашей сущности. В обоих случаях это можно обнаружить только на код-ревью.


                        Т.е. теоретически модель может также отвечать за отправку писем, причём напрямую

                        Я это понял, именно поэтому и задал этот вопрос. С логикой в сущностях нет нормального удобного решения, и статья Хорикова это подтверждает. Можно, но есть разные проблемы, которых нет с логикой в сервисах.


                        Во ВСЕХ книгах, приведённых выше, анемичная модель рассматривается как негативная практика.

                        Там вроде бы обычно почему-то предполагается, что раз логика не в моделях, то она находится в контроллерах или еще непонятно где, и все аргументы строятся вокруг этого.
                        Проверил у Вернона, он в главе про службы почему-то пишет, что клиент там должен что-то знать, непонятно что за клиент, а потом предлагает использовать сервис


                        authenticationService().authenticate(...)

                        И дальше приводит реализацию DefaultEncryptionAuthenticationService, который и есть пример сервиса, про которые я говорю, и который я предлагаю пробрасывать в контроллер. И там же он кстати говорит, что интерфейс для него совсем необязателен.


                        A Фаулер написал на эту тему ещё в 2003м году

                        У него там какие-то общие слова без конкретики "They incur all of the costs of a domain model, without yielding any of the benefits", "By pulling all the behavior out into services, however, you essentially end up with Transaction Scripts, and thus lose the advantages that the domain model can bring.".


                        С логикой в сервисах удобно управлять зависимостями, это главное преимущество.


  1. godzie
    00.00.0000 00:00
    +14

    Труд конечно хороший, но это не DDD. Суть ddd - домен отражен в коде. Могу ли я создать пост в блоге без даты создания? Очевидно нет, и это инвариант - если есть пост то и дата создания у него есть. Отражает ли ваш код этот инвариант? Нет не отражает, я спокойно могу написать new Post и он создастся без даты создания. Поэтому одна из главный концепций DDD - сущности и агрегаты отражают бизнес инварианты, каждый вызов метода может перевести систему из одного (валидного) состояния в другое.


  1. michael_v89
    00.00.0000 00:00
    +7

    Хорошо, что вы интересуетесь такими вещами. Но то, что вы написали, это ужасная неподдерживаемая архитектура. Если вам для 2 сущностей нужно столько классов, что будет в приложении где их 2 сотни? Вы просто запутаетесь где что. Возьмем простой пример — сколько классов в этом приложении нужно поменять, чтобы добавить поддержку пагинации для постов и категорий? Учтите, что для пагинации нужен не только массив сущностей для текущей страницы, но и total count.


    все, что находится в домене ни в коем случае не отдается наружу

    Это невозможно. Запрос на просмотр категории должен возвращать данные категории, значит надо их как-то вернуть из слоя бизнес-логики. Неважно, будет ли это DTO или собственно сущность, они все равно будут возвращаться из бизнес-логики.


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


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


    'menuItems' => $this->categoryService->getList(['isActive' => true])

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


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


    1 контроллер = 1 экшен.

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


    DoctrineFeature, FrontFeature

    DoctrineFeature и FrontFeature никак не могут находиться на одном уровне с бизнес-фичами, они относятся к другим слоям. Доктрина это инфраструктурный слой, и "Фронтенд" и "Админка" это фактически отдельные приложения со своими контроллерами. Смысл Доктрины вообще как раз в том, чтобы использовать просто классы, не привязанные ни к чему. Вы можете настроить маппинг полей отдельно в конфиге, и не иметь аннотаций в самом классе.


    Правильный легко поддерживаемый подход выглядит примерно так. Бизнес-логика находится в сервисах, сервис вызывается из контроллера, в сервис из контроллера передается валидированное DTO для этого действия и сущность, с которой нужно сделать действие. Загружать сущность нужно в контроллере, так как для любого действия вам надо вернуть 404 если она не найдена, и 403 если у пользователя нет доступа для редактирования, для чего могут понадобиться значения свойств сущности (например статус). Правильный response code это ответственность контроллера.


    CategoryController
    #[Route('/category', name: 'frontend_post_category_')]
    class CategoryController extends AbstractController
    {
        public function __construct(
            private CategoryService $categoryService,
            private CategoryRepository $categoryRepository,
            private PostRepository $postRepository,
            private CategoryMapper $categoryViewModelMapper,
            private ValidatorInterface $validator,
        ) {}
    
        #[Route('/', name: 'home', methods: ['GET'])]
        public function list(): Response
        {
            $categoryEntityList = $this->categoryService->getList();
            $categoryViewModelList = $this->categoryViewModelMapper->mapEntityList($categoryEntityList);
    
            return $this->render('@frontend_post_templates/category/list.html.twig', [
                'categoryList' => $categoryViewModelList,
                'menuItems' => $categoryViewModelList
            ]);
        }
    
        #[Route('/{slug}', name: 'view', methods: ['GET'])]
        public function view(string $slug): Response
        {
            $category = $this->categoryRepository->getBySlug($slug);
            if (null === $category) {
                throw $this->createNotFoundException();
            }
    
            $categoryViewModel = $this->categoryViewModelMapper->mapEntity($category);
    
            return $this->render('@frontend_post_templates/category/view.html.twig', [
                'category' => $categoryViewModel,
                'menuItems' => $this->categoryService->getList(),
                'postList' => $this->postRepository->getList(['category' => $categoryViewModel->id, 'isPublished' => true]),
            ]);
        }
    
        #[Route('/create', name: 'create', methods: ['GET', 'POST'])]
        public function create(Request $request)
        {
            if ($request->isMethod('GET')) {
                return $this->render('.../create.html.twig');
            }
    
            $validationResult = $this->validator->validate($request, CategoryCreate::class);
            if ($validationResult->hasErrors()) {
                return $this->render('.../create.html.twig', [
                    'validationResult' => $validationResult,
                ]);
            }
    
            /** @var CategoryCreate $categoryCreateRequest */
            $categoryCreateRequest = $validationResult->getDto();
    
            $category = $this->categoryService->create($categoryCreateRequest);
    
            return $this->redirectToRoute('category.view', ['slug' => $category->getSlug()]);
        }
    
        #[Route('/update/{id}', name: 'update', methods: ['GET', 'POST'])]
        public function update(int $id, Request $request)
        {
            if ($request->isMethod('GET')) {
                return $this->render('.../create.html.twig');
            }
    
            $category = $this->findEntity($id);
            $categoryViewModel = $this->categoryViewModelMapper->mapEntity($category);
    
            $validationResult = $this->validator->validate($request, CategoryUpdate::class);
            if ($validationResult->hasErrors()) {
                return $this->render('.../update.html.twig', [
                    'categoryViewModel' => $categoryViewModel,
                    'validationResult' => $validationResult,
                ]);
            }
    
            /** @var CategoryUpdate $categoryUpdateRequest */
            $categoryUpdateRequest = $validationResult->getDto();
    
            $category = $this->categoryService->update($category, $categoryUpdateRequest);
    
            return $this->redirectToRoute('category.view', ['slug' => $category->getSlug()]);
        }
    
        #[Route('/delete/{id}', name: 'delete', methods: ['POST'])]
        public function delete(int $id): Response
        {
            $category = $this->findEntity($id);
    
            $this->categoryService->delete($category);
    
            return $this->redirectToRoute('category.list');
        }
    
        private function findEntity(int $id): Category
        {
            $category = $this->categoryRepository->findOne($id);
            if ($category === null) {
                throw $this->createNotFoundException();
            }
    
            return $category;
        }
    }

    CategoryService
    class CategoryService
    {
        public function __construct(
            private CategoryRepository $categoryRepository,
        ) {}
    
        /**
         * @return Category[]
         */
        public function getList(): array
        {
            $categoryList = $this->categoryRepository->getList(['isActive' => true]);
    
            return $categoryList;
        }
    
        public function create(CategoryCreate $categoryCreateRequest): Category
        {
            $category = new Category();
            $category->setTitle($categoryCreateRequest->title);
            $category->setContent($categoryCreateRequest->content);
            $category->setSlug($categoryCreateRequest->slug);
            $category->setActive($categoryCreateRequest->isActive);
    
            $this->categoryRepository->save($category);
    
            return $category;
        }
    
        public function update(Category $category, CategoryUpdate $categoryUpdateRequest): Category
        {
            $category->setTitle($categoryUpdateRequest->title);
            $category->setContent($categoryUpdateRequest->content);
            $category->setSlug($categoryUpdateRequest->slug);
            $category->setActive($categoryUpdateRequest->isActive);
    
            $this->categoryRepository->save($category);
    
            return $category;
        }
    
        public function delete(Category $category): void
        {
            $this->categoryRepository->delete($category);
        }
    }


    1. IgorAlentyev
      00.00.0000 00:00

      Полностью поддерживаю. Вопрос только - почему в вашем примере список получаете из сервиса а один элемент из репозитрия? Не логичнее ли будет все доставать из сервиса, который в свою очередь работает с репозиторием?


      1. michael_v89
        00.00.0000 00:00

        Это разные слои, разные уровни абстракции. Лучше всего это заметно на примере статей или новостей. У статьи есть счетчик просмотров, поэтому в сервисе будет метод view, который будет его увеличивать.


        public function view(Article $article): Article {
          $article->views++;
          $this->entityManager->save($article);
        
          return $article;
        }

        Но это не то же самое, что получение Article по id. При получении по id счетчик просмотров увеличивать не нужно.


        В целом да, можно сделать вспомогательный метод в сервисе, но надо понимать, что он просто вспомогательный, и если возникают сложности, надо его оттуда убрать. Если делать так всегда, то придется всегда пробрасывать весь сервис с бизнес-логикой в компоненты, где надо просто получить сущность по id. Загрузка данных из хранилища это все-таки ответственность репозитория. Также для разных методов может понадобиться загружать разные связи сущности, чтобы не было N+1, соответственно для них в репозиторий будет передаваться какой-нибудь сложный запрос, а не просто id. Если такую гибкость добавлять в сервис, то он в этой части превратится просто в обертку для репозитория.


        1. IgorAlentyev
          00.00.0000 00:00

          Понял, тут согласен.


  1. alxsad
    00.00.0000 00:00

    Работа с entity manager в репозиториях - плохая практика. Довольно часто бывают случаи, когда надо сохранить несколько сущностей в одной транзакции. Поэтому em->flush() надо перенести в application layer.


  1. underground20
    00.00.0000 00:00

    Теоретическая часть статьи написана неплохо.
    По практической части:
    1. Слишком много кода для такого простого проекта (оверинжиниринг получился)
    2. Подход к именованию не очень удачный
    3. Архитектурные границы поделены неправильно (у вас получилось подобие CRUD, обернутое в слои)
    4. Есть еще и другие проблемы