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

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

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

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

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

Также с Rich domain model в сущность помещаются все изменяющие ее бизнес-действия. Это приводит к тому, что сущность превращается в God-object, и код получается более сложный в поддержке.

Не превращается - почитайте определение God object. Получается вполне обычный объект, который знает только про себя и контролирует свои инварианты. Если у Вас такая сущность начала разрастаться и затягивать в себя вообще все сущности домена - это говорит только о том, что Вы проигнорировали первое правило агрегатов DDD: делать их как можно меньше.

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

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

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

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

Никакая логика не принадлежит сущности

Этой идее больше лет, чем ООП. Называется: процедурная парадигма программирования. Её плюсы и минусы давно известны, причём минусы достаточно значительны, что и привело к появлению ООП.

Потом кто-то встречает статью Фаулера про Anemic Domain Model, где он говорит, что в сервисах логики быть не должно, и начинаются попытки ее оттуда убрать.

Есть такое, но это просто от непонимания сути вещей и слепого доверия авторитетам. Анемичная модель не является анти-паттерном в общем смысле. Она является анти-паттерном в DDD. Если проект пишется не по DDD, а как Transaction Script, то анемичная модель вполне корректный паттерн.

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

Не в DDD. Когда DDD обсуждает Rich Domain Model то под бизнес-логикой подразумевается исключительно то, что изменяет эти модели. В терминологии CQRS - речь исключительно о командах. Запросы же, которые не изменяют сущность, в DDD рекомендуется делать ровно так же, как и в Transaction Script: грузить прямо из БД специализированными запросами в анемичную модель, сформированную под конкретный use case (т.е. фактически - под текущие нужды UI).

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

См. выше. В данном случае, по DDD, это не бизнес-логика и ей самое место в репозитории.

Свойства сущности это детали ее реализации, которые нужно скрывать.

Речь не о том, что их нельзя прочитать, а о том, что их не должно быть возможности изменить снаружи. Возьмите любой пример из книжки IDDD - там все свойства обычно объявляются { get; private set; }.

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

Ну как это не сможет. Создаем в сущности новый метод, там устанавливаем свойства как нам нужно.

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

Сущность должна сама проверять свои инварианты.

Часто нет таких правил, которые должны соблюдаться во всех сценариях.

Давайте я начну слово, а вы сами его договорите: со-фи-…! Из того, что бывают требования специфичные не для модели, а для конкретного use case, никак не следует отсутствие требований, которые специфичны именно для модели и должны соблюдаться именно всегда. Поэтому одни разумно реализовать методами модели, а другие в доменных сервисах.

Что моделирует сервис?

Может появиться вопрос - если сервис это часть доменного слоя, то что он моделирует?

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

Цель статьи

Полагаю: Chaos, panic and disorder - my work here is done! ©

почитайте определение God object

https://en.wikipedia.org/wiki/God_object

"In object-oriented programming, a god object (sometimes also called an omniscient or all-knowing object) is an object that references a large number of distinct types, has too many unrelated or uncategorized methods, or some combination of both."

Вот про "too many methods" я и говорю. Иначе можно вообще всю логику программы поместить в один класс и сказать, что программа контролирует свои инварианты.

это говорит только о том, что Вы проигнорировали первое правило агрегатов DDD: делать их как можно меньше

А еще надо писать код всегда без ошибок. Это все абстрактные пожелания, вы покажите код. Я же привел пример, есть сущность "Заказ" с 30 статусами, "статус" это одно поле, куда еще меньше-то?)

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

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

Это прекрасный пример софистики.

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

Называется: процедурная парадигма программирования.
что и привело к появлению ООП

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

Она является анти-паттерном в DDD.

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

Когда DDD обсуждает Rich Domain Model то под бизнес-логикой подразумевается исключительно то, что изменяет эти модели.

Вот я и указываю на то, что это некорректно. Бизнес-требования к работе фильтров это такие же бизнес-требования, а их реализация это бизнес-логика.

В данном случае, по DDD, это не бизнес-логика и ей самое место в репозитории.

У бизнес-логики есть общепринятое определение, это не термин из DDD.

Речь не о том, что их нельзя прочитать, а о том, что их не должно быть возможности изменить снаружи.

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

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

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

Поэтому одни разумно реализовать методами модели, а другие в доменных сервисах.

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

Что он моделирует? Ничего. Именно поэтому в DDD

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

Полагаю: Chaos, panic and disorder - my work here is done!

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

набор сеттеров можно поместить в сущность

Уточню, что тут под сеттером я подразумеваю выражение вида $this->name = $name, а не метод setName().

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

Ваши "объекты" это структуры данных и библиотеки процедур. Объект в понимании ООП это объединение состояния и поведения, чего а анемичной модели не наблюдается.

Объект в понимании ООП это объединение состояния и поведения

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

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

Поведение касается деталей реализации объекта. Например, класс какого-нибудь парсера, который хранит внутреннее состояние, которое меняется в процессе работы метода parse(). Или класс для вычисления хеша, который обновляет текущий хеш при поступлении новых данных. Вы не знаете, как он устроен, какие внутренние переменные он хранит, вы получаете только финальный результат. А про сущность знаете, эту информацию мы получаем при анализе предметной области. "Поведение" это не возможность вызвать метод setName(), а то, как именно он работает, какие внутренние свойства меняет. Может он сохраняет значение в $this->_name, может в $this->data['name']. А факт того, что у сущности можно установить свойство "name" по определенным правилам, идет из предметной области.

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

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

Вопрос про аккаунты вы почему-то проигнорировали.

Проверка можно ли из статуса "Ожидает оплаты" перейти в статус "Доставлен" где по вашему должна происходить?

Хорошим тоном является сначала ответить на вопросы, прежде чем задавать свои.

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

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

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

Тогда почему вы не хотите признать, что используете процедурное программирование, а не ООП? :)

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

Потому что в данном случае ООП тоже используется в нужном объеме. 

В слое бизнес логики не используется.

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

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

В слое бизнес логики не используется.

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

<?php
final class TransferService
{
    public function transferMoney(Uuid $sourceAccountId, Uuid $targetAccountId, Money $amount): void
    {
        $sourceAccount = $this->accountRepository->get($sourceAccountId);
        $targetAccount = $this->accountRepository->get($targetAccountId);
        $sourceAccount->withdraw($amount);
        $targetAccount->deposit($amount);
    }

}

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

Ну а transferMoney-то почему не в сущности Account? Можно же вот так написать.

<?php
class Account
{
    public function transferMoney(Account $targetAccount, Money $amount): void
    {
        $this->withdraw($amount);
        $targetAccount->deposit($amount);
    }
}

Последовательность "withdraw, deposit" это и есть бизнес-логика перевода, и она аккаунту не принадлежит.

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

Чтобы уменьшить сложность и не нарушать границы согласованности транзакций, но это вовсе не означает что агрегаты должны быть анемичными.

Ну вот, начались мало формализуемые критерии. А почему сложность-то увеличивается, если поместить эти 2 строки в сущность?)

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

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

Я ответил на это выше. Поведение помещено в тот объект, которому оно принадлежит, просто это не сущность.

Так я и говорю, состояние в одном месте, поведение в другом. Процедурное программирование.

Нет, поведение по управлению состоянием объекта находится в объекте.

<?php

class Product {
    private string $_name;

    public setName(string $name) {
      $this->_name = $name;
    }
}

$product->setName('test');

// ------

/** @property string $name */
class Product {
    private array $data;

    public __set(string $field, mixed $value) {
      $this->data[$field] = $value;
    }
}

$product->name = $test;

В обоих случаях детали реализации поведения скрыты в сущности, наружу она предоставляет свойство "name", а не "_name" или "data".

Статья как раз о том что многие совершают эту ошибку, считают бизнес-логику или наличие свойств деталью реализации.

Нет, поведение по управлению состоянием объекта находится в объекте.

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

<?php
final class Account
{
    
    private $blocked = false;
    private Money $balance;

    public function withdraw(Money $amount): void
    {
        if ($this->balance->isLessThan($amount)) {
            throw new InssuficientFundsException();
        }
        if ($this->blocked) {
            throw new AccountBlockedException();
        }
        $this->balance = $this->balance->subtract($amount);
    }
}

Это не поведение, в контексте бизнес логики

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

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

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

Нет, тогда если вы переименуете "_name" в "name" в первом примере или замените "data" на отдельный свойства во втором, вам придется менять это по всему коду. Именно это и было целью ООП - скрыть детали реализации объекта, чтобы в этом не было необходимости.

Вот поведение:

Ага, клево. Теперь бизнес к вам приходит и говорит "Сделайте мне овердрафт, лимит которого устанавливается в договоре". Что вы будете делать, пробрасывать Agreement в withdraw и isLessThan? Что там ООП говорит про одну причину для изменения?

