Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение91 мин
Количество просмотров69K
Всего голосов 23: ↑20 и ↓3+23
Комментарии34

Комментарии 34

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


с которой не проводятся бизнес-действия

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


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


Если у вашей сущности нет поведения

А почему вы считаете, что "поведение" это именно методы внутри сущности?
"Поведение" это изменение свойств при сохранении identity. Если у вас заказ при каждом изменении будет получать новый id, то это не будет поведением заказа, даже если бизнес-логика будет в классе сущности.


нет в ней смысла

Я написал, в чем смысл, в предыдущем комментарии. Непонятно, почему вы игнорируете аргументы и повторяете одно и то же.


Также ответьте на такой вопрос. У нас есть бизнес-требование "После создания заказа отправить письмо на электронную почту пользователя". Раз это бизнес-требование, значит это часть бизнес-логики. Как вы будете отправлять его из сущности, как будете пробрасывать зависимости, которые это делают?

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

Задача сущности, а точнее, агрегата, оперирующего своими сущностями — соблюсти целостность и непротиворечивость данных.

Эм, нет. Когда мы при анализе предметной области выделяем сущности, мы не думаем про целостность и непротиворечивость данных, мы просто выделяем набор ее свойств.
Про агрегаты как раз хорошее уточнение. То есть инварианты для OrderItem могут соблюдаться в другом классе Order, вне сущности OrderItem. Почему тогда инварианты для сущности Order не могут быть вне неё, в OrderService?


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


Если вам нужны какие-то побочные действия по результату создания заказа — киньте событие.

Тогда бизнес-логика будет не только в сущности. Почему бизнес-требование "При создании отправить письмо" может быть реализовано вне сущности, а бизнес-требование "При создании установить текущую дату создания" не может? Они оба относятся к бизнес-логике.


Если другому домену оно интересно, он его поймает

Это не другой домен, это тот же самый домен заказов. Отправка письма это часть бизнес-требований по созданию заказа, а не требований к работе какого-то другого компонента.

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

Отправка письма при неком действии - это типичное бизнес правило уровня приложения, при разделении на слои контроль этого правила собственно и окажется в слое application.

Установка текущей даты при создании сущности - будет относится к уровню домена если нам необходимо гарантировать что у нас не должно быть возможным наличие сущности хранящей в себе дату отличающейся от фактической даты её создания. То есть, например, когда при создании сущности мы не можем передать эту дату на уровень домена извне.

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

Это именно что разные use cases и требования к ним, разрешаются на уровне приложения и никак не связаны с требованиям к модели предметной области. То есть в разных кейсах могут быть разные поля и требования к ним, но они в итоге должны привести к созданию модели удовлетворяющим глобальным требованиям. Если взять предыдущий пример, когда контроль даты создания - требование к модели, то какими бы небыли различными наборы полей в разных кейсах, дату создания задать через них не выйдет.

То есть инварианты для OrderItem могут соблюдаться в другом классе Order, вне сущности OrderItem. Почему тогда инварианты для сущности Order не могут быть вне неё, в OrderService?

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

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

это типичное бизнес правило уровня приложения

Бизнес ничего не знает про уровень приложения и не-приложения. У него есть требования — посчитать суммы по заданным правилам, зарегистрировать заказ в системе, отправить письмо. Для него это требования одного уровня, а требования бизнеса это бизнес-логика. Любые требования бизнеса, которые он сообщает программисту, относятся к работе приложения.


при разделении на слои контроль этого правила собственно и окажется в слое application

Отправка письма есть в бизнес-требованиях, значит это бизнес-логика. А значит при использовании Rich Domain Model вызов процедуры отправки письма должен быть в сущности. Иначе не соблюдается правило, что бизнес-логика должна быть в сущности. Получается, что у меня есть требование "отправить письмо", я открываю метод сущности, а там нет никакой отправки письма. Это и неправильная модель бизнес-требований, и не соблюдение подхода Rich Domain Model.


Установка текущей даты при создании сущности — будет относится к уровню домена… когда при создании сущности мы не можем передать эту дату на уровень домена извне

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


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

Нет никаких глобальных и не-глобальных требований. Есть требования бизнеса, от вас требуется сделать программу, которая работает в соответствии с ними. Всё, что требует бизнес — это бизнес-логика.


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

У бизнеса нет требований отдельно к модели и не к модели, у него есть требования к программе, как она должна работать. Он не разбирается, модель там у вас или еще что-то. Любое требование бизнеса это часть предметной области, программа должна содержать ее корректную модель.


Касательно этого примера, у него вполне может быть требование "Разрешить задавать дату создания в админке". И оно ничем не отличается от "Всегда задавать дату создания автоматически", в обоих случаях это бизнес-правила (предметная область, домен).


если сервис находится там, то технически он может взять на себя контроль какого то инварианта, но скорее всего для этого найдётся место получше

Вот я как раз говорю, что сервис это самое подходящее место, аргументы привожу. "Скорее всего" как-то не выглядит весомым аргументом)

Бизнес ничего не знает про уровень приложения и не-приложения.

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

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

Роберт Мартин пишет про это в чистой архитектуре так: "A use case is a description of the way that an automated system is used. It specifies the input to be provided by the user, the output to be returned to the user, and the processing steps involved in producing that output. A use case describes application-specific business rules as opposed to the Critical Business Rules within the Entities."

Если загрязнять домен требованиями, которые относятся к приложению, то получаем всё те же недостатки God Object просто размазанные по сервисам - грязный, хрупкий, неповоротливый домен с запутанными связями.

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

Вот я как раз говорю, что сервис это самое подходящее место, аргументы привожу. "Скорее всего" как-то не выглядит весомым аргументом)

"Скорее всего" - во первых потому что обычно сервис избыточен, поскольку большинство действий соответствуют одной сущности и идеально выражаются через её методы. Во вторых, сервис будет необходим при комплексных действиях, но не каждый такой случай потребует контроля особых инвариантов, сервис часто будет только работать с методами сущностей которые как и прежде контролируют свои инварианты.

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

У бизнеса есть процессы которые могут работать без программы — это и есть предметная область

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


Бизнес, например, может заказать несколько программ (или подпрограмм), чтоб они работали с одними и теми же сущностями но разным образом.

Только он все равно не знает, где там у вас в этих программах уровень модели, а где уровень приложения. Бизнес свои требования на такие уровни не разделяет. Бизнес-требования это домен, который надо смоделировать. Модель бизнес-правил домена в программе — это бизнес-логика, просто по определению. Поэтому ни к какому другому слою приложения она относиться не может, как и быть разделенной на 2 слоя.


"Отправить письмо после создания заказа" это требование к логике работы программы, алгоритм действий (создать, потом отправить). А вот например "Показывать на этой странице заказ в этом статусе таким-то цветом" или "Отправлять письмо с вот этим дизайном" это уже требование не к логике, а к отображению, и оно может быть реализовано вне слоя бизнес-логики.


Если загрязнять домен требованиями, которые относятся к приложению, то получаем всё те же недостатки God Object просто размазанные по сервисам — грязный, хрупкий, неповоротливый домен с запутанными связями.

Не получаем. Я тоже могу делать утверждения без доказательств.


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

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


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


поскольку большинство действий соответствуют одной сущности и идеально выражаются через её методы

Я уже привел пример бизнес-требования, которое нормально не выражается — отправка письма после создания заказа. Открываем метод сущности, а там письмо не отправляется, а вместо него создается какое-то событие, которое обрабатывается неизвестно где вне сущности.
И другой пример привел — разные требования к админке и пользовательской части. Вот у нас есть метод "editOrder" для пользователя и "editOrder" для админа, с разными правилами и набором полей, а 2 одноименных метода в PHP и многих других языках сделать нельзя. И сущность содержит знания из разных частей приложения, то есть начинает превращаться в God-object. Покажите пожалуйста, как это идеально выражается в вашем подходе.


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

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


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

Я уже привел примеры, почему это не так. Самодостаточность превращается в God-object, а удобства восприятия изначально нет из-за работы с зависимостями. Если хотите возразить, приводите контр-аргументы к аргументам собеседника, а не игнорируйте.


но вот загрязнение этих доменных сервисов побочками, внесение их в домен на ровне с бизнес правилами предметной области

Так, и почему вы решили, что сервисы не должны быть наравне с бизнес правилами предметной области?)
Сервис это прямая модель инструкций, как что-то делать. В бизнес-требованиях есть набор инструкций "Работа с заказом", в нем есть инструкции "Создание заказа", "Редактирование заказа", "Отмена заказа". Сервис OrderService это модель этого набора инструкций, а его методы это модели инструкций на конкретные действия.
А для админки есть другой набор инструкций с другими действиями, и их моделью будет другой OrderService, со своими правилами.


Давайте так. Вот пример того, как это делается с сервисом.


Скрытый текст
namespace App\Order\Service;

class OrderService
{
  public function __construct(
    private DbConnectionInterface $dbConnection,
    private MailerInterface $mailer,
    private OrderRepository $orderRepository,
    private ProductAvailabilityService $productAvailabilityService,
  ) {}

  public function createOrder(CreateOrderForm $createOrderForm, User $user): Order
  {
      $order = $this->createOrderEntity($createOrderForm, User $user);
      $this->saveOrderEntity($order);
      $this->sendEmailToUser($order);

      return order;
  }

  private function createOrderEntity(CreateOrderForm $createOrderForm, User $user): Order
  {
      $order = new Order();

      $order->setShippingAddress($createOrderForm->shippingAddress);
      $order->setUser($user);

      $orderItems = $this->createOrderItems($createOrderForm);
      $order->setOrderItems($orderItems);

      $order->setGrandTotal($this->calculateGrandTotal($orderItems));

      return $order;
  }

  private function saveOrder(Order $order): void
  {
      $this->dbConnection->createTransaction();

      $this->orderRepository->save($order);
      $this->productAvailabilityService->updateAvailableQuantity($order);

      $this->dbConnection->commit();
  }

  private function sendEmailToUser(Order $order): void
  {
    $this->mailer->sendEmail($order->user->email, 'orderEmailTemplate.html', ['order' => $order]);
  }
}

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


$this->saveOrderEntity($order);
$this->sendToOtherBusinessSystem($order);
$this->sendEmailToUser($order);

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


$this->saveOrderEntity($order);
$this->sendToOtherBusinessSystem($order);
$this->sendEmailToUser($order);
if ($this->needToSendEmailToManager()) {
  $this->sendEmailToManager($order);
}

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

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

