Pull to refresh

Паттерн Aggregate Outside

Level of difficultyMedium
Reading time5 min
Views7K

Руслан Гнатовский 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 к действующей инфраструктуре и реализуем те части инфраструктуры которых еще не существует

Tags:
Hubs:
Total votes 25: ↑25 and ↓0+25
Comments17

Articles