Другой вопрос, бизнес к вам приходит и говорит "Сделайте чтобы в форме перевода показывались ошибки сразу от обоих аккаунтов". Что вы будете делать, дублировать валидацию без эксепшенов?

Ага, клево. Теперь бизнес к вам приходит и говорит "Сделайте мне овердрафт, лимит которого устанавливается в договоре". Что вы будете делать, пробрасывать Agreement в withdraw и isLessThan? Что там ООП говорит про одну причину для изменения?


<?php
final class Account
{

    private $blocked = false;
    private Money $balance;

    private OutsideInterface $outside;

    public function withdraw(Money $amount): void
    {
        if ($this->balance->isLessThan($this->getAvailableAmount())) {
            throw new InssuficientFundsException();
        }
        if ($this->blocked) {
            throw new AccountBlockedException();
        }
        $this->balance = $this->balance->subtract($amount);
    }

    private function getAvailableAmount() {
        return $this->balance->add($this->outside->getOverdraftAmount($this->id));
    }
}

Другой вопрос, бизнес к вам приходит и говорит "Сделайте чтобы в форме перевода показывались ошибки сразу от обоих аккаунтов". Что вы будете делать, дублировать валидацию без эксепшенов?

Это уже требования UI а не бизнес логики. В этом случае чтобы избежать дублирования, логику проверки вынесу в чистую функцию, и буду вызываеть ее изнутри доменного объекта и в тех местах UI где нужна такая валидация:

<?php
function hasEnoughFunds(Money $amount, Money $balance, Money $overdraft): bool
{
    return $balance->add($overdraft)->isGreaterOrEqualThan($amount);
}


"Поведение объекта" это более низкоуровневое понятие, чем бизнес-логика,

Мы говорим о ООП в контексте бизнес логики, а то поведение что вы продемонстировали никакого отношения к бизнес логике не имеет.

$this->outside->getOverdraftAmount()

Что еще за "outside", в бизнес-требованиях нет такого термина) Потом этот код открывает другой программист и полчаса разбирается, чему в бизнес-требованиях оно соответствует. Что там было про Ubiquitous language?

<?php

// TransferService
public function isTransferAllowed(Account $sourceAccount, Account $targetAccount, Money $amount) {
  ...
  $agreement = $this->loadAgreement();

  // если баланс будет меньше лимита в договоре, не разрешать перевод

  $potentialFinalAmount = $sourceAccount->balance->substract($amount);
  if ($potentialFinalAmount < $agreement->getOverdraftAmount()) {
    return false;
  }
  ...
}

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

Мы говорим о ООП в контексте бизнес логики

Термины ООП не меняются от того, где оно применяется. В ООП поведение это методы, которые работают с состоянием объекта. Метод setName() работает с состоянием, значит это поведение. В контексте бизнес логики можно говорить только о правильной или неправильной модели.

Это уже требования UI а не бизнес логики.

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

логику проверки вынесу в чистую функцию

Вау, вы же только что сказали, что это поведение объекта и должно быть внутри класса. Теперь уже не поведение?)
Вот так постепенно вы и вынесете всё в чистые функции.

Вау, вы же только что сказали, что это поведение объекта и должно быть внутри класса. Теперь уже не поведение?)

Поведение остается внутри объекта, вы не можете обойти эту проверку при изменении состояния объекта. Можно кстати эту функцию оставить и внутри класса сделать ее публичной и вызывать ее из UI если вам так больше нравиться.

Что еще за "outside", в бизнес-требованиях нет такого термина) Потом этот код открывает другой программист и полчаса разбирается, чему в бизнес-требованиях оно соответствует. Что там было про Ubiquitous language?

Там также нет и термина репозиторий, фабрика, сервис и многих других, что не мешает их использовать в слое домена. Outside это враппер который абстрагирует внешние зависимости агрегата, если интересно тут этот подход описан подробнее https://habr.com/ru/articles/799019/
Если вам так будет понятнее можно назвать метод getOverdraftAmountFromClientAgreement

Есть код, там меньше 300 строк логики на все методы, напишите хотя бы 2 полностью вместе с контроллером и валидацией, тогда будем сравнивать

Не вижу смысла обсуждать такие тривиальныt вещи как слой UI в контексте обсуждение богатой и анемичной модели.

Метод setName() работает с состоянием, значит это поведение

Инфраструктурного объекта возможно, но никак не доменного.

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

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

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

Да ну как это не могу-то)

class Account
{
  public function withdraw() {
    if (hasEnoughFunds()) {
      throw new InssuficientFundsException();
    }
    
    $this->balance = $this->balance->subtract($amount);
  }

  public function withdrawWithoutCheck() {
    $this->balance = $this->balance->subtract($amount);
  }
}

Outside это враппер который абстрагирует внешние зависимости агрегата

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

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

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

не в контексте DDD и богатой модели.

Вот я как раз и объясняю, что DDD считает некорректно, из-за чего и возникают сложности. Просто понятие "бизнес-логика" это не термин из DDD, поэтому и статья не конкретно о DDD.

Да ну как это не могу-то)
public function withdrawWithoutCheck() {

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

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

Это почему же? Есть операция списание средств со счета. Данная операция изменяет только состояние агрегата счет. Перед изменением этого состояния объект счет должен выполнить ряд проверок чтобы убедиться что такое изменение возможно. Часть данных для этих проверок находится за пределами агрегата, поэтому он их запрашивает из внешнего мира (outside). Если алгоритм списания измениться то это изменение произойдет только внутри агрегата Счет, не затрагивая другие агрегаты. У агрегата счет одна ответственность, контроль всех операций которые изменяют его состояние.

Работы с UI там вообще нет, есть только JSON-ответ со списком ошибок валидации.

Вы похоже не понимаете что такое слой UI в бэкенд приложении и за что этот слой отвечает.

Только никто в здравом уме не станет такой метод писать, при наличии в объекте метода с проверкой.

Ну вот и с сервисами так же.

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

Когда в сущности 3000 строк и пара сотен методов, не знать про нужный метод или пропустить его тоже очень вероятно.
Про сервисы не надо знать, нажимаем "Find usages" в IDE и получаем все места, где используется сущность.

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

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

Это почему же?
Часть данных для этих проверок находится за пределами агрегата

Вот именно потому что часть данных находится за пределами агрегата. Если они нужны сущности для изменения состояния, их надо передавать в аргументах метода. Загружать данные это не ответственность сущности.

Вы похоже не понимаете что такое слой UI в бэкенд приложении и за что этот слой отвечает.

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

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

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

Про сервисы не надо знать, нажимаем "Find usages" в IDE и получаем все места, где используется сущность

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

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

Если не забудет :).

Вот именно потому что часть данных находится за пределами агрегата. Если они нужны сущности для изменения состояния, их надо передавать в аргументах метода. Загружать данные это не ответственность сущности.

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

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

Важно, ответственность слоя UI ограничивается преобразованием данных в понятный для слоя приложения формат, и передача их в слой приложения и обратно. Никакой бизнес логики там быть не должно. Вся бизнес логика должна находится в слое домена и реализована либо в процедурном стиле (Amemic model) либо при помощи ООП (Rich model). Но в большинстве приложений, как правило, нету четкого разделения слоев и парадигм, обычно там бешеная смесь в которой трудно что либо понять.

Именно для этого в DDD и придуманы ограниченные контексты.

Да-да, я ждал, когда про них кто-нибудь вспомнит. В этом примере у нас есть CustomerOrder и ShippableOrder, и в ShippableOrder при желании можно удалить один item, не оглядываясь на ограничения CustomerOrder. Это мало чем отличается от сервисов, а также противоречит вашим словам о преимуществе "Все инварианты внутри класса", потому что теперь у нас 2 класса.
Кстати, в том примере используются сервисы.

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

Это не проще, не сложнее, а так же.

Если не забудет :)

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

Она их и не загружает, а всего лишь объявляет какие данные ей нужны через интерфейс

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

но ничего не знает откуда и как эти данные загружаются

Я знаю как работают интерфейсы, в контексте моего утверждения это не имеет значения.

ответственность слоя UI ограничивается преобразованием данных

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

Ок, вы не понимаете преимущества ограниченных контекстов, инкапсуляции, принципа inversion of control, CQRS и похоже мне не удасться до вас эту информацию донести. Если вас устраивает подход который вы описали в статье, и он позволят вам создавать качественные, поддерживаемые приложения, то это очень хорошо и я могу только за вас порадоваться. Но я бы на вашем месте не стал бы так категорично объявлять этот подход лучше тех подходов которые вы не понимаете.

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

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

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

Я вам уже показал преимущества на примере операции списания средств со счета

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

По ООП у класса должна быть одна причина для изменения

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

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

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

Могу посоветовать вам хорошую книгу "Learning Domain-Driven Design" Vlad Khononov, чтобы лучше понять концепции DDD. А так согласен с автором комментария выше. Нет понимания DDD. Разбирать это конечно слишком долго все.

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

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

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

Что до анемичных моделей и вынесения бизнес-логики отдельно от моделей - это гораздо более распространённый подход чем DDD, который обычно называют Transaction Script. Он прекрасно работает, пока в одном Bounded Context не оказывается реально много сложной бизнес-логики (что бывает довольно редко). К сожалению, Ваш стиль изложения сильно усложнил попытку понять, чем Ваш подход отличается (если отличается) от Transaction Script.

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

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

чем Ваш подход отличается (если отличается) от Transaction Script

Да неважно, Transaction Script это или что-то еще. Основная мысль, что из контроллера или консольной команды должен вызываться какой-то класс с точкой входа в логику, который не сущность. Реализовать это можно по-разному, но этот класс должен быть. Я вижу, что во многих случаях его пытаются убрать и поместить в сущность вообще всё, со всеми зависимостями. От этого возникают проблемы, которые пытаются решить разными способами, например, передавать зависимости в методы сущности по интерфейсу.

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

Ну, на первый взгляд звучит как Transaction Script. Часть бизнес-логики при таком подходе может оказаться не только в этом классе (в слое приложения/use cases, если в терминах Clean Architecture), но и в слое инфраструктуры (в реализации репозитория - та часть, которую необходимо делать в рамках транзакции БД). Слой домена при таком подходе может вообще отсутствовать (то, что называется слоем домена в терминах Clean Architecture, в терминах DDD называется Shared Kernel и это не совсем "слой").

Я вижу, что во многих случаях его пытаются убрать и поместить в сущность вообще всё

Сложно угадать что именно Вы видели и почему оно было реализовано именно так.

Иногда всю бизнес-логику пытаются перенести из слоёв приложения и инфраструктуры в слой домена (и да, большую её часть именно в методы модели). По сути - перейти от Transaction Script к DDD. Делают это обычно тогда, когда бизнес-логика становится насколько сложной, что разработчики перестают справляться с поддержкой бизнес-логики в стиле Transaction Script (бизнес-логику становится сложно понимать, из-за этого возникает много багов, которые очень сложно фиксить не порождая новые баги). И в такой ситуации это вполне уместно.

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

Основная проблема DDD в том, что с ним довольно сложно разобраться, порог входа довольно высокий. Сделать это самостоятельно за несколько дней, по вечерам и выходным - практически невозможно. Лично у меня ушло примерно две недели, с утра до вечера, на чтение книг, статей и просмотр докладов, пока не случился "aha moment". И это только на тактическую часть DDD (потому что будучи архитектом с 30+ годами опыта стратегическую часть DDD я к этому моменту уже давно хорошо понимал и практиковал). Из-за этого порога входа большинство понимает и применяет тактику DDD некорректно (stackoverflow и блоги завалены вопросами по таким кейсам), получая в результате с таким "DDD" ещё больше проблем, чем без него.

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

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

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

чтобы можно было сравнить реализации и оценить возможные проблемы

Было бы круто увидеть реализацию с Rich Domain Model

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

Без сравнение показать "почему логика в сервисах является более правильным подходом", мне кажется, невозможно. У вас в голове есть некоторая реализация Rich Domain Model к которой вы апеллируете, но у тех кто читает статью, этой реализации нет, поэтому статья кажется неполной

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

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

Коммит
ProductService.php
Product.php

@powerman@mike_shapovalov Было бы интересно узнать ваше мнение, если я в чем-то ошибся) Что думаете? Можно было что-то сделать по-другому?

Помимо того что сущность жестко связана со слоем инфраструктуры и презентации мне не совсем понятно зачем всю логику помещать в один класс Product. Я бы реализовал это так:
- ProdcutCreationRequest - когда поставщик добавляет запрос не добавление нового продукта в систему. Этот Request может быть одобрен, и тогда в системе создается новый продукт, с данными из реквеста, или отклонен, и тогда поставщик создает новый реквест на базе старого с исправлениями.
- ProductChangeRequest - если поставщик правит данные по существующему товару. Тут логика похожая, если реквест утвержден, то изменения применяются к товару, если нет то поставщик может создать новый реквест на базе предыдущего.

Так как логика этих двух классов схожа можно в принципе их объединить в один класс с указанием типа, productCreation или productChange, либо поместить общую логику в абстрактный класс.

Таким образом сами эти реквесты будут содержать в себе историю изменений. И эта логика не будет засорять логику самого товара.

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

Примерно так бы выглядел доменный слой заявки на добавление продукта
https://gitlab.com/grifix/grifix/-/tree/feature/habr/modules/sandbox/src/Domain

и тесты к нему
https://gitlab.com/grifix/grifix/-/tree/feature/habr/modules/sandbox/tests/Unit/Domain/ProductCreationRequest

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

Можно логику sendForReview поместить и в Review, а не в Product, но статус товара все равно надо менять, потому что так хочет бизнес.

Ну и если честно для меня в вашем коде сложно понять, где реализована та или иная часть из бизнес-требований. Например, я вижу, что создание товара у вас не реализовано, и непонятно, где именно надо написать new Product(), если бы у меня была такая задача.

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

В нашем реальном приложении ProductCreationRequest существовал только в другой системе, при принятии она отправляла данные товара в нашу через API, и нам надо было только фактически создать товар. Поэтому я в примере рассмотрел только эту часть. Но в создании действительно почти нет логики, надо просто передать нужные данные в конструктор Product. А вот с изменением уже не так все просто. Вся логика, которая у меня находится в классе Product, связана с внесением изменений в сам агрегат Product, это те методы, который вы будете вызывать из подписчиков событий.

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

Так как вы проапдейтите-то?) Для этого должен быть метод в Product. Метод acceptChangesFromReview это и делает, там другой логики и нет.

А чтобы создать ProductUpdateRequest/Review, надо сначала накопить новые значения полей, которые надо отправлять. Это делает метод edit, который записывает их в ProductChange. ProductUpdateRequest не может их хранить, у него другой бизнес-процесс, он отражает запрос в другую систему и создается только при отправке, к нему привязываются комментарии, хранится история таких запросов, и т.д. Вот из этих методов логика в Product и состоит, ее никак не убрать.

А зачем специальный метод? Разве нельзя прооапдейтить продукт штатными методами? changeName, changeDescription и т.д. Допустим поставщик захотел изменить описание продукта и категорию. Он создаёт updateReqest с этими изменениями, менеджер его апрувит, публикуется событие updateReqestApproved которое слушает подписчик, который апдейтит товар. Товар тогда вообще ничего не знает о этих , апдейт реквкестах.

changeName, changeDescription

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

Товар тогда вообще ничего не знает о этих , апдейт реквкестах.

Товар не может совсем не знать, у него есть статус "На проверке", при котором запрещено редактирование, он устанавливается одновременно с созданием UpdateRequest. Значит в Product как минимум должен быть метод, который его устанавливает. Из UpdateRequest мы его вызывать не можем, потому что это другой агрегат, значит надо делать доменный сервис. А это в свою очередь создает вопрос, что если кто-то поставит этот статус напрямую без вызова доменного сервиса. То есть та же ситуация, как и с логикой в сервисах. Замена доменного сервиса на обработчик событий ничего не меняет, а также создает состояние гонки, когда UpdateRequest уже создан, а статус в Product еще не поставлен.

почему UpdateRequest не может хранить значения полей товара

Теоретически это возможно, но практически очень неудобно. Нам пришлось бы создавать UpdateRequest при первом изменении, и он бы висел в системе месяцами, пока пользователь не захочет отправить на проверку в другую систему, с соответствующим различием в датах создания. Это не отражает реальные процессы. Статусы Created и Sent для него имели бы специальные значения, при открытии страницы просмотра товара надо было бы искать в истории последнюю запись для этого товара в одном из этих статусов, чтобы наложить изменения, это создает лишнюю нагрузку на базу.

Для обработки UpdateRequest удобнее видеть diff, а для просмотра товара удобнее финальные значения. Например, было 10 изображений, 1 добавили, 2 удалили. В UpdateRequest будeт 3 записи вида {id: 123, url: '...', action: DELETED}, а в ProductChange список из 9. Иначе в UpdateRequest придется хранить 10+9 записей, чтобы можно было просмотреть diff позже. С проверкой тоже непонятно, в UpdateRequest одна запись с удалением изображения, как без данных из Product проверить ограничение "Для отправки на проверку должно быть описание не менее 300 символов, и хотя бы 1 изображение".