Интересное замечание. Но ведь "создание заказа" - это совершенно разные операции в зависимости от контекста. Рассмотрим модель, в которой "созданый заказ" означает, что клиент заказал товар, но пока его не оплатил. Если клиент захочет изменить заказ - добавить товар, неужто вы напишите что-то вроде

client.add_item_to_order(order, item)

Или, всё-таки

order.add_item(item)

А почему вы считаете, что "поведение" это именно методы внутри сущности?

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

class Client:
    def add_item_to_order(self, order, item):
        order.items.append(item)  # ой, а с чего это вдруг Client знает о внутренностях Order?

Вы говорите о типизации - я согласен! Но ведь типизация может быть утиной. Она может быть описана в интерфейсе. Мой же комментарий, в первую очередь - об избыточности Гексагональной архитектуры и DDD в приложении типа "Что получил в POST , то и записал в базу данных, что лежит в базе данных, то и вернул в GET".

У нас есть бизнес-требование "После создания заказа отправить письмо на электронную почту пользователя". Как вы будете отправлять его из сущности, как будете пробрасывать зависимости, которые это делают?

События домена. Что-то вроде:

order, domain_events = shopping_cart.create_order()
event_dispatcher.dispatch(domain_events)

Здесь, domain_events будет содержать одно (или более!) событий:

[OrderCreatedEvent(id, client_id, items), ...]
Если клиент захочет изменить заказ — добавить товар, неужто вы напишите что-то вроде
client.add_item_to_order(order, item)
order.add_item(item)

Нет) Ни то, ни другое. Действие по добавлению товара в заказ совершает не пользователь. Пользователь лишь отправляет запрос на добавление. Без компьютерной системы он бы позвонил оператору или подошел к продавцу и попросил "Давайте добавим этот товар". А продавец бы сказал "Ок" или "Извините, не получится, заказ уже учтен в журнале заказов". При этом это не решение самого продавца, он лишь выполняет утвержденные инструкции по добавлению товара в заказ. Их может выполнять любой другой продавец, который их изучил. Вот эта абстрактная инструкция это и есть метод сервиса. Поэтому я напишу вот так:


orderService.addItem(order, product)

Или вернее даже так:


addItemCommand = AddItemCommand.fromRequest(request);
validate(addItemCommand);
orderService.addItem(order, addItemCommand);

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

Не понимаю, как из этого следует, что поведение это методы внутри сущности.
С тем, что валить всё в кучу это удобно, я не согласен, ну да ладно.


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

Но нам как правило не надо понимать поведение в целом, нам надо понимать поведение в каком-то конкретном действии. В том и смысл, если другому программисту понадобится добавить новое действие бизнес-логики с новыми правилами, он его просто добавит, ему не надо изучать весь код модели и проверять, не сломает ли он чего.


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


ой, а с чего это вдруг Client знает о внутренностях Order?

Ни с чего, в подходе с анемичной моделью так не пишут, бизнес-логики нет ни в каких сущностях. Логика добавления товара в заказ находится в OrderService. При этом там вполне можно написать и так:


order.addItem(item)
entityManager.save(order);

Тут вот кстати появляется вопрос, где в вашем подходе запускать транзакцию и вызывать entityManager.save(). Не в контроллере же, неужели тоже в сущности?


да и от инкапсуляции данных не останется и следа

Ага, тут появляется другой момент. Инкапсулировать (в смысле скрывать) от вызывающего кода нужно детали реализации. Но сам по себе набор свойств сущности это не детали реализации. Детали реализации это например когда вы наружу выставляете int, а внутри сущности храните его как this.data[6,7,8,9]. Если бы свойства сущности были ее деталями реализации, вы бы никогда их не обнаружили при анализе предметной области.


Если вы помещаете бизнес-логику внутрь сущности, вы смешиваете высокоуровневую логику и детали реализации свойств сущности. То есть в реализации бизнес-действия вы не сможете писать this.some_id, вы должны будете писать this.data[6,7,8,9].


Но ведь типизация может быть утиной. Она может быть описана в интерфейсе.

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


События домена. Что-то вроде:

Ага, я ждал такой ответ. Тогда бизнес-логика создания будет не только в сущности. Почему бизнес-требование "При создании отправить письмо" может быть реализовано вне сущности, а бизнес-требование "При создании установить текущую дату создания" не может?

orderService.addItem(order, addItemCommand);

Отлично! Копнём на один уровень глубже - что же будет происходить в этом методе? И вы сами отвечаете

При этом там вполне можно написать и так:

order.addItem(item)
entityManager.save(order);

Так я и о том же. Сервис (application service, не domain service) не несёт в себе бизнес-логики. Он связывает сущности и остальную инфраструктуру, но он не несёт в себе бизнес правил.

Тут вот кстати появляется вопрос, где в вашем подходе запускать транзакцию и вызывать entityManager.save(). Не в контроллере же, неужели тоже в сущности?

В Application Service, который реализует определённые use case-ы. T.e.:

class OrderService(
    AddItemToOrderUseCase,
    ...
)
    get_or_create_order: GetOrCreateOrderPort
    save_order: SaveOrderPort

    @transactional
    def add_item_to_order(self, order_id, item):
        order = self.get_or_create_order(order_id)
        order.add_item(item)
        self.save_order(order)

