Последние 4 года я занимаюсь реализацией проектов на PHP по DDD, используя слоистую архитектуру. Каждый раз я сталкиваюсь с одной из самых насущных проблем DDD: определение границ агрегата. Ведя разработку "как удобно", очень легко не заметить, как вся бизнес логика сосредоточилась в один "божий класс".

В этой статье я поделюсь своим опытом, как проектировать и разрабатывать по DDD, не скатываясь в "один агрегат, чтобы править всеми". Поговорю о проблемах определения границ агрегата и цены чтения, гидрации и содержания больших объектов в памяти PHP процесса.

Введение

При разработке по DDD новый функционал пишется с доменного слоя, без оглядки на то, как будут храниться данные в базе. Это очень удобно и позволяет писать любой код компактно и читаемо, удерживая высокий cohesion.

Одним из самых полезных в этом плане понятий в DDD является агрегат. Напомню кратко: доменный агрегат обязан реализовывать внутри себя все инварианты, всегда держать все свои данные консистентными, и нам запрещено обращаться к составным частям агрегата в обход его корня.

Если вам интересно почитать о каком-то из упомянутых понятий подробнее, то вот ссылки на мои посты об этом:

Несмотря на то, что соблюдение этих правил вызывает трудности у новичков (с чем я периодически сталкиваюсь на ревью), следование этим правилам существенно облегчает разработку за счет того, что у вас под рукой находится все, что вам нужно.

Давайте рассмотрим это на примере программы лояльности (ПЛ). Есть корневой класс LoyaltyProgram, который держит в себе настройки ПЛ для конкретной организации, и различную бизнес-логику.

Следующий класс, который нам понадобится - это LoyaltyCard, инкапсулирующий баланс, связку с клиентом и бизнес-логику работы с начислениями и списаниями. В идеале класс LoyaltyCard должен быть частью агрегата LoyaltyProgram, то есть LoyaltyProgram должен держать в себе (читай, в памяти PHP процесса) все карты.

С точки зрения написания доменного слоя, карта лояльности зависит от ПЛ в каждом своем методе, поскольку ПЛ держит в себе настройки, определяющие специфику работы алгоритмов начисления и списания бонусов.

Давайте взглянем на код:

class LoyaltyCard
{
    public function accrue(int $amount): void
    {
        if ($this->locked) {
            throw new \DomainException('Нельзя начислять накопления на неактивную карту');
        }
        
        $this->balance += $amount;       
    }
}

Это все, на что способен класс LoyaltyCard, и проблема здесь в том, что этот код не реализует все бизнес-требования, а именно:

  • Сумма начисления должна вычисляться исходя из настроек ПЛ.

  • При достижении определенного баланса, уровень карты должен быть повышен. А знания об уровнях ПЛ, разумеется, сосредоточены в самой ПЛ.

Поэтому, полный код начисления на карту может быть помещен только в LoyaltyProgram:

class LoyaltyProgram
{
    private LoyaltyCardCollection $cards;

    public function accrue(Order $order): void
    {
        if (LoyaltyProgramStatus::ACTIVE !== $this->status) {
            throw new \DomainException('Невозможно выполнять начисления при неактивной программе лояльности');
        }

        if (OrderStatus::FINISHED !== $order->getStatus()) {
            throw new \DomainException('Заказ должен быть в статусе "завершен" для начисления в ПЛ');
        }
        
        $clientId = $order->getClientId();
        $card = $this->cards->getByClientId($clientId); //Внутри проверка на существование карты
        $amount = $this->accumulationStrategy->calculate($order);
        $card->accrue($amount);
        $newLevel = $this->levels->getLevelByAmount($card->getBalance());
        if (null !== $newLevel) {
            $card->tryToUpLevel($newLevel);
        }
    }
}

Такой код обладает максимальным cohesion и полностью реализует всю необходимую бизнес-логику.

По аналогии пишутся и все остальные методы работы с картами, включая создание карт и удаление. При таком подходе вся логика работы с ПЛ сосредотачивается в одном классе. LoyaltyProgram становится ответственен за все, что связано с ПЛ, включая такие проверки, как защита от создания карты для клиента, у которого уже есть карта. Последнее достигается за счет того, что ПЛ держит в себе всю коллекцию карт.