ProductChange это дежурные изменения, UpdateRequest это запрос на их проверку. Как коммит и мерж-реквест.

Пока товар на проверке его вообще никто не может редактировать, даже менеджер?

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

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

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

Нет, в этих методах мы можем делать проверку assertIsEditable() и бросать исключение если редактирование продукта заблокировано.

Товар не может совсем не знать, у него есть статус "На проверке",

Я бы вместо дополнительного статуса просто добавил бы свойство isEditable

Значит в Product как минимум должен быть метод, который его устанавливает.

Я думаю что как реакцию на UpdateRequestApproved можно выполнять Product::allowEdit, а потом changeName и все остальное.

Из UpdateRequest мы его вызывать не можем, потому что это другой агрегат, значит надо делать доменный сервис

Но можем как реакцию на событие UdpateRequestApproved

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

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

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

Можно повесить обработчики событий которые создадут специальную проекцию (денормализированную таблицу в базе) специально заточенную под операции чтения для показа истории.

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

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

как без данных из Product проверить ограничение "Для отправки на проверку должно быть описание не менее 300 символов, и хотя бы 1 изображение".

Получить эти данные из продукта через outside.

Нет, в этих методах мы можем делать проверку assertIsEditable() и бросать исключение если редактирование продукта заблокировано.

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

Я бы вместо дополнительного статуса просто добавил бы свойство isEditable

Бизнес хочет статус, но принципиально это ничего не меняет, просто вместо int надо записывать bool. Если мы управляем состоянием Product снаружи, то логика находится не в сущности.

Я бы сказал создает eventual consistency, но насколько я понял это и так происходит

Нет, тут будет именно race condition. Первый процесс создал UpdateRequest, а статус "На проверке" еще не поставил. Второй проверяет статус, он не "На проверке", значит можно создавать UpdateRequest, и тоже создает. С отдельным обработчиком событий, тем более асинхронным, это невозможно предотвратить.

Можно повесить обработчики событий которые создадут специальную проекцию (денормализированную таблицу в базе)

Ну вот ProductChange это и есть специальная таблица)

когда есть полная история событий, которые публикуют агрегаты

А аналитики как должны с базой работать?) Как делать фильтры типа "Показать заявки, где меняется категория"? История событий без финального состояния применима только в очень ограниченных ситуациях. Тем более что пользователь может 100 раз поменять описание, прежде чем отправить на проверку, зачем хранить эти промежуточные значения.
Хранить diff изменений "было-стало" это основное назначение Review/UpdateRequest, он больше ни для чего не нужен, это сущность из бизнес-требований.

Получить эти данные из продукта через outside.

Ну получили вы, а дальше-то что?) Надо на них наложить изменения и получить финальное состояние товара, которое надо проверять на соответствие критериям. То есть у вас будет структура, дублирующая Product, или вообще нетипизированный хешмап. Ну понятно, если создать сущность Product2, то в исходном Product логику писать не нужно.

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

Нет, тут будет именно race condition. Первый процесс создал UpdateRequest, а статус "На проверке" еще не поставил. Второй проверяет статус, он не "На проверке", значит можно создавать UpdateRequest, и тоже создает. С отдельным обработчиком событий, тем более асинхронным, это невозможно предотвратить.

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

А аналитики как должны с базой работать?) Как делать фильтры типа "Показать заявки, где меняется категория"? История событий без финального состояния применима только в очень ограниченных ситуациях

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

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

К сожалению времени на реализацию полного решения у меня нет, да я и не уверен что даже в этом случае вас удастся в чем-то убедить, но если вам интересен пример реализации чего то посложнее то рекомендую это: https://www.microsoft.com/en-us/download/details.aspx?id=34774

В этом случае нужна компенсирующая операция, если не удалось заблокировать продукт

Да не нужна там никакая операция) Просто создавать UpdateRequest и обновлять Product нужно в одной транзакции, они для этого и придуманы.
Статус OnReview это денормализация INSERT в таблицу review/update_request.
Если под блокировкой вы подразумеваете блокировку от редактирования с помощью установки статуса OnReview, то это не будет работать. Оба процесса прочитают текущий статус, который еще не OnReview, потом сначала первый поставит OnReview без ошибок, потом второй.

Ну и запрос нельзя обрабатывать, пока не подтверждена блокировка товара

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

Я не говорил что аналитики должны работать с историей событий непосредственно

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

да я и не уверен что даже в этом случае вас удастся в чем-то убедить

Так это я хочу убедить вас или хотя бы других, что код в этом случае становится менее поддерживаемым)

если вам интересен пример реализации чего то посложнее

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

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

Возможно неправильным было не решение а реализация.

Так это я хочу убедить вас или хотя бы других, что код в этом случае становится менее поддерживаемым)

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

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

Я лично за всю карьеру сталкивался с таким кейсом всего один раз. Во всех остальных случаях мне удавалось разделить сложную бизнес-логику проекта в целом на кучку относительно несложных Bounded Context (обычно по одному на микросервис), чтобы в каждом отдельно взятом микросервисе тактический DDD не требовался. (Правда, стоит отметить, что конкретно онлайн-магазины я не писал, а обычно именно эта сфера используется в примерах тактического DDD.)

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

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

Эванс про это говорит вполне однозначно: вы не сделаете "правильный" дизайн ни с первого раза ни с любого другого. По мере углубления понимания предметной области или изменения требований "правильными" моделями окажутся не такие, как сейчас. Вот, к примеру, прекрасный доклад, где Эванс рассказывает большую часть времени о том, какой моделью лучше описывать перевозку груза из точки A в точку B: последовательностью моделей Leg (рёбер соединяющих две точки) или Stop (последовательностью точек). И как изменение понимания предметной области качает весы от одного варианта к другому.

Конечно, такого рода рефакторинг - дело недешёвое. (И это только одна из причин, почему использовать тактику DDD в простых проектах нет смысла.) Особенно, если делать его нужно каждый раз, когда выражать бизнес-логику в коде становится даже не сложно, а так, немного сложновато. Но именно такие приёмы DDD и позволяют гарантировать, что бизнес-логику в коде выражать всегда будет просто, и в большинстве случаев это будет почти дословно тому, как описывает требования эксперт.

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

Вы должны писать в коде (точнее, в слое домена) практически буквально то, что произносит эксперт в предметной области.

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

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

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

это означает только то, что то, что Вы делаете в коде - это не DDD

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

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

Всё так. Именно поэтому когда нет сложной бизнес-логики Transaction Script использовать и проще и дешевле. Просто Вы не сталкивались с ситуациями, когда из-за роста сложности бизнес-логики такой подход перестаёт работать. А я с таким столкнулся. Всего один раз, но мне хватило, чтобы понять, что да, такое бывает, и нет, своими обычными подходами я это не вытяну. Скорее всего пока Вы сами с такой ситуацией не столкнётесь Вы продолжите верить, что описанный в статье подход действительно лучше DDD справляется со сложной бизнес-логикой.

Ага, опять абстрактные пожелания, что код надо писать без ошибок)

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

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

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

Это совсем другое.

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

и готов принести в жертву всё остальное

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

И это только одна из причин, почему использовать тактику DDD в простых проектах нет смысла
А я с таким столкнулся. Всего один раз

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

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

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

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

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

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

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

Курсы повышения квалификации для английских дантистов. :) Там из-за неудачного стечения обстоятельств не было возможности применить стратегию DDD и в результате вся бизнес-логика проекта оказалась в одном микросервисе. А это и сами дантисты разных профессий/квалификаций, и их персональные планы развития, и требования гос.регулятора, и собственно обучающие материалы/курсы которые они могли проходить прямо у нас, и разные виды других обучающих активностей которые они могли делать не у нас но у нас учитывать, и поставщики обучающих курсов… посыпать всё это разными требованиями, которым надо соответствовать в определённые периоды времени (от гос.регулятора, из собственных планов пользователей, нашего проекта, …), добавить напоминалки/ачивки/… И это я ещё всех подробностей уже не помню, дело было 6 лет назад.

то ничего более конструктивного чем "вы неправильно делаете TDD" тут не скажешь

Ну как это не скажешь. "У вас не покрыты вот эти методы, значит вы писали их без тестов. Сначала пишем тест вот так [пример кода], потом код метода вот так [пример кода]". Так же как в любом учебнике. Тем более что DDD не про последовательность действий, а про финальный результат для заданных требований.

Курсы повышения квалификации для английских дантистов

Спасибо за информацию. По описанию выглядит похоже на наше реальное приложение для поставщиков.

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

"У вас не покрыты вот эти методы, значит вы писали их без тестов. Сначала пишем тест вот так [пример кода], потом код метода вот так [пример кода]". Так же как в любом учебнике.

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

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

Будет. До какого-то предела. А потом случится то, что называется "проблема масштабирования". Big ball of mud тоже нормально пишется до какого-то предела, а потом почему-то ровно те же действия что и раньше по его развитию начинают требовать экспоненциально больше сил и времени. Но разработчик, у которого все Big ball of mud проекты укладывались в пару месяцев, вполне может искренне в это не верить, потому что не сталкивался лично.

Так что тут не ещё один учебник нужен, а полноценное индивидуальное обучение.

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

А потом случится то, что называется "проблема масштабирования"

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

вполне может искренне в это не верить, потому что не сталкивался лично

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

Вот если, как вы говорите, писать часть бизнес-логики в репозиториях, тогда согласен, будет Big ball of mud. Но зачем так делать, если можно не делать.

Лично мне нужен конкретный пример законченного приложения.

Поиск по гитхабу "ddd example" не помог?

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

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

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

Да, Ваша точка зрения вполне ясна. Но нет, не будет работать. Убедить я Вас в этом, очевидно, не смогу, так что остановимся на том, что наши мнения по этому вопросу расходятся.

Но зачем так делать, если можно не делать.

Из соображений производительности и консистентности. Иначе придётся либо открывать долгие транзакции "на весь use case" либо самостоятельно реализовывать механизм изоляции вместо встроенного в РСУБД (например, как делает UoW на ручном версионировании строк и оптимистических блокировках).

Иначе придётся либо открывать долгие транзакции "на весь use case"

Почему нельзя их открывать вручную когда нужно?

Поиск по гитхабу "ddd example" не помог?

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

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

- Обеспечение логики с 2 транзакциями "сохранить - отправить сетевой запрос - сохранить".
- Работа с 2 сущностями-агрегатами в одном действии, например установка значений и сохранение Product и Review в одной транзакции при отправке на ревью.
- Работа с мьютексами.
- Разделение логики на группы, например чтобы логику для админки нельзя было вызвать из пользовательской части.
- Валидация входных данных с загрузкой связанной информации - на входе name, description, category_id, надо подгрузить категорию и проверить статус, если она неактивна вернуть ошибку вместе с ошибками для других полей.
- Валидация данных сущности, которая требует зависимости, например валидатор штрихкодов.
- Использование зависимостей в логике - при выполнении действия отформатировать дату специальным форматтером, конвертировать markdown в html, при загрузке данных товара из CSV скачать изображение по URL и загрузить на файловый сервер с соответствующими записями в таблице file.

Почему нельзя их открывать вручную когда нужно?

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

  • транзакция тупо вокруг каждого use case целиком (обычно в DDD)

  • транзакция тупо вокруг каждого метода глобального репозитория (обычно в Transaction Script)

  • транзакция исключительно вокруг метода сохранения изменений накопленных в UnitOfWork (обычно в DDD)

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

Там нет моего приложения. Мне интересно именно сравнить реализации для одинаковых бизнес-требований.

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

  • Обеспечение логики с 2 транзакциями "сохранить - отправить сетевой запрос - сохранить".

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

  • Работа с 2 сущностями-агрегатами в одном действии, например установка значений и сохранение Product и Review в одной транзакции при отправке на ревью.

Обычно бизнес допускает в таких ситуациях eventual consistency. В этом случае первый use case добавляет/изменяет агрегат Review плюс отправляет доменное событие, а второй use case (обработчик этого события) отражает результат добавления/изменения ревью на агрегате Product.

  • Работа с мьютексами.

Обычно не требуется. Каждый use case подгружает из БД собственный инстанс агрегата, конфликты одновременного изменения одного агрегата разными use case разрешаются не через мьютексы, а через транзакцию в БД.

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

Это вопрос авторизации, и он не очень простой. Дело в том, что DDD очень не нравится идея замусоривать методы моделей логикой авторизации (потому что она нужна буквально во всех и это затрудняет понимание их основной бизнес-логики). Так что обычно (если возможно) авторизация делается где-то на предыдущих этапах: сетевой файрвол, edge proxy, слой приложения (use case). Сами модели остаются не защищены, так что мешать таким вызовам именно в слое домена - некому.

  • Валидация входных данных с загрузкой связанной информации - на входе name, description, category_id, надо подгрузить категорию и проверить статус, если она неактивна вернуть ошибку вместе с ошибками для других полей.

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

  • Валидация данных сущности, которая требует зависимости, например валидатор штрихкодов.

Ровно так же, как в предыдущем пункте, только вместо реализации репозитория (из слоя инфраструктуры, доступную в слое домена через инверсию зависимостей) используется такая же реализация клиента к внешнему API/библиотеке.

  • Использование зависимостей в логике - при выполнении действия отформатировать дату специальным форматтером, конвертировать markdown в html, при загрузке данных товара из CSV скачать изображение по URL и загрузить на файловый сервер с соответствующими записями в таблице file.

В общем случае всё это - не задачи для слоя домена. Бывают исключения, конечно (когда форматирование markdown в html это основной/ключевой функционал нашего проекта), но в большинстве проектов это задачи для сервисов в слое инфраструктуры (доступных в домене через интерфейсы/инверсию зависимостей) или даже вообще UI.

то Вы и напишите аналоги задач из таких репо в собственном стиле

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

плюс отправляет доменное событие

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

Обычно бизнес допускает в таких ситуациях eventual consistency.

Ну то есть нужные бизнес-требования реализовать нельзя.

Обычно не требуется.

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

конфликты одновременного изменения одного агрегата разными use case разрешаются не через мьютексы, а через транзакцию в БД.

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

Это вопрос авторизации, и он не очень простой.

Нет, права доступа это другой вопрос. Тут я говорю только про то, чтобы все не было в куче (в одном классе) и чтобы это нельзя было вызвать случайно или по незнанию. Зачем нам логика админки в коде пользовательской части?
Кстати да, проверка, что пользователь может редактировать этот товар, это тоже интересный вопрос.

Если требуется загружать через репозиторий другие сущности

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

используется такая же реализация клиента к внешнему API/библиотеке

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

В общем случае всё это - не задачи для слоя домена.

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

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

В бизнес-требованиях никаких событий нет, там четко сказано "сохранить это, отправить это".

А вот здесь DDD, на мой взгляд, использует не совсем корректную подмену понятий: "делаем как произносит словами бизнес" подменяет на "учим бизнес словами произносить немного иначе". Суть идеи в том, что концепцию доменных событий до бизнеса донести несложно (в отличие от разных технических/программерских/инфраструктурных вещей), плюс бизнес действительно нередко подразумевает наличие таких событий даже если не говорит про них явно словами. В результате мы сначала учим бизнес "говорить правильно" :) а потом получаем возможность отразить в коде это дословно. С точки зрения DDD это норм, потому что эксперт предметной области всё ещё в состоянии понимать наш способ описания бизнес-логики, в состоянии сам думать этим способом, и в состоянии корректно описывать требования этим способом.

Ну то есть нужные бизнес-требования реализовать нельзя.

Как раз бизнес-требования - можно. А вот искусственно добавленные разработчиками требования немедленной консистентности (в которой бизнес не нуждается и которые не требовал) - нет.

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

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

Что до сложностей, то чем искать способы реализовать что-то сложное лучше искать способы получить нужный результат избежав реализации чего-то сложного. Хороший пример в контексте DDD - идея дробления проекта на несколько Bounded Context, что позволяет сильно снизить сложность реализации каждого из них.

При отправке на ревью мьютекс это не замена транзакции, он сохраняется в течение 2 транзакций и сетевого вызова.

Хм. А что случится, если сервис в середине этого всего упадёт?

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

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

Зачем нам логика админки в коде пользовательской части?

Затем, что это - не логика админки! Это - просто штатная операция над товаром, которая требуется бизнес-логике. Кто имеет право её выполнять - в абсолютном большинстве случаев не важно для слоя домена.

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

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

Примерно так:

  • Метод use case в слое приложения:

    • открыть транзакцию

    • вызвать метод доменного сервиса

    • закрыть транзакцию

  • Метод доменного сервиса в слое домена:

    • загрузить из репо агрегат

    • загрузить из репо вспомогательные сущности

    • вызвать метод агрегата, передав ему вспомогательные сущности параметрами

    • сохранить агрегат

  • Метод агрегата:

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

    • если валидации провалились, то вернуть группу соответствующих доменных ошибок

В результате возвращённая методом агрегата группа доменных ошибок через доменный сервис будет передана в use case и из него дальше в сторону UI, возможно где-то по дороге (напр. в слое адаптеров, если используется чистая архитектура) преобразовавшись из доменных ошибок во что-то понятное на уровне API между бэком и UI.

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

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

Это бизнес-требования к работе с товарами, значит это часть домена.

Это не так.

Если бизнес требует, чтобы конкретный статус товара выводился "красненьким", из этого не следует, что данному функционалу место в инвариантах модели в слое домена, а не в UI.

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

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

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

если валидации провалились, то вернуть группу соответствующих доменных ошибок

Вот этот момент меня всегда немного смущал. Если мы будем делать проверки бизнес правил последовательно, то код в слое домена будет проще, но ошибку он будет возвращать одну. Кроме этого, мне кажется, что то каким образом ошибки будут отображаться это скорее требование слоя UI. При простых валидациях, типа формат данных, обязательное не обязательное поле эти проверки легко можно продублировать в слое UI, но если такая валидация требует каких-то проверок с связанных с внутренней бизнес логикой агрегата, то мне кажется что лучше сделать в агрегате публичный метод который проверяет бизнес правило и возвращает boolean, который может дёрнуть UI, чем усложнять логику агрегата группировкой ошибок.

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

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

Кроме этого, мне кажется, что то каким образом ошибки будут отображаться это скорее требование слоя UI.

Всё верно. Но UI нужно иметь, что отображать. Если UI имеет только первую ошибку, то отобразить все UI не сможет. А вот если UI получит все, то отобразить можно будет что и как угодно - одну, все, первые 3, сразу, по одной, etc. Так что если есть возможность вернуть с бэка сразу все - лучше так и сделать.

проверки легко можно продублировать в слое UI

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

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

Прямо скажем, не самая лучшая идея:

  1. Это будет медленно в UI (надо дёрнуть кучу API перед отправкой основного запроса).

  2. Это будет громоздко на уровне API. Валидаций много - это будет раздувать API в целом в разы (условно, имея 5-10 валидаций на агрегат мы вместо в среднем 5 методов API на агрегат получим 10-15). Валидации могут часто меняться - это будет требовать много (причём вполне возможно несовместимых) изменений API.

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

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

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

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

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

  1. Это будет медленно в UI (надо дёрнуть кучу API перед отправкой основного запроса).

Вы меня немного не правильно поняли, я имел ввиду слой UI бэкенд приложения, а не фронтенд приложение.

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

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

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

я имел ввиду слой UI бэкенд приложения

О чём речь? Можно пример?

И код этого клиента будет намного проще если агрегат будет возвращать одну ошибку, а не целый набор.

Для этого есть стандартный подход: в ответе API в поле "error" возвращается одна ошибка (обычно первая или последняя) для простых клиентов, а в поле "details" дополнительно прилагается полный список ошибок для более продвинутых клиентов.

О чём речь? Можно пример?

Это слой который отвечает например за преобразование http запроса в CQRS команду или запрос, передачу его в слой application и преобразование ответа из слоя application в http ответ.

Для этого есть стандартный подход: в ответе API в поле "error"

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

Хотя нет, все равно клиенту придется итерировать по коллекции. Возьмем пример который я привел в одном их предыдущих комментариев:

<?php
final class Account
{
    
    private $blocked = false;
    private Money $balance;

    public function withdraw(Money $amount): void
    {
        if ($this->balance->isLessThan($amount)) {
            throw new InssuficientFundsException();
        }
        if ($this->blocked) {
            throw new AccountBlockedException();
        }
        $this->balance = $this->balance->subtract($amount);
    }
}


Допустим у нас есть подписчик который обрабатывает AccountBlockedException

<?php
public function onWithdrawInstructed(WitdrawInstructedEvent $event): void
{
    $account = $this->accountRepository->get($event->accountId);
    try{
        $account->withdraw($event->amount);
    }
    catch (AccountBlockedException $e) {
        //do something
    }
}

Если мы будем возвращать коллекцию исключений, то этому подписчику нужно будет ловить все исключения которые бросает метод withdraw проверять не является ли корневое исключение AccountBlockedException и если нет, то проверять нет ли этого исключения во вложенной коллекции. На мой взгляд это сильно усложняет код обработки ошибок.

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

Можно ошибки складывать в массив "поле - список ошибок для поля", и сделать ValidationException с полем errors. Просто это неявно и похоже на goto. Ну и есть вопрос проброса инстанса той библиотеки-валидатора в сущность, чтобы не писать вручную однотипные проверки типа NotBlank.

Не всегда ошибка привязана к свойству сущности. Да и не всегда метод в сущности тригерится как реакция на отправку формы, поэтому валидация бизнес правил не должна быть привязана к полю на форме. Провалидировать NotBlank в сущности вообще невозможно, например в языках с типизацией и nullsafe. Как вы передадите blank значение в метод doSomething(int $value):void

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

NotBlank это просто пример, обычно это валидация для строк, для int может быть Positive.

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

Это слой который отвечает например за преобразование http запроса в CQRS команду или запрос, передачу его в слой application и преобразование ответа из слоя application в http ответ.

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

бросаем первое исключение, а внутрь него кладем коллекцию последующих

Я бы скорее использовал общее для всех ошибок валидации исключение (напр. как предложил выше @michael_v89"ValidationException с полем errors"), и использовал ровно ту же библиотеку для валидаций при необходимости проверки на наличие конкретной ошибки внутри поля errors. Но, в идеале, слой адаптеров не должен рыться этих ошибках, он должен тупо вернуть общий код ошибки в API для любых ошибок валидации (т.е. ему достаточно проверить что это именно ValidationException) и все ошибки как есть, может переложив первую или последнюю в отдельное поле (для простых клиентов).

Хотя нет, все равно клиенту придется итерировать по коллекции.

А кто тут является клиентом?

Если это клиент дёрнувший API, то он ошибки получает ответом из API как обычное значение, а не список исключений.

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

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

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

Другое дело, что хотелось бы избежать внесения изменений в код домена из-за изменений требований к UI. И как раз эта проблема решается одновременным возвратом всех ошибок валидации из домена (и на уровне сетевого API), т.к. позволяет дальше любые изменения требований к UI удовлетворять непосредственно в логике UI. Это требование не обязательно должно быть жёстким: если отдельные ошибки в слое домена сложно проверить и вернуть одновременно со всеми остальными то можно для них сделать исключение и возвращать их отдельно (юзабилити от этого если и пострадает то обычно незначительно, и в контексте DDD нам ясность доменной логики однозначно важнее юзабилити).

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

Да, это слой презентации, в некоторых описаниях многоуровневой архитектуры его называют слоем UI.

А кто тут является клиентом?

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

Добавление сюда любых подробностей (напр. какая именно валидация провалена) - это уже автоматом удовлетворение нужд UI, а не домена

Я смотрю на это немного под другим углом. Если в процессе выполнения бизнес операции агрегат столкнулся с ситуацией когда он эту операцию выполнить не может, то в зоне ответственности агрегата это исключительная ситуация, на которую он не знает как реагировать и поэтому он бросает исключение передавая ответственность вызывающему коду. С моей точки зрения лучший способ сообщить максимально подробно что именно произошло это вернуть в ответе специальный тип, в данном случае исключение, из которого вызывающий код сможет получить всю информацию которая его интересует, чтобы принять решение, как эту исключительную ситуацию обработать. Если вместо специального типа мы будем бросать ValidationException, то во первых интерфейс агрегата будет зависеть от сторонней библиотеки, а во вторых, это затруднит вызывающему коду понимание что именно произошло. Поэтому группировку ошибок я бы использовал только в ситуации если есть очень жесткое требование со стороны UI и его никак нельзя обойти, но по умолчанию бросал бы типизированные исключения по отдельности, для каждой исключительной ситуации.

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

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

Поэтому группировку ошибок я бы использовал только в ситуации если есть очень жесткое требование со стороны UI и его никак нельзя обойти

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

Проблема в том, что со временем выясняется, что большинство UI рано или поздно захочет все ошибки вместо одной, иначе юзабилити слишком страдает

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

такую валидации можно провести а слое презентации ещё до трансформации входных данных в типы и передачи их в слой домена

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

На самом деле слой адаптеров тоже валидирует. Просто он делает совершенно другие валидации: он проверяет что входящий DTO соответствует требованиям API (форматы значений, обязательные поля, etc.). Если по формату API полученный DTO корректный, то дальше данные передаются в модель на валидацию уже по бизнес-правилам. А чуть позже, при транзакции в БД, данные будут валидированы ещё раз, на соответствено констрейнам таблицы в базе. Все эти три слоя работают с разными данными и делают разные валидации. Очевидно, что UI в любом случае будет получать ошибки от всех трёх слоёв. Просто ошибка полученная от слоя презентации говорит о баге в самом UI-клиенте, ошибка от БД говорит о баге на бэке, а ошибки от слоя домена UI должен уметь удобно показывать юзеру.