Но сам по себе набор свойств сущности это не детали реализации

А что же? Если сервис будет напрямую модифицировать поля объекта - значит от уже завязан на их реализацию и наоборот.

Почему бизнес-требование "При создании отправить письмо" может быть реализовано вне сущности, а бизнес-требование "При создании установить текущую дату создания" не может?

Потому что "создание" в первом и втором случае - разные вещи. Я предполагаю, что письмо можно отправить лишь после того, как "заказ будет принят", т.е. будет тем или иным образом сохранён в системе. Значит "при создании заказа отправить письмо" - сформулировано не верно и надо обновлять Ubiquitous Language на который опираются все участники разработки продукта :)

Сервис (application service, не domain service) не несёт в себе бизнес-логики.

Я говорю про domain service, он вызывается из контроллера. Application service в этой схеме не нужен.


В Application Service, который реализует определённые use case-ы.

Так это уже реализация бизнес-логики. И email оттуда же можно отправлять. То есть ваша версия от amenic model отличается только расположением сеттеров, все остальные важные действия находятся вне сущности, в том числе действия с несколькими сущностями, которые надо выполнить в транзакции.


Если сервис будет напрямую модифицировать поля объекта — значит от уже завязан на их реализацию и наоборот.

Он завязан на сущность, на то он и сервис с бизнес-логикой, но не на реализацию. Он не знает, что вы внутри храните свойство some_id как this.data[6,7,8,9] или как this.data['some_id'], он будет обращаться к свойству order.some_id. Или к геттеру order.getSomeId() если в вашем языке нельзя настроить обработчик обращения к свойству.


Потому что "создание" в первом и втором случае — разные вещи.

Нет, на уровне бизнес-требований это одна вещь, порядок создания заказа.


и надо обновлять Ubiquitous Language на который опираются все участники разработки продукта

Вот как раз в том и дело, что на уровне бизнеса это называется "Отправить письмо при создании заказа". Бизнес не знает, что вы там куда сохраняете, для него это часть процесса создания заказа.


Я предполагаю, что письмо можно отправить лишь после того, как "заказ будет принят"

Конечно, сначала сохраняем, потом отправляем письмо. Но это все равно логика создания заказа. Можно еще отправлять сообщение в другую систему, при чем только при определенных условиях, например если заказаны предметы определенной категории. Это тоже обсуждается на уровне бизнес-требований. Поэтому вынесение этого в обработчик сообщений это размазывание бизнес-логики по разным классам.

Я говорю про domain service, он вызывается из контроллера. Application service в этой схеме не нужен.

С точки зрения гексагональной архитектуры вы жёстко связываете адаптер и application. Адаптер должен общаться с приложением через интерфейс.

Так это уже реализация бизнес-логики. И email оттуда же можно отправлять.

Почти. Application Service - это координатор, он скорее дирижирует бизнес-логикой.

Он завязан на сущность, на то он и сервис с бизнес-логикой, но не на реализацию.

Говоря о реализации я имею ввиду, что сервису приходится напрямую связываться с полем. Но эту связь можно опустить, если у модели будут методы, отражающие бизнес-операции.

Нет, на уровне бизнес-требований это одна вещь, порядок создания заказа.

Вот, а теперь вы говорите о "порядке" создания заказа. Значит "создание заказа" состоит из нескольких шагов, и отправка письма - один из таких шагов. А первый шаг, пусть будет называться "сохранение заказа", или "запись заказа в книгу заказов". Но это - не одна сплошная операция, а связанная последовательность операций (если хотите - Сага).

Поэтому вынесение этого в обработчик сообщений это размазывание бизнес-логики по разным классам.

Безусловно, пихать всё в один класс - ещё то извращение. Технически реализуемо - можно, если передавать функцию отправки письма из Application Service-a в доменную модель или сервис. У Хорикова на эту тему хорошая статья Domain model purity vs. Domain model completeness.

Но я всё-таки вернусь к дискуссии об использовании Domain Service вкупе с анемичной моделью против богатой доменной модели. Не поленился заглянуть в Синюю книгу. Вот что пишет Эванс:

It can be harder to distinguish application SERVICES from domain SERVICES. The application layer is responsible for ordering the notification. The domain layer is responsible for determining if a threshold was met— though this task probably does not call for a SERVICE, because it would fit the responsibility of an "account" object.

Здесь, как видите, Эванс явно указывает на реализацию логики в доменной модели, а не в сервисе. Однако далее:

On the other hand, a feature that can transfer funds from one account to another is a domain SERVICE because it embeds significant business rules (crediting and debiting the appropriate accounts, for example) and because a "funds transfer" is a meaningful banking term. In this case, the SERVICE does not do much on its own; it would ask the two Account objects to do most of the work. But to put the "transfer" operation on the Account object would be awkward, because the operation involves two accounts and some global rules.

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

Этого показалось недостаточно, пришлось открыть Красную книгу.

Under what conditions would an operation not belong on an existing Entity or Value Object? It is difficult to give an exhaustive list of reasons, but I’ve listed a few here. You can use a Domain Service to:
* Perform a significant business process
* Transform a domain object from one composition to another
* Calculate a Value requiring input from more than one domain object
...
It’s a very common one, and that kind of operation can require two, and possibly many, different Aggregates or their composed parts as input. And when it is just plain clumsy to place the method on any one Entity or Value, it works out best to define a Service.

