Pattern Aggregate Outside

Pattern Aggregate Outside

Ruslan Hnatovsky aka @Number55 in his article When neither there, nor here, or in search of the optimal border of the Domain layer, described the well-known problem of business logic leaking from the aggregate, if this logic depends on data that is outside the aggregate, and offered several solutions to this problem , each of which is not without flaws. Many of these shortcomings have been described in the article as well as in the comments, so I will not duplicate this information here, but will try to offer a solution that is free of these shortcomings.

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

Suppose we have a currency exchange service. And there is an aggregate application for currency exchange (Bid). This application has the following business rules:

  1. User cannot exchange more than 1000 dollars per day

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

  3. The 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 the business requirements, we need data that is outside the aggregate Bid. Only option number 2 in Ruslan’s article (using the repository in the aggregate) allows you to make these checks inside the aggregate. Since there are a few checks in addition to the repository, we need to implement a few more dependencies.

The repository itself with the method of receiving user applications 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;  
}

A 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 test all the requirements inside the aggregate, we’ll have to implement all these dependencies, resulting in code that looks 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 $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;  
    }  
}

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

  1. The aggregate has several external dependencies that contain methods with side effects that it could theoretically cause. For example, extract another unit from the repository and change its state.

  2. Changing the interface of these dependencies is not controlled by the aggregate and may require changes to the internal logic of the aggregate.

  3. At the time of implementation of business logic, we should think about the details of the interface of external dependencies, which are not directly related to the logic of the aggregate.

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

And if we try to dance from the needs of the aggregate, and at the stage of business logic implementation, we will not think about where exactly the aggregate will receive external data. First, let’s describe the aggregate’s needs for external data in the form of an interface that will be located in the Domain layer next to the aggregate:

<?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 is placed in the Infrastructure layer and will take over 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 the above-mentioned dependencies directly into the aggregate, we will introduce them into some 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 think at all about the implementation of the outside interface. We can fully implement the logic at the domain level, test it with unit tests, and leave the outside implementation to another developer with less experience in DDD.

What we have as a result:

  • All domain logic is inside the domain object, and not spread across external services

  • The aggregate has only one external dependency, which is maximally sharpened for the needs of the aggregate, the interface that it defines itself

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

  • Development can be divided into stages:

    • At the first stage, we implement business logic, without thinking about interfaces and details of infrastructure implementation, which 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

Related posts