На самом деле слой адаптеров тоже валидирует. Просто он делает совершенно другие валидации: он проверяет что входящий DTO соответствует требованиям API (форматы значений, обязательные поля, etc.).

Именно про такую валидацию я и говорю, я не предлагаю дублировать валидацию бизнес правил в слое презентации. И как правило UI требует все ошибки скопом именно после такой валидации, а не после валидации бизнес правил.

а во вторых, это затруднит вызывающему коду понимание что именно произошло

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

Как правило так и происходит, но иногда бывают ситуации когда вызывающий код реагирует на конкретную ошибку, например на InssuficientFundsExceptionотправит сообщение в шину, которое перехватит сервис нотификации и пришлет пользователю определенное сообщение

Кроме этого, если мы например используем Rest API разные исключение могут мапится на разные коды http. Можно конечно на каждый такой код сделать свой тип исключений, но на мой взгляд это будет жёсткая привязка домена к конкретному типу API.

Это как раз неявное поведение, про которое я говорил. Мы делаем исключение, которое на самом деле не исключение а событие, и бросаем его аналогично остальным, хотя по требованиям эта ситуация отличается.
Если для этой ситуации надо отправлять уведомление, в отличие от других ошибок, то надо делать это явно с пробросом eventBus или emailClient, и без исключений, а с обычным if. Потому что бизнес-требования записываются как "Если недостаточно средств, то отправить уведомление пользователю", а значит в коде вызовы проверки и отправки должны быть на одном уровне детализации.

Это как раз неявное поведение, про которое я говорил. Мы делаем исключение, которое на самом деле не исключение а событие,

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

Если для этой ситуации надо отправлять уведомление, в отличие от других ошибок, то надо делать это явно с пробросом eventBus или emailClient, и без исключений, а с обычным if

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

Событие публикуется когда изменяется состояние агрегата

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

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

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

class Entity
{
  public function isFundsSufficient(): bool
  {
    ...
  }
  
  public function performAction() {
    if (!$this->isFundsSufficient()) {
      throw new RuntimeException('Cannot perform action');
    }
    ...
  }
}


class ExternalCode
{
  public function performAction()
  {
    // прямая запись бизнес-требования
    if (!$entity->isFundsSufficient()) {
      $this->sendNotificationToUser('Insufficient funds');
      return;
    }
    
    $entity->performAction();
  }
}

Дальше можно подумать, как вместо isFundsSufficient сделать несколько проверок.

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

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

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

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

Отправки да, проверки не, иначе агрегат не будет контролировать свои инварианты.

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

Это уже операция чтения а не записи, а чтение это про read model, богатая модель используется только для операций записи.

  • Мы делаем исключение, которое на самом деле не исключение а событие

  • произошла попытка выполнить бизнес действие, которая закончилась неудачей

В этом случае было бы достаточно бросать RuntimeException или ValidationException, и уведомлять о любой неудаче, а не именно о insufficient funds.

Я говорю о том, что является событием для бизнеса, а не для DDD. Бизнес хочет отправлять уведомление. Это уведомление о каком-то событии. Мы его имитируем исключением. Проверка в одном месте, обработка ее результата в другом.

иначе агрегат не будет контролировать свои инварианты

В моем примере кода агрегат контролирует свои инварианты. Функция валидации вызывается и из внешнего кода, и из performAction в сущности.

а чтение это про read model, богатая модель используется только для операций записи

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

В моем примере кода агрегат контролирует свои инварианты. Функция валидации вызывается и из внешнего кода, и из performAction в сущности.

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

Какая разница, проверки для кнопки все равно должны быть те же самые.

Согласен, в этом случае, если проверка касается только внутреннего состояния объекта, чтобы не дублировать код, можно применить паттерн Спецификация.

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

Ну да, у нас же SRP, агрегат отвечает за целостность своего состояния, все остальное не его ответственность.

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

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

если проверка касается только внутреннего состояния объекта

Иногда может касаться и не только внутреннего состояния.
Как это связано со спецификацией, я не понял.

Ну да, у нас же SRP

SRP не заставляет разбивать связанную логику на несвязанные части. Есть принцип Low Coupling, и в вашем варианте вызывающий и вызываемый код получаются highly coupled, потому что содержат детали реализации одного уровня.
По SRP должен быть метод, из которого вызываются все нужные шаги бизнес-действия. Меняются требования к последовательности шагов (детали первого уровня) - меняется этот метод. Меняются требования к деталям какого-то шага (детали второго уровня) - меняется метод, выполняющий этот шаг.

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

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

Иногда может касаться и не только внутреннего состояния.
Как это связано со спецификацией, я не понял.

Описываем внутрее состояние с помощью спецификации и используем ее внутри агрегата при записи и для рид модели при чтении.

SRP не заставляет разбивать связанную логику на несвязанные части. Есть принцип Low Coupling, и в вашем варианте вызывающий и вызываемый код получаются highly coupled, потому что содержат детали реализации одного уровня.

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

Не в контроллере же проверять.

В контроллере тоже кстати может понадобиться проверять, на пример если у вас не API а сервер-сайд рендеринг, то на одно исключение может происходить редирект на другую страницу, а на другое рендеринг какого-то определенного шаблона.

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

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

О том как списать средства знает агрегат Счет. О том как уведомить пользователя знает Система Уведомлений.

Ну а соединять их должен вызывающий код конструкцией вида if (insufficientFunds) sendNotification();.
Это если мы хотим, чтобы код читался как бизнес-требования.

то изменения затронут только Систему Уведомления, но никак не затронут агрегат Счет

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

У этих программных модулей разная зона ответственности и они между собой слабо связаны.

Так я говорю не про систему уведомлений, а про вызывающий код, который ловит InsufficientFundsException и вызывает систему уведомлений. Действие же не система уведомлений вызывает.

если у вас не API а сервер-сайд рендеринг

Я бы возвращал в контроллер код ошибки, это более явно.

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

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

Отправлю из агрегата

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

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

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

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

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

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

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

Да, если есть возможность, то я бы делал вызовы явно

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

События убирают типизацию

Это почему-же?

Я не писал что уведомление нужно слать как реакцию на исключение

Писали.
"но иногда бывают ситуации когда вызывающий код реагирует на конкретную ошибку, например на InssuficientFundsException отправит сообщение в шину, которое перехватит сервис нотификации и пришлет пользователю определенное сообщение".

Т.е. пользователь при переводе денег будет ждать пока ваш сервис отправит все запросы к внешним API

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

Это почему-же?

Потому что вместо конкретного
$this->statisticsService->saveTransferStatistics(Transfer $transfer)
у нас теперь
$this->eventHandler->sendEvent(int Event::STATISTICS, array ['transfer_data' => array ['amount' => float $amount, 'account_id' => int $accountId]).
Потом сложно найти все места, где используется amount из Transfer, если мы захотим сделать его структурой [amount, currency].

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

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

То что так часто делают не означает что это правильно.

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

Что произойдет в этом случае?

<?php
$nontificatinApi->notifyUser('Isufficend funds');
//Cервер внезапно упал
$statisticApi->registerOverdraftUsage($userId)


Потом сложно найти все места, где используется amount из Transfer, если мы захотим сделать его структурой [amount, currency].

Что мешает сделать так:

<?php
$this->eventHandler->sendEvent(
  new OverdraftUsedEvent(
    userId: $this->id,
    amount: $amount
  )
)

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

Разница есть и она довольно существенная.

Что мешает сделать так:

Ничего не мешает, это то же самое. Вместо свойства Transfer::amount у нас теперь OverdraftUsedEvent::amount, поэтому по "Find Usages" места использования второго свойства показаны не будут.
Кроме того, при асинхронной обработке это DTO все равно будет сериализовано, и чтобы отследить места использования, надо гарантировать, что в коде обработчика оно будет десериализовано в этот же класс, что далеко не всегда происходит.

Что произойдет в этом случае?

Это не отправка сообщений в очереди, а прямые вызовы API, но ладно, это неважно. Если сервер упадет, то действие будет выполнено, а некоторые или все уведомления отправлены не будут, это может произойти в любом подходе.

Более интересно другое.

class Account
{
  public function withdraw()
  {
    ...
    $this->eventBus->sendEvent(Event::OverdraftUsed);
    // а транзакция после вызова этого метода не закоммитилась
  }
}

Что произойдет в этом случае?)
Это как раз тот случай, про который я писал в статье - сначала надо сохранить, потом отправить. А сущность за сохранение данных не отвечает.

Ничего не мешает, это то же самое. Вместо свойства Transfer::amount у нас теперь OverdraftUsedEvent::amount, поэтому по "Find Usages" места использования второго свойства показаны не будут.

Не понял, почему find usages не покажет вам OverdraftUsedEvent::amount?
И это не тоже самое, потому что в данном случае типизация есть, а вы это указали недостатком событийного подхода.

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

Что мешает использовать ту же библиотеку для десериализации в обработчике?

Это не отправка сообщений в очереди, а прямые вызовы API, но ладно, это неважно. 

А в чем принципиальная разница?

// а транзакция после вызова этого метода не закоммитилась

Событие не сохранится в базу данных вместе с агрегатом. Более подробно читайте в описании шаблона Transactional Outbox.

это может произойти в любом подходе.

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

Не понял, почему find usages не покажет вам OverdraftUsedEvent::amount?

Find usages на Transfer::amount не покажет места использования OverdraftUsedEvent::amount, для него надо искать отдельно, и так на каждое DTO, где используется amount из Transfer, и аналогично для других свойств.

потому что в данном случае типизация есть, а вы это указали недостатком

Я сказал не что ее нет, а что она теряется. Был Transfer, а стал OverdraftUsedEvent с данными из Transfer. Потом открываешь код, который работает с OverdraftUsedEvent, и поди догадайся, что он связан с Transfer.

Что мешает использовать ту же библиотеку

Теоретически ничего не мешает, но практически это не всегда происходит по разным причинам.

А в чем принципиальная разница?

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

Событие не сохранится в базу данных вместе с агрегатом.

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

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

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

Я не писал что уведомление нужно слать как реакцию на исключение

Хотя нет, писал, должен признать, что пример был неудачный, вы правильно заметили что в данном случае лучше пробросить even bus в агрегат и отправить сообщение.

это уже автоматом удовлетворение нужд UI, а не домена

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

С названием полей всё сложно. Когда на бэке чистая архитектура присланное UI поле может довольно сложным и неочевидным образом мапиться на модели домена. Соответственно, ошибка валидации обнаруженная в слоях приложения/домена будет в любом случае не про то поле, которое UI прислал в API, а про поля модели. При этом заниматься творческим переписыванием текста ошибок домена в слое адаптеров - очевидно дурная затея, поэтому обычно никто даже не пытается это делать. Так что, в конечном итоге, обычно именно UI приходится самому мапить ошибки бэка на поля ввода либо просто их выводить, чтобы юзер сам догадался какое поле(я) нужно исправить.

Если UI присылает поля, мы валидируем входное DTO и привязываем ошибки к этим полям. Если мы валидируем сущность по id в URL, то тут поля обычно не нужны, только сообщения. Их да, просто выводят рядом с кнопкой, которая отправляет запрос, или в попапе. Но их надо как-то вернуть из процедуры валидации, и обычно все сразу, чтобы пользователь не исправлял ошибки по одной. Иногда бизнес все равно хочет подсвечивать поля формы даже для таких кнопок, тогда нужна возможность из валидации возвращать и поля сущности, чтобы можно было сделать маппинг.

валидируем входное DTO и привязываем ошибки к этим полям

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

Формат входного DTO обычно фиксирован и устарел (потому что это API и там важно сохранять совместимость), а модель могла сильно измениться со временем. Иногда меняются только названия и форматы полей но они всё ещё один-к-одному, но могут быть и другие варианты. Самый простой пример: на вход пришли два поля: FirstName и LastName, а при передаче в модель они были переданы как одно поле FullName (слой адаптера объединил их в одну строку через пробел). А бывает и наоборот, в DTO через API пришло одно поле FullName а при конвертации в модель адаптер это значение как-то разделил на FirstName и LastName.

Кроме того, ошибка не всегда относится либо к модели в целом либо одному полю. Опять же, банальный пример: значения в полях Password и RepeatPassword должны совпадать, и данная валидация относится к обоим полям одновременно.

Но, самое важное даже не это вот всё. Важно то, что нужно ли привязывать ошибки к полям, к каким именно, и как это лучше сделать - должно определяться логикой UI, а не бэком. Потому что эта "привязка" - исключительно вопрос UI и UX (ведь функционально приложение будет корректно работать даже если ошибки будут показаны не рядом с нужным полем, и UI в любом случае в состоянии тем или иным способом понять к какому полю относится ошибка если в API перечислен список всех возможных ошибок бэка).

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

Поэтому бэк может вернуть список ошибок, он должен вернуть код ошибки, он может приложить к каждой ошибке структуру с подробностями специфичными для конкретного кода ошибки - в общем предоставить UI всё необходимое, чтобы UI смог распознать ошибку, перевести на нужный язык подставив в шаблон детали, и вывести её наилучшим для UX способом. Но бэк не должен решать, что данную ошибку надо выводить рядом с каким-то полем в UI (хотя бы потому, что в UI поля FullName тоже может не быть, потому что UI запрашивает у юзера FirstName, MiddleName и LastName а на сервер передаёт их в одном поле DTO FullName).

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

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

Опять же, банальный пример: значения в полях Password и RepeatPassword должны совпадать, и данная валидация относится к обоим полям одновременно.

Это обычная валидация входного DTO, тут нет проблем привязать ошибку куда нужно. Assert\EqualsTo(targetProperty: 'RepeatPassword') $password и аналогично для другого поля если нужно.

должно определяться логикой UI, а не бэком

А для бэка это неважно. Бэк предоставляет требования к вызову API "Для этого действия отправь мне такое DTO на этот адрес".
Соответственно, если с этим DTO что-то не так, он должен показать клиенту API, с каким полями и что именно не так. Потому что если он вернет просто "Значение не должно быть пустым", то непонятно, какое именно значение надо исправить. Это вопрос уведомлений для соблюдения протокола. А как клиент это будет показывать пользователю, это его дело.
Если же с ресурсом что-то не так, то тут входных полей нет, и нужно дополнительное соглашение, как уведомлять клиент, почему его действие нельзя выполнить. Иногда бизнесу достаточно текстовых ошибок, иногда нет.

Но бэк не должен решать, что данную ошибку надо выводить рядом с каким-то полем в UI

Я и не сказал, что он должен. Его дело вернуть информацию с достаточным для клиента содержанием.

потому что UI запрашивает у юзера FirstName, MiddleName и LastName а на сервер передаёт их в одном поле DTO FullName

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

А вот искусственно добавленные разработчиками требования немедленной консистентности (в которой бизнес не нуждается и которые не требовал)

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

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

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

А что случится, если сервис в середине этого всего упадёт?

База освободит мьютекс при закрытии соединения.

Это должно быть штатной ситуацией

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

Если без мьютекса у нас нет целостности, то это значит только то, что при креше сервиса мы целостность окончательно потеряем

Да нет, целостность такая же, как и в случае креша с 2 транзакциями без мьютекса. Мьютекс просто тормозит параллельный процесс.

Кто имеет право её выполнять - в абсолютном большинстве случаев не важно для слоя домена.

Зато важно для программистов, которые поддерживают приложение. Когда все в куче, этим сложно управлять. Это тот же принцип, как и рекомендация делать интерфейсы как можно меньше.

Думайте про это так

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

вызвать метод агрегата, передав ему вспомогательные сущности параметрами, сохранить агрегат
Метод агрегата: выполнить валидации

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

"красненьким", из этого не следует, что данному функционалу место в инвариантах модели в слое домена

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

Всё, что относится к особенностям представления данных

Я говорю не про особенности представления данных, а про бизнес-требования к обработке и валидации данных. Требования к изображению ничем не отличаются от требований к описанию. Просто изображение требует других методов работы с данными, для чего нужны зависимости.
Если описание короткое (маленькое), надо вернуть ошибку валидации. Если изображение не скачивается, надо тоже вернуть ошибку валидации. Если скачивается, но его размеры не соответствуют бизнес-требованиям (маленькие), то тоже вернуть ошибку валидации. И при редактировании данных через интерфейс критерии такие же, только файлы загружаются стандартным способом. Это все часть бизнес-действия "Загрузка данных множества товаров из CSV", и нигде кроме домена она быть не может.

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

https://stackoverflow.com/a/10013528

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

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

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

  2. Сложные агрегаты технически не все орм могут персистить из коробки, можно схватить кучу гемора с этим.

Из опыта могу сказать, что довольно удачное решение - это не использовать модель Order объект как агрегат, а создать модуль Order с "приватными" классами и публичным апи и сущность, которая фактически просто dto, сделать также сделать приватной для модуля. Этим вы гарантируете, что именно только апи модуля могут менять её. Общение межу модулями через доменные события. Модульность и приватность в пхп достигается разными тулзовинами.

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

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

Публикации

Истории