Это в целом то же, что говорил Эванс. Только Вернон явно указывает на when it is just plain clumsy to place the method on any one Entity.... Clumsy - неуклюжий, неловкий, неповоротливый, топорный, но для кода, мы скорее скажем "неуместный".
Т.е. по Вернону писать

orderService.addItem(order, addItemCommand);

имеет смысл тогда, когда

order.addItem(item)

- неуместно.

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

Адаптер должен общаться с приложением через интерфейс.

На практике проброс какого бы то ни было сервиса в контроллер по интерфейсу создает только сложности в поддержке.
Проблем с пробросом сервиса с бизнес-логикой в контроллер в моей практике никогда не было.


Application Service — это координатор, он скорее дирижирует бизнес-логикой.

По вашему описанию это выглядит как лишняя сущность. Бизнес-логика это набор конкретных действий "Делаем это, делаем это, делаем то". Тот код, где они перечисляются, и есть бизнес-логика. Делать для него еще какие-то обертки нет необходимости.


Говоря о реализации я имею ввиду, что сервису приходится напрямую связываться с полем.

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


Но эту связь можно опустить, если у модели будут методы, отражающие бизнес-операции.

Я про это писал, в реализации этих методов у вас будет смешивание разных уровней абстракции — абстрактная бизнес-логика и детали реализации свойств сущности.


Значит "создание заказа" состоит из нескольких шагов, и отправка письма — один из таких шагов.

Конечно, я про это сразу написал.
"После создания заказа отправить письмо на электронную почту пользователя".


Но это — не одна сплошная операция, а связанная последовательность операций

Да я вроде и не говорил, что это одна сплошная операция. Вы спорите о чем-то своем.
В бизнес-требованиях описаны шаги, в коде содержится модель этих шагов. Есть инструкция "Создание заказа", в коде есть метод "createOrder", в инструкции указаны шаги "Сделать это, сделать то", в реализации метода "createOrder" есть вызовы "doThis(); doThat();". Это бизнес-логика создания заказа. Есть шаг "сохранить данные в базу", есть "отправить письмо". Детали реализации шага "сохранить данные в базу" это уже не бизнес-логика, у бизнеса нет требований какие шаги надо делать для сохранения данных, он в этом не разбирается.


Безусловно, пихать всё в один класс — ещё то извращение.

Не в один класс, а в один метод. Все шаги по созданию заказа должны вызываться из одной точки входа. А детали реализации этих шагов вы вполне можете разнести по разным классам. Но в коде будет место, где они все перечислены, как в инструкции. Поменялась инструкция — ищем точку входа, меняем в соответствии с инструкцией. Не надо лазить по всему коду и искать обработчики событий.
Более того, с бизнес-логикой в сервисах нет требования иметь один сервис для сущности. Для пользовательской части будет один сервис, для админки другой, с разными входными данными и разными правилами валидации. Требования для редактирования заказа пользователем и администратором совершенно разные, нет смысла их пихать в один класс сущности. Админку и сайт могут разрабатывать разные команды, и им не надо будет разбираться в деталях реализации друг друга.


У Хорикова на эту тему хорошая статья

У него там описана некая "trilemma", где все варианты имеют недостатки, и появляется она из-за того что надо пробрасывать зависимости в сущность. А с анемичной моделью она решается очень просто — надо просто решить, что границы домена это сервисы, а не модели, и контроль за состоянием сущности находится в них. Тогда модели остаются чистыми, а зависимости пробрасываются только в конструктор сервиса и являются деталями реализации бизнес-логики, а не частью интерфейса бизнес-методов.


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

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

На практике проброс какого бы то ни было сервиса в контроллер по интерфейсу создает только сложности в поддержке.
Проблем с пробросом сервиса с бизнес-логикой в контроллер в моей практике никогда не было.

А если, один и тот же use case вызывается не только HTTP контроллером, но например по сообщению из очереди, или через определённые интервалы?
И как вы тестируете подобную конструкцию? Как изолируете и замещаете (mocking) слои в юнит-тестах?

По вашему описанию это (Application service) выглядит как лишняя сущность. Бизнес-логика это набор конкретных действий "Делаем это, делаем это, делаем то". Тот код, где они перечисляются, и есть бизнес-логика. Делать для него еще какие-то обертки нет необходимости.

Да ну? Надо срочно Вернону передать, чего это он надумал об Application Service-ах и говорит о них с первых страниц Красной книги? А потом ещё посвящает им целую главу!

The Application Services are the direct clients of the domain model. These are responsible for task coordination of use case flows, one service method per flow. When using an ACID database, the Application Services also control transactions, ensuring that model state transitions are atomically persisted.
... Keep Application Services thin, using them only to coordinate tasks on the model.

В Синей книге конечно всё куда менее разжёванно, но тем не менее:

For example, if the banking application can convert and export our
transactions into a spreadsheet file for us to analyze, that export is an application service. There is no meaning of "file formats" in the domain of banking, and there are no business rules involved.
On the other hand, a feature that can transfer funds from one account to another is a domain service because it embeds significant business rules.