Если вам охота глубже погрузиться в то, как устроен код из примера выше, то вот вам ссылки на мои посты:

Как я уже говорил, при таком подходе писать (а позднее и дорабатывать) бизнес-логику становится очень легко, а сам код становится компактным и читаемым. Размещение всей логики внутри агрегата делает код максимально сбитым (высокий cohesion) и не позволяет обойти бизнес-правила в клиентском коде.

Однако, применение такого подхода порождает две проблемы: цены и определения границ агрегата.

Проблема цены

В примере выше программа лояльности (ПЛ) держит в себе (то есть, в памяти PHP процесса) все карты, каковых могут быть десятки тысяч. Уже накладно. А теперь давайте представим, что карта лояльности держит в себе историю всех событий: списаний, начислений, активации и т. д.

class LoyaltyCardChangeLog
{
    private string $cardId;

    /** @var LoyaltyCardChangeLogEntry[] */
    private array $entries = [];
    
    public function accrue(/*...*/): void
    {
        $this->entries[] = new LoyaltyCardChangeLogEntry(/*...*/);
    }    
}

class LoyaltyCard
{
    private LoyaltyCardChangeLog $changeLog;
    
    public function accrue(int $amount): void
    {
        $this->balance += $amount;
        $this->changeLog->accrue(/*...*/);       
    }
}

Понятно, что долгоживущие карты могут накопить сотни событий в истории. Но с точки зрения кода это опять очень удобно и надежно: мы будем добавлять события в историю прямо внутри методов LoyaltyCard, что оставит клиентский код чистым, а также не позволит "забыть" добавить запись в историю, т. к. запись происходит на самом низовом уровне - в самой карте лояльности.

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

Проблема границ

Вторая проблема — это проблема границ. Продолжим наш пример: карта лояльности предоставляет скидку, зависящую от уровня карты («бронза», «серебро», «золото»). Скидка — отдельная сущность со своей бизнес‑логикой, видами скидок и стратегиями их применения к заказу (на весь заказ, на определенные товары и так далее)

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

class LoyaltyCard
{
    private ?Discount $discount

    public function getDiscount(): ?Discount
    {
        if (!$this->isActive()) {
            //Инвариант: нельзя получить скидку по неактивной карте
            return null;
        }
        
        return $this->discount;
    }
}

Но тут возникает закономерный вопрос: как не дойти до того, что весь проект окажется в одном агрегате? Теперь, когда проблемы обозначены, давайте рассмотрим варианты их решения. Начнем с проблемы границ.

Решение проблемы границ

К сожалению, тут нет четких универсальных рецептов. Но есть принципы, которые нужно держать в голове.

Первый и главный: границы агрегатов проходят так, чтобы все инварианты выполнялись. Если для соблюдения инварианта в коде агрегата вам не хватает каких-то данных, значит скорее всего они должны там быть.

Второй принцип: границы агрегата - это границы консистентности. Агрегат обязан всегда содержать консистентные данные.

Третий принцип: транзакционность. Если какие-либо объекты должны изменяться вместе атомарно, то, вероятнее всего, они должны быть частью одного агрегата.

Давайте рассмотрим применение всех этих принципов на примере.

Представьте, что у настройки программы лояльности предполагают переключение разных валют накопления: ночи, мили, рубли, бонусы и т. д. При реализации такого функционала необходимо реализовать инвариант: при изменении в настройках валюты накопления, необходимо сбросить настройки уровней лояльности (бронза, серебро, золото).

Это логично: Представьте, у вас была валюта "ночи" и уровень "серебро" требовал накопить 30 ночей. Потом вы изменили валюту на рубли. Настройку уровня "серебро" в 30 теперь уже рублей необходимо сбросить. Давайте сначала посмотрим на неправильный пример.

