Паттерн Aggregate Outside
Руслан Гнатовский aka @Number55 в свой статье Когда ни туда, ни сюда, или в поисках оптимальной границы Domain слоя описал известную проблему протекания бизнес-логики из агрегата, в случае если эта логика зависит от данных которые находятся вне агрегата, и предложил несколько решений этой проблемы, каждое из которых не лишено недостатков. Многие из этих недостатков были описаны в статье, а также в комментариях поэтому я не буду здесь дублировать эту информацию, а попытаюсь предложить решение которое этих недостатков лишено.
В качестве примера возьмем выдуманный кейс, который чуть сложнее валидации электронного адреса пользователя на уникальность.
Предположим у нас есть сервис обмена валют. И есть агрегат заявка на обмен валют (Bid
). У этой заявки есть следующие бизнес правила:
Пользователь не может обменять более 1000 долларов в сутки
Если сумма обмена менее 100 долларов обменный курс берется из банка A, если больше то из банка Б.
Лимит и минимальная сумма могут отличаться в зависимости от дня недели
Для упрощения примера предположим что мы всегда обмениваем доллары.
Как видим для проверки бизнес требований нам нужны данные которые находятся за пределами агрегата 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;
}
}
На мой взгляд у такого подхода есть ряд проблем:
Агрегат имеет несколько внешних зависимостей, которые содержат методы с сайд эффектам которые он в теории может вызывать. Например вытащить из репозитория другой агрегат и изменить его состояние.
Изменения интерфейса этих зависимостей не контролируется агрегатом, и может потребовать изменения внутренней логики агрегата.
В момент реализации бизнес логики мы должны задумываться деталях интерфейса внешних зависимостей которые напрямую не связаны с логикой агрегата.
Для того чтобы протестировать такой агрегат нам придется создавать мок для каждой из этих зависимостей и создавать фикстуры для всех данных которые они возвращают даже если агрегат не использует часть этих данных.
А что если мы попробуем плясать от потребностей агрегата, и на этапе реализации бизнес логики не будем задумываться о том откуда именно агрегат будет получать внешние данные. Для начала опишем потребности агрегата во внешних данных в виде интерфейса, который будет находится в слое 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 к действующей инфраструктуре и реализуем те части инфраструктуры которых еще не существует