Но ещё лучше об этом написано в "другой Красной книге", ака Patterns, Principles and Practices of Domain-Driven Design [Skott Millet, Nick Tune]. Глава 25я, "Commands: Application Service Patterns for Processing Business Use Cases":

As a starting point, you can think of application services as having two general responsibilities. First, they are responsible for infrastructural concerns: managing transactions, sending e‐mails, and similar technical tasks. In addition, application services have to coordinate with the domain to carry out full business use cases. Carrying out these responsibilities correctly helps prevent domain logic from being obfuscated or incorrectly located in application services.

Хм, тут я немного засомневался, может мы просто говорим об одном и том же, но называем их разными именами? Или вы всё-таки говорите о HTTP-контроллерах?

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

Ах вот оно что! Но доступно для чего? Для каких целей? Мне не хочется, чтобы кто угодно мог писать что попало в мои поля. Я здесь не о геттерах и сеттерах, а допустим о коллекции Order.items у которой тип ArrayList и которую вы предлагаете выставлять напоказ. Где гарантии, что туда не запишут какую-нибудь белиберду? А ведь это можно сделать, т.к.
Order.items.add(...) - и здесь нет ни одного инварианта.
НО! При этом я бы оставил публичное поле (геттер) Order.items, возвращающий read-only коллекцию.

Да я вроде и не говорил, что это одна сплошная операция.

Пардон, невнимательно читал.

Это бизнес-логика создания заказа. Есть шаг "сохранить данные в базу", есть "отправить письмо"

Отправить письмо - да, бизнес-логика. Сохранить данные - да. "В базу" - реализация :) И да, это всё один use case.

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

Я вспомнил эту статью всвязи с

Inject out-of-process dependencies into the domain model — Keeps performance and domain model completeness, but at the expense of domain model purity.

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

def create_order(email_sender: Function):
    ...
    email_sender.send("Congratulations, your order have been created!")

Выглядит вырвиглазно.

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

