Pull to refresh

Clean Architecture, DDD, гексагональная архитектура. Разбираем на практике blog на Symfony

Level of difficultyMedium
Reading time91 min
Views64K

Всем привет! Давайте знакомиться ;) Я Аня, и я 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);

и тд.

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

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

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

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

Tags:
Hubs:
Total votes 23: ↑20 and ↓3+23
Comments34

Articles