如何对将在多个步骤中创建的聚合进行建模,例如向导样式

Dav*_*ang 6 c# domain-driven-design aggregateroot

我将以Airbnb为例。

注册爱彼迎账户后,您可以通过创建房源成为房东。要创建房源,Airbnb UI 将指导您分多个步骤完成创建新房源的过程:

在此处输入图片说明

它还会记住您走过的最远的一步,所以下次当您想继续该过程时,它会重定向到您离开的地方。


我一直在努力决定是否应该将列表作为聚合根,并将方法定义为可用步骤,还是将每个步骤视为它们自己的聚合根,以便它们很小?

列为聚合根

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

    ...
}
Run Code Online (Sandbox Code Playgroud)

聚合根中的大多数复杂类只是值对象,ListingStatus只是一个简单的枚举:

public enum ListingStatus : int
{
    Draft = 1,
    Published = 2,
    Unlisted = 3,
    Deleted = 4
}
Run Code Online (Sandbox Code Playgroud)

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

    ...
}
Run Code Online (Sandbox Code Playgroud)

在列表聚合根中包含所有内容的好处是可以确保所有内容都具有事务一致性。并且这些步骤被定义为领域关注点之一。

缺点是聚合根很大。在每个步骤中,为了调用列表操作,您必须加载包含所有内容的列表聚合根。

对我来说,听起来除了地理位置调整可能取决于房产地址,其他步骤并不相互依赖。例如,listing 的标题和描述与您上传的照片无关。

所以我在想我是否可以把每一步都当作它们自己的聚合根?

每一步都作为自己的聚合根

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

...
Run Code Online (Sandbox Code Playgroud)

将每个步骤作为自己的聚合根的好处是它使聚合根变小(在某些方面我什至觉得它们太小了!)所以当它们被持久化回数据存储时,性能应该更快。

缺点是我失去了列表聚合的事务一致性。例如,列表地理位置聚合仅通过 Id 引用列表。我不知道我是否应该在那里放置一个列表值对象,以便我可以在上下文中获得更多有用的信息,例如最后一步、列表状态等。

关闭为基于意见?

我在网上找不到任何示例来展示如何在 DDD 中对这种类似向导的样式进行建模。我发现的关于将一个巨大的聚合根分成多个较小的根的大多数例子都是关于一对多的关系,但我这里的例子主要是关于一对一的关系(可能除了照片)。

我认为我的问题不会基于意见,因为

  1. 在 DDD 中建模聚合的方法是有限的
  2. 我已经介绍了一个具体的商业模式airbnb,作为一个例子。
  3. 我列出了我一直在考虑的两种方法。

你可以建议我哪一种方法,你会采取和原因,或者其他方法来自两个我列出不同的原因

Fun*_*unk 3

让我们讨论一下拆分大型集群聚合的几个原因:

  • 多用户环境中的事务问题。在我们的例子中,只有一个人Host管理Listing. 其他用户只能发表评论。作为单独的聚合进行建模Review允许根上的事务一致性Listing
  • 性能和可扩展性。一如既往,这取决于您的具体用例和需求。不过,一旦Listing创建后,您通常会查询整个列表,以便将其呈现给用户(除了折叠的评论部分之外)。

现在让我们看一下值对象的候选者(不需要身份):

  • 地点
  • 便利设施
  • 描述和标题
  • 设置
  • 可用性
  • 价格

请记住,将内部部件限制为值对象是有好处的。其一,它大大降低了整体复杂性。

至于向导部分,关键是需要记住当前步骤:

...,所以下次当您想恢复该过程时,它将重定向到您离开的位置。

由于聚合体在概念上是持久性的单位,因此从上次中断的地方恢复将需要我们持久保存部分水合的聚合体。您确实可以将 a 存储ListingStep在聚合上,但是从域的角度来看这真的有意义吗?和之前Amenities 需要指定吗?这真的是一个总体问题吗?或者可以将其转移到服务中吗?当所有s 都是通过使用同一个 Service 创建时,该 Service 可以轻松确定上次中断的位置。DescriptionTitleListingListing

将这种向导方法引入域模型感觉违反了关注点分离原则。B&B 领域专家很可能对向导流程漠不关心。

考虑到上述所有因素,Listingas 聚合根似乎是一个不错的起点。


更新

我认为向导是 UI 的概念,而不是域的概念,因为理论上,由于每个步骤不依赖于其他步骤,因此您可以按任何顺序完成任何步骤。

事实上,这些步骤是独立的,这清楚地表明,在数据输入的顺序上,聚合所带来的真正的不变量并不存在。在这种情况下,它甚至不是域问题。

我可以毫无问题地将这些步骤建模为它们自己的聚合根,并让 UI 确定上次停止的位置。

向导步骤(页面)不应映射到其自己的聚合。在 DDD 之后,用户操作通常会转发到应用程序 API/服务,而应用程序 API/服务又可以将工作委托给域对象和服务。应用程序服务只关心技术/基础设施(例如持久性),而领域对象和服务拥有丰富的领域逻辑和知识。这通常称为洋葱或六边形架构。请注意,依赖项是向内的,因此域模型不依赖于其他任何东西,也不知道其他任何东西。

洋葱拱门

思考向导的另一种方式是,它们基本上是数据收集器。通常在最后一步会进行某种处理,但之前的所有步骤通常只是收集数据。您可以使用此功能在用户(过早)关闭向导时包装所有数据,将其发送到应用程序 API,然后合并聚合并将其保留到用户下次出现。这样您只需要在页面上执行基本验证,而不涉及真正的域逻辑。

我对这种方法唯一担心的是,当所有步骤都填写完毕并且列表准备好接受审查和发布时,谁负责?我考虑过列表聚合,但它没有包含所有信息。

这就是应用程序服务作为工作委托者发挥作用的地方。它本身不具备真正的领域知识,但它“了解”所有涉及的参与者并可以将工作委托给他们。它不是一个未绑定的上下文(没有双关语),因为您希望将事务范围一次限制为一个聚合。如果没有,您将不得不诉诸两个阶段提交,但那是另一个故事了。

总而言之,您可以存储ListingStatusonListing并将其背后的不变量作为根聚合的责任。因此,它应该拥有所有信息,或者与其一起提供,以进行ListingStatus相应的更新。换句话说,这与向导步骤无关,而是与描述聚合背后的过程的名词和动词有关。在这种情况下,输入保护所有数据的不变量,并且数据当前处于要发布的正确状态。从那时起,返回并保留仅具有部分状态或以不连贯的方式的聚合是非法的。