Вот вы прочли статью того же Хорикова. При всей многогранности и сложности, Domain Service не предлагается как решение в принципе (хотя в обсуждении к статье спрашивают, почему бы их не использовать? http://disq.us/p/2b0xejg)

Но ладно Хориков. Во ВСЕХ книгах, приведённых выше, анемичная модель рассматривается как негативная практика. Исключение делается в функциональном подходе, но не в ООП.

Тот же Миллет:

A common opinion that many DDD practitioners share is that entities should be behavior oriented. This means that an entity’s interface should expose expressive methods that communicate domain behaviors instead of exposing state. More generally, this is closely related to the OOP principle of “Tell Don’t Ask.”

A Фаулер написал на эту тему ещё в 2003м году: https://www.martinfowler.com/bliki/AnemicDomainModel.html

А если, один и тот же use case вызывается не только HTTP контроллером, но например по сообщению из очереди

Ну а в чем тут проблема, пробрасываем этот же сервис в обработчик сообщений и вызываем.


И как вы тестируете подобную конструкцию?

Я пишу на PHP, там не нужен интерфейс чтобы замокать класс. $this->createMock(SomeService::class).


Надо срочно Вернону передать

Я сам специалист в области программирования, и могу быть не согласен с Верноном. Значения имеют аргументы, а не кто именно сказал что-то.


When using an ACID database, the Application Services also control transactions

Я транзакции помещаю в сервис, который называю "сервис с бизнес-логикой", возможно он имеет в виду такие сервисы. Тогда непонятно, зачем нужен дополнительно еще Domain Service. Вы имеете в виду, что он будет вызываться между запуском и коммитом транзакции? Если мы запустили транзакцию, мы должны точно знать, что происходит внутри нее, то есть реализацию. Неправильно вызывать метод какого-то интерфейса, может там реализация в сеть лезет.


Или вы всё-таки говорите о HTTP-контроллерах?

Контроллеры это не сервисы. Контроллеры преобразовывают HTTP-запрос в runtime-данные для вызова сервиса, и runtime-данные результата сервиса в HTTP-ответ.


Но доступно для чего? Для каких целей?

Для обращения к ним в бизнес-логике, на чтение и на запись.


Мне не хочется, чтобы кто угодно мог писать что попало в мои поля.

Почему кто угодно? Будет писать только сервис с бизнес-логикой.


Где гарантии, что туда не запишут какую-нибудь белиберду?

Гарантии находятся в сервисе с бизнес-логикой. Критерии "белиберда или нет" зависят от use-кейса, а не от сущности. А если пихать все use-кейсы в сущность, она превратится в God-object.


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


Отвечая на ваш незаданный вопрос "А что мешает создать сущность напрямую помимо сервиса?", который обычно задают. Ничего не мешает, так же как ничего не мешает другому программисту скопировать ваш класс сущности, поменять там правила соблюдения инвариантов, и сохранять в базу его вместо вашей сущности. В обоих случаях это можно обнаружить только на код-ревью.


Т.е. теоретически модель может также отвечать за отправку писем, причём напрямую

Я это понял, именно поэтому и задал этот вопрос. С логикой в сущностях нет нормального удобного решения, и статья Хорикова это подтверждает. Можно, но есть разные проблемы, которых нет с логикой в сервисах.


Во ВСЕХ книгах, приведённых выше, анемичная модель рассматривается как негативная практика.

Там вроде бы обычно почему-то предполагается, что раз логика не в моделях, то она находится в контроллерах или еще непонятно где, и все аргументы строятся вокруг этого.
Проверил у Вернона, он в главе про службы почему-то пишет, что клиент там должен что-то знать, непонятно что за клиент, а потом предлагает использовать сервис


authenticationService().authenticate(...)

И дальше приводит реализацию DefaultEncryptionAuthenticationService, который и есть пример сервиса, про которые я говорю, и который я предлагаю пробрасывать в контроллер. И там же он кстати говорит, что интерфейс для него совсем необязателен.


A Фаулер написал на эту тему ещё в 2003м году

У него там какие-то общие слова без конкретики "They incur all of the costs of a domain model, without yielding any of the benefits", "By pulling all the behavior out into services, however, you essentially end up with Transaction Scripts, and thus lose the advantages that the domain model can bring.".


С логикой в сервисах удобно управлять зависимостями, это главное преимущество.

Труд конечно хороший, но это не DDD. Суть ddd - домен отражен в коде. Могу ли я создать пост в блоге без даты создания? Очевидно нет, и это инвариант - если есть пост то и дата создания у него есть. Отражает ли ваш код этот инвариант? Нет не отражает, я спокойно могу написать new Post и он создастся без даты создания. Поэтому одна из главный концепций DDD - сущности и агрегаты отражают бизнес инварианты, каждый вызов метода может перевести систему из одного (валидного) состояния в другое.

Хорошо, что вы интересуетесь такими вещами. Но то, что вы написали, это ужасная неподдерживаемая архитектура. Если вам для 2 сущностей нужно столько классов, что будет в приложении где их 2 сотни? Вы просто запутаетесь где что. Возьмем простой пример — сколько классов в этом приложении нужно поменять, чтобы добавить поддержку пагинации для постов и категорий? Учтите, что для пагинации нужен не только массив сущностей для текущей страницы, но и total count.


все, что находится в домене ни в коем случае не отдается наружу

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


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


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


'menuItems' => $this->categoryService->getList(['isActive' => true])

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


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


1 контроллер = 1 экшен.

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


DoctrineFeature, FrontFeature

DoctrineFeature и FrontFeature никак не могут находиться на одном уровне с бизнес-фичами, они относятся к другим слоям. Доктрина это инфраструктурный слой, и "Фронтенд" и "Админка" это фактически отдельные приложения со своими контроллерами. Смысл Доктрины вообще как раз в том, чтобы использовать просто классы, не привязанные ни к чему. Вы можете настроить маппинг полей отдельно в конфиге, и не иметь аннотаций в самом классе.


Правильный легко поддерживаемый подход выглядит примерно так. Бизнес-логика находится в сервисах, сервис вызывается из контроллера, в сервис из контроллера передается валидированное DTO для этого действия и сущность, с которой нужно сделать действие. Загружать сущность нужно в контроллере, так как для любого действия вам надо вернуть 404 если она не найдена, и 403 если у пользователя нет доступа для редактирования, для чего могут понадобиться значения свойств сущности (например статус). Правильный response code это ответственность контроллера.


CategoryController
#[Route('/category', name: 'frontend_post_category_')]
class CategoryController extends AbstractController
{
    public function __construct(
        private CategoryService $categoryService,
        private CategoryRepository $categoryRepository,
        private PostRepository $postRepository,
        private CategoryMapper $categoryViewModelMapper,
        private ValidatorInterface $validator,
    ) {}

    #[Route('/', name: 'home', methods: ['GET'])]
    public function list(): Response
    {
        $categoryEntityList = $this->categoryService->getList();
        $categoryViewModelList = $this->categoryViewModelMapper->mapEntityList($categoryEntityList);

        return $this->render('@frontend_post_templates/category/list.html.twig', [
            'categoryList' => $categoryViewModelList,
            'menuItems' => $categoryViewModelList
        ]);
    }

    #[Route('/{slug}', name: 'view', methods: ['GET'])]
    public function view(string $slug): Response
    {
        $category = $this->categoryRepository->getBySlug($slug);
        if (null === $category) {
            throw $this->createNotFoundException();
        }

        $categoryViewModel = $this->categoryViewModelMapper->mapEntity($category);

        return $this->render('@frontend_post_templates/category/view.html.twig', [
            'category' => $categoryViewModel,
            'menuItems' => $this->categoryService->getList(),
            'postList' => $this->postRepository->getList(['category' => $categoryViewModel->id, 'isPublished' => true]),
        ]);
    }

    #[Route('/create', name: 'create', methods: ['GET', 'POST'])]
    public function create(Request $request)
    {
        if ($request->isMethod('GET')) {
            return $this->render('.../create.html.twig');
        }

        $validationResult = $this->validator->validate($request, CategoryCreate::class);
        if ($validationResult->hasErrors()) {
            return $this->render('.../create.html.twig', [
                'validationResult' => $validationResult,
            ]);
        }

        /** @var CategoryCreate $categoryCreateRequest */
        $categoryCreateRequest = $validationResult->getDto();

        $category = $this->categoryService->create($categoryCreateRequest);

        return $this->redirectToRoute('category.view', ['slug' => $category->getSlug()]);
    }

    #[Route('/update/{id}', name: 'update', methods: ['GET', 'POST'])]
    public function update(int $id, Request $request)
    {
        if ($request->isMethod('GET')) {
            return $this->render('.../create.html.twig');
        }

        $category = $this->findEntity($id);
        $categoryViewModel = $this->categoryViewModelMapper->mapEntity($category);

        $validationResult = $this->validator->validate($request, CategoryUpdate::class);
        if ($validationResult->hasErrors()) {
            return $this->render('.../update.html.twig', [
                'categoryViewModel' => $categoryViewModel,
                'validationResult' => $validationResult,
            ]);
        }

        /** @var CategoryUpdate $categoryUpdateRequest */
        $categoryUpdateRequest = $validationResult->getDto();

        $category = $this->categoryService->update($category, $categoryUpdateRequest);

        return $this->redirectToRoute('category.view', ['slug' => $category->getSlug()]);
    }

    #[Route('/delete/{id}', name: 'delete', methods: ['POST'])]
    public function delete(int $id): Response
    {
        $category = $this->findEntity($id);

        $this->categoryService->delete($category);

        return $this->redirectToRoute('category.list');
    }

    private function findEntity(int $id): Category
    {
        $category = $this->categoryRepository->findOne($id);
        if ($category === null) {
            throw $this->createNotFoundException();
        }

        return $category;
    }
}

CategoryService
class CategoryService
{
    public function __construct(
        private CategoryRepository $categoryRepository,
    ) {}

    /**
     * @return Category[]
     */
    public function getList(): array
    {
        $categoryList = $this->categoryRepository->getList(['isActive' => true]);

        return $categoryList;
    }

    public function create(CategoryCreate $categoryCreateRequest): Category
    {
        $category = new Category();
        $category->setTitle($categoryCreateRequest->title);
        $category->setContent($categoryCreateRequest->content);
        $category->setSlug($categoryCreateRequest->slug);
        $category->setActive($categoryCreateRequest->isActive);

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

        return $category;
    }

    public function update(Category $category, CategoryUpdate $categoryUpdateRequest): Category
    {
        $category->setTitle($categoryUpdateRequest->title);
        $category->setContent($categoryUpdateRequest->content);
        $category->setSlug($categoryUpdateRequest->slug);
        $category->setActive($categoryUpdateRequest->isActive);

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

        return $category;
    }

    public function delete(Category $category): void
    {
        $this->categoryRepository->delete($category);
    }
}

Полностью поддерживаю. Вопрос только - почему в вашем примере список получаете из сервиса а один элемент из репозитрия? Не логичнее ли будет все доставать из сервиса, который в свою очередь работает с репозиторием?

Это разные слои, разные уровни абстракции. Лучше всего это заметно на примере статей или новостей. У статьи есть счетчик просмотров, поэтому в сервисе будет метод view, который будет его увеличивать.


public function view(Article $article): Article {
  $article->views++;
  $this->entityManager->save($article);

  return $article;
}

Но это не то же самое, что получение Article по id. При получении по id счетчик просмотров увеличивать не нужно.


В целом да, можно сделать вспомогательный метод в сервисе, но надо понимать, что он просто вспомогательный, и если возникают сложности, надо его оттуда убрать. Если делать так всегда, то придется всегда пробрасывать весь сервис с бизнес-логикой в компоненты, где надо просто получить сущность по id. Загрузка данных из хранилища это все-таки ответственность репозитория. Также для разных методов может понадобиться загружать разные связи сущности, чтобы не было N+1, соответственно для них в репозиторий будет передаваться какой-нибудь сложный запрос, а не просто id. Если такую гибкость добавлять в сервис, то он в этой части превратится просто в обертку для репозитория.

Понял, тут согласен.

Работа с entity manager в репозиториях - плохая практика. Довольно часто бывают случаи, когда надо сохранить несколько сущностей в одной транзакции. Поэтому em->flush() надо перенести в application layer.

Теоретическая часть статьи написана неплохо.
По практической части:
1. Слишком много кода для такого простого проекта (оверинжиниринг получился)
2. Подход к именованию не очень удачный
3. Архитектурные границы поделены неправильно (у вас получилось подобие CRUD, обернутое в слои)
4. Есть еще и другие проблемы

По поводу вот этого фрагмента статьи - "Domain layer (Repository) - важно! в домене никогда не реализуются репозитории. За реализацию отвечате инфраструктурный слой". Откуда взялась эта странная идея, что интерфейсы слоя Infrastructure layer надо помещать в Domain layer? Уже это встречал неоднократно. Зависимость между слоями направлена сверху вниз. Infrastructure layer в структуре слоёв лежит ниже Domain layer и соответственно объекты Infrastructure layer не смогут увидеть интерфейсы Infrastructure layer, которые описаны в Domain layer. На рисунке в этой статье идёт стрелка от Infrastructure layer к Domain layer, но в таком случае это уже не многослойная архитектура, а какая-то эклектика из слоёв.

Зависимость между слоями направлена сверху вниз. Infrastructure layer в структуре слоёв лежит ниже Domain layer и соответственно объекты Infrastructure layer не смогут увидеть интерфейсы Infrastructure layer, которые описаны в Domain layer

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

В статье на схеме зависимости отображены сплошными стрелками, что там нарисовано выше или ниже - смысловой нагрузки не имеет.

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

Мне кажется в классе PostService SOLID подкачал.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории