Паттерн Aggregate Outside

ae27185fd045f0e3dcaae8131ab904d5

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

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

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

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

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

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

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

Как видим для проверки бизнес требований нам нужны данные которые находятся за пределами агрегата Bid. Только вариант номер 2 в статье Руслана (внедрение репозитория в агрегат) позволяет сделать эти проверки внутри агрегата. Поскольку проверок несколько то кроме репозитория нам потребуется внедрить еще несколько зависимостей

Сам репозиторий с методом получения заявок пользователя на конкретный день:

Сервис для коммуникации с банком.

Репозиторий для настроек для каждого дня недели

Чтобы проверить все требования внутри агрегата, нам придется внедрить все эти зависимости в итоге код будет выглядеть примерно так:

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 рядом с агрегатом:

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

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

Благодаря этому код самого агрегата сильно упроститься:

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

© Habrahabr.ru