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