class LoyaltyProgramService
{
    public function changeCurrency(LoyaltyCurrency $newCurrency, string $programId): void
    {
        $this->connection->beginTransaction();
        $lp = $this->loyaltyProgramRepository->getById($programId);
        $levels = $this->loyaltyLevelRepository->getByProgramId($programId);
        foreach ($levels as $level) {
            $level->resetRequiredAmmount();
            $this->loyaltyLevelRepository->save($level);
        }
        
        $this->loyaltyProgramRepository->save($lp);
        $this->connection->commit();
    }
}

Здесь уровни лояльности являются отдельными от ПЛ сущностями. Минусы такого решения очевидны:

  • Существует возможность изменить валюту накопления, забыв сбросить уровни лояльности.

  • За транзакцию отвечает сервис, а значит, нужно следить и за тем, чтобы никто не забывал оборачивать весь код с бизнес-логикой в транзакции по всей кодовой базе.

Подробнее в минусах вынесения всей логики в сервисы по сравнению с DDD я писал в отдельной статье на Хабре. Теперь давайте посмотрим на правильный пример:

class LoyaltyProgram
{
    public function changeCurrency(LoyaltyCurrency $newCurrency): void
    {
        if ($newCurrency === $this->currency) {
            return;         
        }
        
        $this->levels->resetRequiredAmounts(); //внутри foreach по уровням
    }
}

class DoctrineLoyaltyProgramRepository implements LoyaltyProgramRepositoryInterface
{
    public function save(LoyaltyProgram $loyaltyProgram): void
    {
        $this->em->beginTransaction();
        try {
            $this->em->persist($loyaltyProgram);
            foreach ($loyaltyProgram->getLevels() as $level) {
                $this->em->persist($level);
            }

            $this->em->flush();
            $this->em->commit();
        } catch (\Throwable $e) {
            $this->em->rollback();
            throw $e;
        }
    }
}

Как видите, в этом примере класс LoyaltyLevel является частью доменного агрегата LoyaltyProgram. Мы включили LoyaltyLevel, руководствуясь теми самыми двумя принципами: инварианта и транзакционности.

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

Это очень важно, обратите на это внимание: в приведенном выше примере объект класса LoyaltyProgram всегда содержит в себе консистентные данные от момента выхода его из репозитория (завершения гидрации) и до завершения PHP процесса.

В этом и состоит одно из ключевых правил DDD: не может быть ни микросекунды времени, когда ваш агрегат содержит неправильные с точки зрения бизнес-логики данные. Сравните это с примером кода выше, где мы написали логику в сервисе. В этом примере мы сначала меняем состояние LoyaltyProgram, а потом LoyaltyLevel. Это недопустимо.

Таким образом, решение о включении LoyaltyLevel в состав агрегата соответствует всем трем принципам: инварианта (нам нужно соблюдать бизнес-правило), консистентности (объект LoyaltyProgram должен всегда держать в себе валидные данные) и транзакционности (ПЛ и ее уровни должны изменяться атомарно).

Теперь давайте посмотрим на еще один пример, уже приводившийся выше:

class LoyaltyCard
{
    private ?Discount $discount

    public function getDiscount(): ?Discount
    {
        if (!$this->isActive()) {
            //Инвариант: нельзя получить скидку по неактивной карте
            return null;
        }
        
        return $this->discount;
    }
}

Класс Discount описывает довольно большое количество собственной логики. Существуют скидки не только по программе лояльности, но и большое количество других скидок, вообще никак не связанных с ПЛ. Так должен ли агрегат LoyaltyProgram держать в себе объект Discount?

Принятие решения по таким пограничным случаям - ответственность разработчика, проектирующего систему. И здесь нет однозначно правильных вариантов.

Первый вопрос, которым стоит задаться, это должно ли изменение состояния LoyaltyProgram вести к изменениям в Discount. Если нет, то можете смело связать два объекта по id, и не включать Discount в состав агрегата.

В реальном проекте, откуда я взял пример с программой лояльности, правила такие: после активации ПЛ должны быть созданы скидки в соответствии с уровнями лояльности. После того, как ПЛ активирована, а скидки созданы, менять настройки ПЛ нельзя, то есть, скидки остаются неизменными.

