Как моделировать агрегаты, которые будут создаваться в несколько этапов, например, в стиле мастера.

Я буду использовать Airbnb в качестве примера.

Зарегистрировав учетную запись Airbnb, вы можете стать хозяином, создав объявление. Чтобы создать объявление, пользовательский интерфейс Airbnb проведет вас через процесс создания нового объявления в несколько этапов:

введите здесь описание изображения

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


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

Листинг как совокупный корень

public sealed class Listing : AggregateRoot
{
    private List<Photo> _photos;

    public Host Host { get; private set; }
    public PropertyAddress PropertyAddress { get; private set; }
    public Geolocation Geolocation { get; private set; }
    public Pricing Pricing { get; private set; }
    public IReadonlyList Photos => _photos.AsReadOnly();
    public ListingStep LastStep { get; private set; }
    public ListingStatus Status { get; private set; }

    private Listing(Host host, PropertyAddress propertyAddress)
    {
        this.Host = host;
        this.PropertyAddress = propertyAddress;
        this.LastStep = ListingStep.GeolocationAdjustment;
        this.Status = ListingStatus.Draft;

        _photos = new List<Photo>();
    }

    public static Listing Create(Host host, PropertyAddress propertyAddress)
    {
        // validations
        // ...
        return new Listing(host, propertyAddress);
    }

    public void AdjustLocation(Geolocation newGeolocation)
    {
        // validations
        // ...
        if (this.Status != ListingStatus.Draft || this.LastStep < ListingStep.GeolocationAdjustment)
        {
            throw new InvalidOperationException();
        }
 
        this.Geolocation = newGeolocation;
    }

    ...
}

Большинство сложных классов в корне агрегата — это просто объекты-значения, а ListingStatus — простое перечисление:

public enum ListingStatus : int
{
    Draft = 1,
    Published = 2,
    Unlisted = 3,
    Deleted = 4
}

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

using Ardalis.SmartEnum;

public abstract class ListingStep : SmartEnum<ListingStep>
{
    public static readonly ListingStep GeolocationAdjustment = new GeolocationAdjustmentStep();
    public static readonly ListingStep Amenities = new AmenitiesStep();
    ...

    private ListingStep(string name, int value) : base(name, value) { }

    public abstract ListingStep Next();

    private sealed class GeolocationAdjustmentStep : ListingStep
    {
        public GeolocationAdjustmentStep() :base("Geolocation Adjustment", 1) { }

        public override ListingStep Next()
        {
            return ListingStep.Amenities;
        }
    }

    private sealed class AmenitiesStep : ListingStep
    {
        public AmenitiesStep () :base("Amenities", 2) { }

        public override ListingStep Next()
        {
            return ListingStep.Photos;
        }
    }

    ...
}

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

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

Для меня это звучит так, как если бы настройка геолокации не зависела от адреса объекта, другие шаги не зависят друг от друга. Например, название и описание объявления не имеют значения, какие фотографии вы загружаете.

Итак, я подумал, могу ли я рассматривать каждый шаг как свои собственные совокупные корни?

Каждый шаг как собственный совокупный корень

public sealed class Listing : AggregateRoot
{
    public Host Host { get; private set; }
    public PropertyAddress PropertyAddress { get; private set; }

    private Listing(Host host, PropertyAddress propertyAddress)
    {
        this.Host = host;
        this.PropertyAddress = propertyAddress;
    }

    public static Listing Create(Host host, PropertyAddress propertyAddress)
    {
        // Validations
        // ...
        return new Listing(host, propertyAddress);
    }
}

public sealed class ListingGeolocation : AggregateRoot
{
    public Guid ListingId { get; private set; }
    public Geolocation Geolocation { get; private set; }

    private ListingGeolocation(Guid listingId, Geolocation geolocation)
    {
        this.ListingId = listingId;
        this.Geolocation = geolocation;
    }

    public static ListingGeolocation Create(Guid listingId, Geolocation geolocation)
    {
        // Validations
        // ...
        return new ListingGeolocation(listingId, geolocation);
    }
}

...

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

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

Закрыть как основанный на мнении?

Я не могу найти ни одного примера в Интернете, где показано, как смоделировать этот волшебный стиль в DDD. Кроме того, большинство примеров, которые я нашел о разделении огромных совокупных корней на несколько более мелких, относятся к отношениям «один ко многим», но мой пример здесь в основном относится к отношениям «один к одному» (возможно, за исключением фотографий).

Я думаю, что мой вопрос не будет основан на мнении, потому что

  1. Существует только ограниченное количество способов моделирования агрегатов в DDD.
  2. В качестве примера я представил конкретную бизнес-модель airbnb.
  3. Я перечислил 2 подхода, о которых я думал.

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


person David Liang    schedule 08.02.2021    source источник
comment
Краткий ответ в качестве комментария. Прочтите о шаблоне Builder и State Machine.   -  person Adam Jachocki    schedule 08.02.2021
comment
@AdamJachocki: можете ли вы опубликовать свой ответ с примерами? Я читал об этих схемах.   -  person David Liang    schedule 08.02.2021
comment
этот вопрос не должен основываться на мнении   -  person sta    schedule 08.02.2021


Ответы (4)


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

  • Транзакционные проблемы в многопользовательских средах. В нашем случае есть только один Host, управляющий Listing. Только отзывы могут быть размещены другими пользователями. Моделирование Review как отдельного агрегата обеспечивает согласованность транзакций в корне Listing.
  • Производительность и масштабируемость. Как всегда, это зависит от вашего конкретного случая использования и потребностей. Хотя после создания Listing вы обычно запрашиваете весь список, чтобы представить его пользователю (за исключением, возможно, свернутого раздела отзывов).

Теперь давайте посмотрим на кандидатов на объекты-значения (не требующие идентификации):

  • Место расположения
  • Удобства
  • Описание и название
  • Настройки
  • Доступность
  • Цена

Помните, что есть преимущества в ограничении внутренних частей как объектов-значений. Во-первых, это значительно снижает общую сложность.

Что касается части мастера, ключевой момент заключается в том, что текущий шаг необходимо запомнить:

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

Поскольку агрегаты концептуально являются единицей сохраняемости, возобновление с того места, где вы остановились, потребует от нас сохранения частично гидратированных агрегатов. Вы действительно можете хранить ListingStep в агрегате, но имеет ли это смысл с точки зрения предметной области? Нужно ли нужно указывать Amenities перед Description и Title? Действительно ли это касается агрегата Listing или его можно переместить в службу? Когда все Listing создаются с помощью одного и того же Сервиса, этот Сервис может легко определить, где он остановился в прошлый раз.

Использование этого волшебного подхода в модели предметной области выглядит как нарушение принципа разделения интересов. Эксперты домена B&B вполне могут быть безразличны к потоку мастера.

Принимая во внимание все вышеперечисленное, Listing как совокупный корень кажется хорошим местом для начала.


ОБНОВЛЕНИЕ

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

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

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

Шаги (страницы) мастера не должны сопоставляться с собственными агрегатами. После DDD действия пользователя обычно перенаправляются в прикладной API/службу, которая, в свою очередь, может делегировать работу объектам и службам домена. Служба приложений занимается только техническими/инфраструктурными вещами (например, постоянством), тогда как объекты и службы предметной области содержат богатую логику и знания предметной области. Это часто называют луковой или шестиугольной архитектурой. Обратите внимание, что зависимости указывают внутрь, поэтому модель предметной области ни от чего не зависит и ни о чем другом не знает.

Луковая арка

Другой способ думать о волшебниках состоит в том, что они в основном являются сборщиками данных. Часто на последнем этапе выполняется какая-то обработка, но все предыдущие этапы обычно просто собирают данные. Вы можете использовать эту функцию, чтобы обернуть все данные, когда пользователь закрывает мастер (преждевременно), отправить их в API приложения, а затем увлажнить агрегат и сохранить его до следующего раза, когда пользователь придет в себя. Таким образом, вам нужно только выполнить базовую проверку на страницах, но никакая реальная логика домена не задействована.

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

Именно здесь в игру вступает Application Service как делегатор работы. Сам по себе он не обладает реальным знанием предметной области, но знает всех вовлеченных игроков и может делегировать им работу. Это не несвязанный контекст (не каламбур), так как вы хотите, чтобы область транзакций ограничивалась одним агрегатом за раз. Если нет, вам придется прибегнуть к двухэтапным коммитам, но это уже другая история.

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

person Funk    schedule 10.02.2021
comment
Я думал, что мастер — это концепция пользовательского интерфейса, а не домена, потому что теоретически, поскольку каждый шаг не зависит от других, вы можете завершить любой шаг в любом порядке. У меня нет проблем с моделированием этих шагов как их собственных сводных корней, и пользовательский интерфейс определяет, где он остановился в прошлый раз. Единственное, что меня беспокоит в связи с этим подходом, заключается в том, что, когда все этапы пройдены и листинг готов к рассмотрению и публикации, кто будет нести за это ответственность? Я думал об объединении листинга, но в нем нет всей информации. - person David Liang; 11.02.2021
comment
@DavidLiang Я обновил свой пост, чтобы ответить на ваши вопросы. - person Funk; 11.02.2021
comment
спасибо за обновления. Таким образом, если каждый шаг не зависит от других, я могу смоделировать их как отдельные агрегаты, а пользовательский интерфейс и служба приложений определят последний шаг. Я использую CQRS, поэтому при создании пользовательского интерфейса я могу искать постоянное хранилище и выяснять, какие данные отсутствуют, поэтому я могу выяснить, какой был самый дальний шаг. Я вполне согласен с этим. Мое последнее беспокойство: когда все на каждом шаге заполнено и листинг готов к публикации, какой модели предметной области служба приложения должна делегировать работу? - person David Liang; 14.02.2021
comment
Я все еще сомневаюсь, стоит ли мне иметь концепцию чернового листинга и опубликованного листинга. Поэтому, когда все на каждом шаге заполнено, служба приложений может делегировать работу опубликованному списку. При таком подходе мне не нужно сохранять статус в сводном листинге, поэтому каждому агрегату, смоделированному на каждом этапе, не нужно иметь дело со статусом, потому что они знают, что находятся в черновом контексте листинга. Моя проблема с этим подходом заключается в том, что при переходе от черновика списка к опубликованному списку мне нужно сохранить личность (т. Е. Использовать идентификатор черновика списка в качестве идентификатора опубликованного списка). - person David Liang; 15.02.2021
comment
Если у меня нет концепции черновика и опубликованного листинга, то я могу предположить, что листинг будет иметь статус, и на последнем этапе служба приложений делегирует работу агрегату листинга, который установит его статус как опубликованный. . Моя проблема в том, что для каждого дочернего агрегата, который я моделирую каждый шаг, ему нужен не только идентификатор листинга, но и статус листинга. Даже я могу смоделировать их как объект-значение, кто отвечает за создание этого объекта-значения (id + статус)? Репозиторий листинга? Для меня не имеет смысла, если у него есть метод, возвращающий только объект значения. - person David Liang; 15.02.2021

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

person choquero70    schedule 11.02.2021
comment
Как я уже сказал в другом комментарии, я подумал, что многоэтапность — это проблема пользовательского интерфейса, поэтому эти шаги можно смоделировать как их собственные агрегаты. У меня вопрос: когда все этапы выполнены и листинг готов к рассмотрению и публикации, какой совокупный корень отвечает за него? Корень агрегата листинга, который имеет начальный метод create(), не имеет информации о другом шаге. Я также подумал о том, что может быть 2 ограниченных контекста: черновой листинг и листинг. Только черновой листинг имеет стиль мастера. - person David Liang; 11.02.2021

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

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

список не обязательно должен быть глобальным,
вы можете отобразить списки, для которых у вас есть вся необходимая информация из «Контекста списков» и которые доступны для периода поиска и т. д.

person Amokrane Belloui    schedule 15.02.2021
comment
Тот волшебник AirBnb был просто примером. Разрабатываю ли я свою систему на основе пользовательского интерфейса или нет, это не было предметом моего вопроса. Допустим, у меня есть требование к домену, согласно которому листинг или вообще любая огромная сводная информация должны быть заполнены поэтапно. Как бы вы сделали это в DDD? - person David Liang; 03.03.2021

По моему опыту, DDD была методологией проектирования, возникшей из культуры того, что мы сейчас называем моделированием внутренних данных Java. С тех пор современная веб-разработка значительно повзрослела и развилась благодаря фреймворкам Angular/React/Vue, которые имеют свои собственные парадигмы моделирования данных. Исходя из опыта работы с UX, я подробно расскажу о том, как структурировать компоненты пользовательского интерфейса, которые интегрируются с моделями DDD.

Отдельные данные от презентации

Здесь работает MVC-дизайн. Наивно конечным результатом этого рабочего процесса является построение модели предметной области Listing. Но я уверен, что модель домена AirBnB для листинга намного сложнее. Давайте аппроксимируем это, рассматривая каждый шаг как форму построения независимых моделей. Для упрощения рассмотрим только модели для Photo и Location..

Class Photo:           Class Location:
  id                    guid
  src                   geolocation

Предоставьте представление для каждой модели

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

Class PhotoView:       Class LocationView:
  id                       guid
  src                      geolocation
  valid { get }            valid { get }

Определить контроллер

Теперь рассмотрим модель представления WizardView, которая поможет организовать независимые представления в поведении мастера. У нас уже есть независимые представления, которые заботятся о действительном/недействительном состоянии. Теперь нам просто нужно представление о текущем шаге. В пользовательском интерфейсе AirBnb кажется, что текущий шаг больше похож на выбранное состояние, когда элемент списка развернут, а все остальные свернуты. В любом случае переход на всю страницу или выбранный представляет собой одно и то же состояние этого шага — активно ‹-› все остальные неактивны. Если _selected равно null, пройдите steps[] для первого недопустимого шага, в противном случае null ‹--› все допустимо.

StepView может отображать целую страницу или, в случае AirBnb, один элемент списка, где status == view.valid.

Class WizardView:          Class StepView:
   steps[]                    title
   _selected                  view
   selected { get set }       status { get }
   addStep(StepView)
   submit()

submit() представляет любую обработку, которую вы хотите инициировать, когда все шаги допустимы и модели предметной области могут быть построены. Обратите внимание, как я отложил фактическое создание любой реальной модели предметной области и сохранил только формы или черновики структур данных в представлениях. Только во время submit(), либо при нажатии кнопки, либо в качестве обратного вызова, когда происходит событие all valid, эти представления всплывают в виде всплывающих окон данных, скорее всего, для выполнения запроса к серверу. Здесь вы можете создать модель более высокого уровня Listing и сделать ее полезной нагрузкой запроса. Однако общение с серверной частью не входит в обязанности мастера. Он просто объединяет все данные вместе для правильного обработчика для создания действительного запроса.

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

person jlau    schedule 17.02.2021
comment
Спасибо за ваше время, чтобы изучить это. Возможно, мой вопрос был недостаточно ясен. Я не думаю, что упомянул в своем OP, но я использую подход CQRS, поэтому у моего внешнего интерфейса нет проблем с запросом непосредственно в базу данных и выяснением, какие шаги отсутствуют при создании черновика списка. У меня также есть службы приложений в качестве определенных вариантов использования, поэтому, когда пользователь нажимает кнопку отправки в форме на каждом шаге, пользовательский интерфейс (мое приложение ASP.NET Core MVC) вызывает соответствующий вариант использования. Затем в каждом варианте использования служба приложения управляет рабочим процессом и решает, какую модель предметной области использовать. - person David Liang; 17.02.2021
comment
Мой вопрос касается моделирования предметной области. Я вижу плюсы и минусы с обеих сторон: смоделируйте каждый шаг как отдельный агрегат или просто создайте один большой агрегат под названием Listing, в котором хранится статус и последний шаг. - person David Liang; 17.02.2021