Руслан Гнатовский aka @Number55 в своей статье Когда ни туда, ни сюда, или в поисках оптимальной границы Domain слоя описал известную проблему протекания бизнес-логики из агрегата, в случае если эта логика зависит от данных которые находятся вне агрегата, и предложил несколько решений этой проблемы, каждое из которых не лишено недостатков. Многие из этих недостатков были описаны в статье, а также в комментариях, поэтому я не буду здесь дублировать эту информацию а попытаюсь предложить решение, которое этих недостатков лишено.
В качестве примера возьмем выдуманный кейс, который чуть сложнее валидации электронного адреса пользователя на уникальность.
Предположим у нас есть сервис обмена валют. И есть агрегат заявка на обмен валют (Bid). У этой заявки есть следующие бизнес правила:
Пользователь не может обменять более 1000 долларов в сутки
Если сумма обмена менее 100 долларов обменный курс берется из банка A, если больше то из банка Б.
Лимит и минимальная сумма могут отличаться в зависимости от дня недели
Для упрощения примера предположим что мы всегда обмениваем доллары и курс у банков не меняется в течении дня.
Как видим для проверки бизнес требований нам нужны данные которые находятся за пределами агрегата Bid. Только вариант номер 2 в статье Руслана (внедрение репозитория в агрегат) позволяет сделать эти проверки внутри агрегата. Поскольку проверок несколько то кроме репозитория нам потребуется внедрить еще несколько зависимостей
Сам репозиторий с методом получения заявок пользователя на конкретный день:
<?php interface BidRepository { public function add(Bid $bid): void; public function get(Uuid $id): Bid; /** * @return Bid[] */ public function getUserBids(DateTimeImmutable $date, Uuid $userId): array; }
Сервис для коммуникации с банком.
<?php interface BankGateway { public function getExchangeRate(Currency $source, Currency $target): float; public function makeBid(Amount $amount, Currency $targetCurrency): void; }
Репозиторий для настроек для каждого дня недели
<?php final class ExchangeSettings { public function __construct( public int $dayOfTheWeek, public int $dailyExchangeLimit, public int $premiumLimit, public int $someOtherSettingNotRelatedToBid ) { } } interface ExchangeSettingsRepository { public function getExchangeSettings(int $dayOfTheWeek): ExchangeSettings; public function addExchangeSetting(ExchangeSettings $settings): void; }
Чтобы проверить все требования внутри агрегата, нам придется внедрить все эти зависимости, в итоге код будет выглядеть примерно так:
<?php final class Bid { private float $rate; public function __construct( private Uuid $id, private Uuid $userId, private DateTimeImmutable $createdAt, private Amount $amount, private Currency $targetCurrency, private BidRepository $bidRepository, private BankGateway $bankA, private BankGateway $bankB, private ExchangeSettingsRepository $exchangeSettingsRepository ) { $this->assertExchangeLimitDoesNotExceed(); if ($this->amount->getValue() > $this->exchangeSettingsRepository->getExchangeSettings((int)($this->createdAt)->format('N'))->premiumLimit) { $this->rate = $this->bankA->getExchangeRate($this->amount->getCurrency(), $this->targetCurrency); } else { $this->rate = $this->bankB->getExchangeRate($this->amount->getCurrency(), $this->targetCurrency); } } private function assertExchangeLimitDoesNotExceed(): void { $total = 0; foreach ($this->bidRepository->getUserBids($this->createdAt, $this->userId) as $bid) { $total += $bid->getAmount()->getValue(); if ($total > $this->exchangeSettingsRepository->getExchangeSettings( (int)$this->createdAt->format('N') )->dailyExchangeLimit) { throw new RuntimeException('Exchange limit exceeded!'); } } } public function getAmount(): Amount { return $this->amount; } }
Диграммма зависимостей будет выглядеть так:

На мой взгляд у такого подхода есть ряд проблем:
Агрегат имеет несколько внешних зависимостей, которые содержат методы с сайд эффектам которые он в теории может вызывать. Например вытащить из репозитория другой агрегат и изменить его состояние.
Изменения интерфейса этих зависимостей не контролируется агрегатом, и может потребовать изменения внутренней логики агрегата.
В момент реализации бизнес логики мы должны задумываться о деталях интерфейса внешних зависимостей которые напрямую не связаны с логикой агрегата.
Для того чтобы протестировать такой агрегат нам придется создавать мок для каждой из этих зависимостей и создавать фикстуры для всех данных которые они возвращают даже если агрегат не использует часть этих данных.
А что если мы попробуем плясать от потребностей агрегата, и на этапе реализации бизнес логики не будем задумываться о том откуда именно агрегат будет получать внешние данные. Для начала опишем потребности агрегата во внешних данных в виде интерфейса, который будет находится в слое Domain рядом с агрегатом:
<?php interface BidOutside { public function getStandardRate(Currency $sourceCurrency, Currency $targetCurrency): int; public function getPremiumRate(Currency $sourceCurrency, Currency $targetCurrency): int; public function getPremiumLimit(DateTimeImmutable $date): int; public function getDailyLimit(DateTimeImmutable $date, Uuid $userId): int; }
Реализация этого интерфейса размещенная в слое Infrastructure возьмет на себя всю работу по подготовке и конвертации данных из внешних источников в формат удобный для агрегата.
Т.е по факту вместо внедрения всех вышеперечисленных зависимостей напрямую в агрегат, мы их внедрям в некий враппер, который отсекает не нужн��е методы, получает данные из этих зависимостей и конвертирует их в формат удобный для агрегата.
Диграмма зависимостей будет теперь выглядеть так:

Благодаря этому код самого агрегата сильно упроститься:
<?php final class Bid { private float $rate; public function __construct( private BidOutside $outside, private Uuid $id, private DateTimeImmutable $createdAt, private Uuid $userId, private Amount $amount, private Currency $targetCurrency, ) { $this->assertExchangeLimitDoesNotExceed(); if ($this->amount->getValue() > $this->outside->getPremiumLimit($this->createdAt)) { $this->rate = $this->outside->getPremiumRate($amount->getCurrency(), $this->targetCurrency); } else { $this->rate = $this->outside->getStandardRate($amount->getCurrency, $this->targetCurrency); } } private function assertExchangeLimitDoesNotExceed(): void { if ($this->outside->getDailyLimit($this->createdAt, $this->userId) < $this->amount->getValue()) { throw new RuntimeException('Exchange limit exceeded!'); } } }
Более того на этапе написания бизнес кода мы можем вообще не задумываться над реализацией интерфейса outside. Мы можем полностью реализовать логику на уровне домена, протестировать ее с помощью юнит тестов, а реализацию outside передать другому разработчику, у которого меньше опыта в применении DDD.
Что мы имеем в итоге:
Вся доменная логика находится внутри доменного объекта, а не размазана по внешними сервисам
У агрегата есть только одна внешняя зависимость максимально заточенная под потребности агрегата, интерфейс который он сам и определяет
Создание мока для этой зависимости намного проще чем создание моков для нескольких зависимостей которые напрямую не связанны с агрегатом.
Разработку можно разделить на этапы:
На пером этапе мы имплементируем бизнес логику не задумываясь о интерфейсах и деталях реализации инфраструктуры которая обеспечиваeт нас данным для принятия бизнес решений
На втором этапе подключаем агрегат через outside к действующей инфраструктуре и реализуем те части инфраструктуры которых еще не существует