При такой бизнес-логике атомарное изменение Discount и LoyaltyProgram не требуется, и мы можем связать их по ссылке.

class LoyaltyProgram
{
     public function createDiscounts(): DiscountCollection
     {
        if (!$this->isActive()) {
            throw new \DomainException('Нельзя создать скидки при неактивной ПЛ');
        }

        $levels = $this->getLevels();
        if (empty($levels)) {
            throw new \DomainException('Нельзя создать скидки ПЛ при отсутствии уровней');
        }

        $discounts = [];
        foreach ($levels as $level) {
            $discounts[] = $level->createDiscount(/*...*/);
        }

        return new DiscountCollection(...$discounts);
    }
}

class LoyaltyCard
{
    public function getDiscountId(): ?string
    {
        //Инвариант по-прежнему соблюдается
        if (!$this->isActive()) {
            return null;
        }

        return $this->level?->getDiscountId();
    }
}

Как видите, в примере выше, LoyaltyProgram несет в себе логику создания скидок в зависимости от своих настроек, но не держит в себе экземпляры Discount. При этом существует связь по id между LoyaltyCard и Discount, и бизнес-правило невозможности получения скидки по неактивной карте прописано в LoyaltyCard.

Поскольку класс Discount является отдельной сущностью и содержит в себе все необходимые методы применения к цене (снижения цены), легко написать код, который извлечет объект Discount из хранилища и применит скидку по ПЛ даже при неактивной карте.

То есть, в примере выше мы прописали инвариант, но его довольно легко обойти. Устраивает вас такой компромисс, или нет, решать только вам.

Есть еще один путь, которым можно пойти. Это написание доменного сервиса. Доменные сервисы обычно используются для реализации логики, задействующей разные контексты. Если вы решили, что Discount и LoyaltyProgram - разные агрегаты, то логика применения скидок по ПЛ может быть прописана в таких сервисах.

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

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

Решение проблемы цены

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

Замена коллекций облегченными структурами

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

class Wiki
{
    private WikiStructure $structure;

    public function addMaterial(Material $material): void
    {
        $this->structure->addMaterial($material);
    }    
}

class WikiStructure
{
    private string $wikiId;
    private array $pathsByMaterialId = [];
    private array $materialIdsByFullNames = [];

    public function addMaterial(Material $material): void
    {
        $this->pathsByMaterialId[$material->getId()] = $material->getPath();
        $this->materialIdsByFullNames[$material->getFullName()] = $material->getId();
        DomainEventPublisher::instance()->publish(new MaterialAdded($material));
    }
}

Как видите, в приведенном примере WikiStructure не хранит все материалы, а только информацию об их id, именах и путях.

При этом материал остается частью агрегата Wiki, а транзакционность достигается с помощью двух средств: доменных событий и класса, агрегирующего изменения в структуре Wiki. В моей статье на Хабре более подробно рассказывается о доменных событиях, классе DomainEventPublisher и о том, как реализовываются обработчики. Так что, здесь я на этом останавливаться не буду.

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

class DoctrineEntityChangesObserver
{
    private static ?self $instance = null;
    
    /** @var Material[] */
    private array $newMaterials = [];

    public static function instance(): static
    {
        if (null === static::$instance) {
            static::$instance = new static();
        }

        return static::$instance;
    }
    
    public function newMaterial(Material $material): void
    {
        $this->newMaterials[] = $material;        
    }
    
    /**
     * @return Material[]
     */
    public function getNewMaterials(): array
    {
        return $this->newMaterials;        
    }
}

Хоть он и называется Observer, с одноименным паттерном он имеет мало общего, но, как видите, реализует Singleton. Для того, чтобы вновь созданный материал сохранялся вместе с Wiki, нам в обработчике события MaterialAdded нужно отметить новый материал:

class MaterialAddedSubscriber implements DomainEventSubscriberInterface
{
    public function isSubscribedTo(DomainEvent $event): bool
    {
        return $event instanceof MaterialAdded;
    }

    public function handle(DomainEvent $event): void
    {
        /** @var MaterialAdded $event */
        DoctrineEntityChangesObserver::instance()->newMaterial($event->material);
    }
}

При таком подходе WikiStructure хранит в себе актуальные метаданные относительно всех материалов, а DoctrineEntityChangesObserver держит материалы, созданные в рамках текущего процесса. При сохранении агрегата в репозитории мы обеспечим атомарное сохранение и того и другого.

class DoctrineWikiRepository implements WikiRepositoryInterface
{
    public function save(Wiki $wiki): void
    {
        $this->em->beginTransaction();
        try {
            $this->em->persist($wiki);
            $changesObserver = DoctrineEntityChangesObserver::instance();
            foreach ($changesObserver->getNewMaterials() as $material) {
                $this->em->persist($material);
            }

            //...

            $this->em->flush();
            $this->em->commit();
        } catch (\Throwable $e) {
            $this->em->rollback();
            throw $e;
        }
    }
}

Такой подход позволяет избежать хранения в памяти всех материалов Wiki, однако у него есть ограничения. Если вернуться к примеру с программой лояльности, то там есть такой код.

class LoyaltyProgram
{
    public function accrue(Order $order): void
    {
        $clientId = $order->getClientId();
        $card = $this->cards->getByClientId($clientId);
        $amount = $this->accumulationStrategy->calculate($order);
        $card->accrue($amount);
        $newLevel = $this->levels->getLevelByAmount($card->getBalance());
        if (null !== $newLevel) {
            $card->tryToUpLevel($newLevel);
        }
    }
}

Как видите, здесь изменяются свойства вложенного объекта LoyaltyCard, что, в принципе, тоже можно было бы обработать событиями, хотя код от этого стал бы запутаннее. Но проблема еще и в том, что LoyaltyCard имеет собственную логику, вызываемую в методе accrue(), а значит нам все-таки нужен полноценный инстанс этого класса.

Для обхода этой проблемы можно пойти как минимум двумя способами.

Перенос транзакционности в слой Application

Более простой, хотя и немного ломающий DDD путь, состоит в том, чтобы отказаться от хранения карт в LoyaltyProgram, но оставить все операции, связанные с картами, в классе корня агрегата. Выглядеть это будет вот так:

class LoyaltyProgram
{
    public function accrue(
        LoyaltyCard $card,
        Order $order,
    ): void {
        //...
        $amount = $this->accumulationStrategy->calculate($order);
        $card->accrue($amount);
        $this->tryToUpCardLevelAutomatically($card);
    }
    
    public function createCard(string $id, Customer $customer): LoyaltyCard
    {
        //...
    }
    
    public function mergeCards(LoyaltyCard $main, LoyaltyCard ...$secondary): void
    {
        //...
    }
}

При таком подходе логически карта остается частью ПЛ: вы декларируете это на уровне операций. Любые действия с картой совершаются через интерфейс LoyaltyProgram. Но физически LoyaltyProgram не держит в себе все карты и вы возлагаете на слой Application ответственность вытащить карту из репозитория и обернуть в транзакцию сохранение ПЛ и карты. Про то, как строится слой Application можно подробнее прочитать мой пост, а вот пример кода:

class CreateLoyaltyCard
{
    public function __construct(
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly LoyaltyProgramRepositoryInterface $programRepository,
        private readonly LoyaltyCardRepositoryInterface $cardRepository,
        private readonly IdGeneratorInterface $idGenerator,
        private readonly TransactionalSessionInterface $transactionalSession,
    ) {
    }

    public function execute(CreateLoyaltyCardParams $params): void
    {
        $customer = $this->customerRepository->getById($params->customerId);
        $card = $this->cardRepository->findByCustomerId($customer->getId());
        if (null !== $card) {
            throw new \DomainException("Loyalty card already exists.");
        }

        $lp = $this->programRepository->getByCompanyId($customer->getCompanyId());
        $card = $lp->createCard($this->idGenerator->generate(), $customer);
        $this->transactionalSession->transactional(function () use ($card, $lp) {
            $this->cardRepository->save($card);
            $this->programRepository->save($lp);
        });
    }
}

Этот путь является наиболее простым с точки зрения реализации: вам не нужно придумывать облегченные структуры данных и заботиться об их поддержании в актуальном состоянии. Вы сохраняете инварианты внутри кода агрегата, но выносите транзакционность на уровень выше: в слой Application.

К сожалению, часть бизнес-логики у вас неизбежно тоже поднимется в слой Application. В данном случае в Application команду уехала проверка о том, что нельзя дважды выдать карту одному гостю. Если бы класс LoyaltyProgram держал все карты в себе, то эта проверка была бы внутри его метода createCard(). Здесь, как видите, мы расплатились снижением cohesion и отходом от правил DDD.

В книге Domain-Driven Design in PHP предлагается еще более радикальный подход: открывать транзакцию в момент начала обработки запроса фреймворком и коммитить ее после окончания обработки. Лично я такой подход никогда не применял и предпочитаю идти путем из примера выше.

Ленивые коллекции

Последнее решение, о котором стоит сказать - это ленивые коллекции. По сути, это развитие идеи облегченной структуры с метаданными, о которой я писал выше. Ленивая коллекция может хранить в себе метаданные, например список id всех элементов, и предоставлять операции для доступа к отдельным элементам, например, getById().

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

Это значит, что нам необходимо в инфраструктурном слое перехватывать вызов методов коллекции типа getById() и загружать требуемый элемент из хранилища. Сделать это можно с помощью все тех же событий. Вот набросок кода. Повторюсь, на практике это не опробовано, но работать должно.)

namespace App\Domain\Model\LoyaltyCard;

class LoyaltyCardCollection
{
    /** @var array<string, ?LoyaltyCard> key - id */
    private array $elements = [];
    
    public function getById(string $id): LoyaltyCard
    {
        if (!isset($this->elements[$id])) {
            throw new \DomainException('LoyaltyCardCollection does not contain element ' . $id);        
        }
        
        DomainEventPublisher::instance()->publish(new LoyaltyCardRequested($this, $id));
        
        return $this->elements[$id]; 
    }
    
    public function initCard(LoyaltyCard $card): void
    {
        if (!isset($this->elements[$card->getId()])) {
            throw new \LogicException('Attempting to init card which id not represented in collection');        
        }
        
        if (null !== $this->elements[$card->getId()]) {
            throw new \LogicException('Attempting to init card twice');      
        }
        
        $this->elements[$card->getId()] = $card;
    }
}
namespace App\Infrastructure\EventListener\Domain;

use App\Domain\Events\DomainEvent;
use App\Domain\Events\DomainEventSubscriberInterface;
use App\Domain\Event\LoyaltyCardRequested;
use App\Domain\Model\LoyaltyCard\LoyaltyCardRepositoryInterface;

class LoyaltyCardRequestedSubscriber implements DomainEventSubscriberInterface
{
    public function __construct(
        private LoyaltyCardRepositoryInterface $cardRepository
    ) {
    }
    
    public function isSubscribedTo(DomainEvent $event): bool
    {
        return $event instanceof LoyaltyCardRequested;
    }

    public function handle(DomainEvent|LoyaltyCardRequested $event): void
    {
        $card = $this->cardRepository->getById($event->cardId); 
        $event->collection->initCard($card);
    }
}

В приведенном выше примере при вызове метода LoyaltyCardCollection::getById() происходит выброс события LoyaltyCardRequested. Поскольку DomainEventPublisher работает синхронно, сразу же вызывая все обработчики события, то управление будет передано в класс LoyaltyCardRequestedSubscriber, который вытащит карту из хранилища и передаст ее в коллекцию. После этого выполнение вернется в метод LoyaltyCardCollection::getById() и коллекция отдаст карту так, будто она всегда там и была.

На этом, пожалуй, и все. Я попытался осветить проблему границ доменного агрегата парой постов в своем канале, но быстро вышел за предельный размер поста, отчего и появилась эта статья. И все равно, получившийся текст не охватывает проблему целиком, давая лишь общие ориентиры и самые очевидные решения. Тем не менее, я надеюсь, что вам было интересно и полезно. Как всегда, спасибо всем, кто дочитал до конца и до новых встреч.)

Комментарии (0)