Aggregate Outside pattern

Ruslan Gnatovsky aka @Number55 in his article When neither here nor there, or in search of the optimal boundary of the Domain layer, described the well-known problem of business logic flowing from an aggregate, if this logic depends on data that is outside the aggregate, and proposed several solutions to this problems, each of which is not without its drawbacks. Many of these shortcomings were described in the article and also in the comments, so I will not duplicate this information here, but will try to offer a solution that does not have these shortcomings.

As an example, let’s take a fictitious case that is a little more complicated than validating a user’s email address for uniqueness.

Let's assume we have a currency exchange service. And there is a unit application for currency exchange (Bid). This application has the following business rules:

  1. The user cannot exchange more than $1000 per day

  2. If the exchange amount is less than $100, the exchange rate is taken from bank A, if more, then from bank B.

  3. Limit and minimum amount may vary depending on the day of the week

To simplify the example, let's assume that we always exchange dollars.

As you can see, to check business requirements we need data that is outside the unit Bid. Only option number 2 in Ruslan’s article (incorporating a repository into an aggregate) allows you to do these checks inside the aggregate. Since there are several checks, in addition to the repository, we will need to implement several more dependencies

The repository itself with the method for receiving user requests for a specific day:

<?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;  
}

Service for communication with the bank.

<?php

interface BankGateway  
{  
    public function getExchangeRate(Currency $source, Currency $target): float;  
  
    public function makeBid(Amount $amount, Currency $targetCurrency): void;  
}

Repository for settings for each day of the week

<?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;  
}

To check all the requirements inside the aggregate, we will have to inject all these dependencies so the code will end up looking something like this:

<?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 $bankCurrencyExchange $currencyExchangeA,  
       private CurrencyExchange $currencyExchangeB,  
       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;  
    }  
}

In my opinion, this approach has a number of problems:

  1. The unit has several external dependencies that contain methods with side effects that it can, in theory, call. For example, pull out another unit from the repository and change its state.

  2. Changes to the interface of these dependencies are not controlled by the aggregate, and may require changes to the internal logic of the aggregate.

  3. At the time of implementing business logic, we must think about the details of the interface of external dependencies that are not directly related to the logic of the unit.

  4. In order to test such an aggregate, we would have to mock each of these dependencies and create fixtures for all the data they return, even if the aggregate does not use some of that data.

What if we try to dance from the needs of the unit, and at the stage of implementing the business logic we do not think about where exactly the unit will receive external data from. First, let's describe the unit's needs for external data in the form of an interface, which will be located in the Domain layer next to the unit:

<?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;  
  
}

The implementation of this interface located in the Infrastructure layer will take on all the work of preparing and converting data from external sources into a format convenient for the unit.

That is, in fact, instead of introducing all of the above dependencies directly into the aggregate, we implement them into a wrapper that cuts off unnecessary methods, receives data from these dependencies and converts them into a format convenient for the aggregate.

Thanks to this, the code of the unit itself will be greatly simplified:

<?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!');  
        }  
    }  
}

Moreover, at the stage of writing business code, we may not even think about implementing the outside interface. We can fully implement the logic at the domain level, test it using unit tests, and transfer the outside implementation to another developer who has less experience in using DDD.

What we have as a result:

  • All domain logic is located inside the domain object, and is not spread out among external services

  • The unit has only one external dependency, maximally tailored to the needs of the unit, the interface which it itself defines

  • Creating a mock for this dependency is much easier than creating mocks for several dependencies that are not directly related to the unit.

  • Development can be divided into stages:

    • At the first stage, we implement business logic without thinking about the interfaces and implementation details of the infrastructure that provides us with data for making business decisions

    • At the second stage, we connect the unit via outside to the existing infrastructure and implement those parts of the infrastructure that do not yet exist

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *