Паттерн Aggregate Outside

от автора

Руслан Гнатовский aka @Number55 в своей статье Когда ни туда, ни сюда, или в поисках оптимальной границы Domain слоя описал известную проблему протекания бизнес-логики из агрегата, в случае если эта логика зависит от данных которые находятся вне агрегата, и предложил несколько решений этой проблемы, каждое из которых не лишено недостатков. Многие из этих недостатков были описаны в статье, а также в комментариях, поэтому я не буду здесь дублировать эту информацию а попытаюсь предложить решение, которое этих недостатков лишено.

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

Предположим у нас есть сервис обмена валют. И есть агрегат заявка на обмен валют (Bid). У этой заявки есть следующие бизнес правила:

  1. Пользователь не может обменять более 1000 долларов в сутки

  2. Если сумма обмена менее 100 долларов обменный курс берется из банка A, если больше то из банка Б.

  3. Лимит и минимальная сумма могут отличаться в зависимости от дня недели

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

Как видим для проверки бизнес требований нам нужны данные которые находятся за пределами агрегата 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;       }   } 

Диграммма зависимостей будет выглядеть так:

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

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

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

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

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

А что если мы попробуем плясать от потребностей агрегата, и на этапе реализации бизнес логики не будем задумываться о том откуда именно агрегат будет получать внешние данные. Для начала опишем потребности агрегата во внешних данных в виде интерфейса, который будет находится в слое 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 к действующей инфраструктуре и реализуем те части инфраструктуры которых еще не существует


ссылка на оригинал статьи https://habr.com/ru/articles/799019/