DDD设计聚合体的权衡过程 | Matt Bentley


本文针对的是对DDD和聚合体有一定工作经验的人,这些例子使用了C#。然而,如果你熟悉任何OO语言,那么你也能跟上。
可以在此处查看 GitHub 

问题域
首先,我们需要一个问题域来探索。我为一家假公司 DDDMart 创建了一个小型电子商务应用程序:
需要添加产品评论。

业务需求

  • 评论必须具有关联的Order和Customer。
  • 评论的评分必须介于 1-5 之间。
  • 必须留下评论。它应该少于 200 个字符。
  • 如果客户不到六个月大,他们可以回复评论。

子域
该域由三个子域组成,这些子域被构造为独立的微服务:

  • 目录(核心)——产品目录
  • 订单(核心)——用于创建一个Basket和创建一个Order不同的Products
  • 付款(支持)- 用于生成和跟踪发票以及存储客户付款方式

战略设计
第一步是使用战略设计来分析问题领域。事件风暴是一个很好的方法,可以找出任何额外的需求,并帮助设计我们的有界环境(BC:界限上下文、有界上下文)。

新的”评论Review“概念将驻留在它自己的BC中。目录子域似乎是最符合逻辑的地方,但是如果随着时间的推移,复杂性增加,我们可能想把它移到自己的域和/或微服务中。

战术设计
现在我们已经准备好开始了,有一些原则可以帮助指导我们完成这一过程。在做决定时,我们应该始终考虑到这些原则。

关键原则:

  • 倾向于 "始终验证有效的领域 "的方法:在我们的聚合中执行不变量(业务规则)有不同的方法:尝试始终将聚合保持在验证有效的状态,而不是使用延迟验证方法,这样才能把事情简单化。
  • 使聚合尽可能小:尽可能地保持聚合的规模,臃肿的聚合最终会变得更加复杂,并且通常会产生性能问题。
  • 聚合应该能够验证其所有的不变性:最后两个是最重要的概念,可以帮助你决定聚合的大小。你希望它们尽可能的小,同时仍然能够满足/执行其业务规则,例如,一辆汽车必须有四个轮子。如果试图将其建模为一个聚合体,那么车轮必须是汽车聚合体中的一个实体,以便能够实现事物本身这一自然的不变性。(离开车轮的汽车不能叫汽车,也许是轮船了,事物变异了)
  • 偏爱值对象:当人们开始使用DDD时,通常不愿意使用值对象,因为它们似乎不如实体那么熟悉。值对象的功能非常强大,你应该将其作为默认的构建模块,只有在无法使用值对象时才切换到实体,而不是相反,这也是人们通常的出发点。
  • 以高测试覆盖率为目标: 记住你的领域是你的应用程序的核心。确保它有一套广泛的单元测试,覆盖你所有的不变因素。
  • 对变化持开放态度:为聚合体建模是很难的。真的很难!我们没有人第一次就能做对,所以要记住这一点,对变化持开放态度。

初始设计
这可能是我们第一次尝试创建聚合。我们知道我们的聚合应该尽可能小,所以让我们将Review和Response拆分为单独的聚合。我们也有一些基本的验证,所以我们可以编写一些有用的测试。

public class Review : AggregateRoot
{
    public Review(Guid productId, int rating, string comment, 
        Guid customerId, Guid orderId)
    {
        ProductId = productId;
        if(rating < 1 || rating > 5)
        {
            throw new DomainException("Rating must be between 1 and 5");
        }
        Rating = rating;
        Comment = (comment ?? string.Empty).Trim();
        if (string.IsNullOrEmpty(comment))
        {
            throw new DomainException(
"Comment is required.");
        }
        if (Comment.Length > 200)
        {
            throw new DomainException(
"Comment length must be less than or equal to 200");
        }
        CustomerId = customerId;
        OrderId = orderId;
    }

    public Guid ProductId { get; set; }
    public int Rating { get; set; }
    public string Comment { get; set; }
    public Guid CustomerId { get; set; }
    public Guid OrderId { get; set; }
    
    public void Update(int rating, string comment)
    {
        if (rating < 1 || rating > 5)
        {
            throw new DomainException(
"Rating must be between 1 and 5");
        }
        Rating = rating;
        Comment = (comment ?? string.Empty).Trim();
        if (string.IsNullOrEmpty(comment))
        {
            throw new DomainException(
"Comment is required.");
        }
        if (Comment.Length > 200)
        {
            throw new DomainException(
"Comment length must be less than or equal to 200");
        }
    }
}

以上是Review聚合的第一次尝试

下面是ReviewResponse 聚合的第一次尝试:

public class ReviewResponse : AggregateRoot
{
    public ReviewResponse(Guid reviewId, Guid customerId, string comment)
    {
        ReviewId = reviewId;
        CustomerId = customerId;
        Comment = comment;
    }

    public Guid ReviewId { get; set; }
    public Guid CustomerId { get; set; }
    public string Comment { get; set; }
}

测试代码一部分:

  var comment = "Test comment.";
        var review = new ReviewBuilder()
                        .WithComment(comment)
                        .Build();

        review.Comment.Should().Be(comment);

由于在实例化聚合时通常会传入相当多的参数,因此该Builder模式非常有用,可以在我们的聚合发生变化时隔离仅在一个地方进行这些更改。最后一个关键原则表明它可能会改变!

public class ReviewBuilder
{
    private Guid _productId = Guid.NewGuid();
    private Guid _orderId = Guid.NewGuid();
    private Guid _customerId = Guid.NewGuid();
    private int _rating = 4;
    private string _comment = "This is a test comment.";

    public Review Build()
    {
        var review = new Review(_productId, _rating, _comment, _customerId, _orderId);
        return review;
    }

    public ReviewBuilder WithRating(int rating)
    {
        _rating = rating;
        return this;
    }

    public ReviewBuilder WithComment(string comment)
    {
        _comment = comment;
        return this;
    }
}

封装
第一次尝试有一些问题,但让我们从最大的问题开始——DDD 就是使用封装使我们的聚合保持一致的状态:
Review必须在有效状态下通过构造函数创建,但没有什么可以阻止某人在创建后将属性修改为无效值,例如将设置Rating为6。
我们可以通过将所有设置器更改为私有来解决此问题。

Guard
我们有一些很好的验证逻辑,但是很难重用,我们需要大量的测试来覆盖它。让我们通过添加一些Guard类来改进它。Guard可以在此链接中找到这些类的代码。

public class Review : AggregateRoot
{
    public Review(Guid productId, int rating, string comment, 
        Guid customerId, Guid orderId)
    {
        ProductId = productId;
        Guard.Against.LessThan(rating, 1, "Rating");
        Guard.Against.GreaterThan(rating, 5,
"Rating");
        Rating = rating;
        comment = (comment ?? string.Empty).Trim();
        Guard.Against.NullOrEmpty(comment,
"Comment");
        Guard.Against.LengthGreaterThan(comment, 200,
"Comment");
        Comment = comment;
        CustomerId = customerId;
        OrderId = orderId;
    }

    public Guid ProductId { get; private set; }
    public int Rating { get; private set; }
    public string Comment { get; private set; }
    public Guid CustomerId { get; private set; }
    public Guid OrderId { get; private set; }
    
    public void Update(int rating, string comment)
    {
        Guard.Against.LessThan(rating, 1,
"Rating");
        Guard.Against.GreaterThan(rating, 5,
"Rating");
        Rating = rating;
        comment = (comment ?? string.Empty).Trim();
        Guard.Against.NullOrEmpty(comment,
"Comment");
        Guard.Against.LengthGreaterThan(comment, 200,
"Comment");
        Comment = comment;
    }
}

工厂
现在看起来好多了。下一个问题:通常,我们使用诸如实体框架或 NHibernate 之类的 ORM 来持久化我们的聚合。
这些通常旨在将我们的属性名称与构造函数参数匹配,并且在从数据库中重构它们时,它们会通过构造函数传入数据。
这意味着所有这些很好的验证逻辑都在重复,这可能会损害性能并导致兼容性错误。

工厂模式可以帮助我们移动验证逻辑。通常,我在聚合上使用静态Factory方法并将构造函数更改为私有以保留封装。Factory可以创建一个单独的类,但我发现这通常只对多态Entities或Value Objects可以重用验证逻辑的地方有用:

public class Review : AggregateRoot
{
    private Review(Guid productId, int rating, string comment, 
        Guid customerId, Guid orderId)
    {
        ProductId = productId;
        Rating = rating;
        Comment = comment;
        CustomerId = customerId;
        OrderId = orderId;
    }

   //工厂模式 用来生成聚合内部的值对象和实体
    public static Review Create(Guid productId, int rating, string comment,
        Guid customerId, Guid orderId)
    {
        Guard.Against.LessThan(rating, 1,
"Rating");
        Guard.Against.GreaterThan(rating, 5,
"Rating");
        comment = (comment ?? string.Empty).Trim();
        Guard.Against.NullOrEmpty(comment,
"Comment");
        Guard.Against.LengthGreaterThan(comment, 200,
"Comment");
        return new Review(productId, rating, comment, customerId, orderId);
    }

    public Guid ProductId { get; private set; }
    public int Rating { get; private set; }
    public string Comment { get; private set; }
    public Guid CustomerId { get; private set; }
    public Guid OrderId { get; private set; }

    public void Update(int rating, string comment)
    {
        Guard.Against.LessThan(rating, 1,
"Rating");
        Guard.Against.GreaterThan(rating, 5,
"Rating");
        Rating = rating;
        comment = (comment ?? string.Empty).Trim();
        Guard.Against.NullOrEmpty(comment,
"Comment");
        Guard.Against.LengthGreaterThan(comment, 200,
"Comment");
        Comment = comment;
    }
}


根据你所使用的ORM,你可能会发现你还需要添加一个无参数的私有构造函数。

那个Builder生成器的类现在已经得到了回报。我们只需要在一个地方改变我们的测试程序集,就可以让它编译了。


值对象
让我们回到我们的原则:在可能的情况下,我们应该偏爱值对象。这里有几个地方我们可以重构成Value Objects来封装我们的一些验证逻辑。

Rating 评级是这里最明显的重构案例。让我们来看看如何将其重构为一个值对象。

public class Rating : ValueObject<Rating>
{
    private Rating(int rating)
    {
        Value = rating;
    }

    public static Rating Create(int rating)
    {
        Guard.Against.LessThan(rating, 1, "Rating");
        Guard.Against.GreaterThan(rating, 5,
"Rating");
        return new Rating(rating);
    }

    public int Value { get; private set; }

    protected override int GetValueHashCode()
    {
        return Value.GetHashCode();
    }

    protected override bool ValueEquals(Rating other)
    {
        return Value.Equals(other.Value);
    }
}

Value Objects帮助我们提供了一个更丰富的领域,并允许我们重新使用我们的验证逻辑。我们的 "审查 "集合现在变得更干净了,而且我们还设法删除了更新方法中的重复代码。

public class Review : AggregateRoot
{
    private Review(Guid productId, Rating rating, Comment comment,
        Customer customer, Guid orderId)
    {
        ProductId = productId;
        Rating = rating;
        Comment = comment;
        Customer = customer;
        OrderId = orderId;
    }

    public static Review Create(Guid productId, Rating rating, Comment comment,
        Customer customer, Guid orderId)
    {
        return new Review(productId, rating, comment, customer, orderId);
    }

    public Guid ProductId { get; private set; }
    public Rating Rating { get; private set; }
    public Comment Comment { get; private set; }
    public Customer Customer { get; private set; }
    public Guid OrderId { get; private set; }

    public void Update(Rating rating, Comment comment)
    {
        Rating = rating;
        Comment = comment;
    }
}

由于我们已经为我们所有的不变式编写了测试,我们可以运行它们来确保我们在重构到Value Objects时没有引入任何bug。我们的Review Builder继续确保在我们的测试项目中需要最小的改动。

重新分析我们的设计
让我们回到我们的原则上来。我们有两个很好的小聚合体(原则2),但是我们不能验证我们所有的业务规则(原则3),这个业务规则是:如果一个产品评论Review超过6个月,就不能实现回应Response了。
这表明我们的边界是错误的,响应Response必须被加入Review聚合体这个集合中。

我们现在要做一个有趣的决定:Response应该是一个实体还是一个值对象?
这时我们可能需要回到我们的业务专家那里,问一些问题来帮助理解这个问题。
这里要问的一个重要问题是Response在发布后是否可以被编辑?
我们的(想象中的)业务专家告诉我们,如果人们犯了错误或改变了主意,很可能会编辑他们的回复,所以我倾向于使用实体。
实体是可变的,而值对象是不可变的。

对于很多这样的决定,其实并没有一个正确的答案。我发现,从业务专家那里获得尽可能多的洞察力:并从其他开发者那里获得平衡的观点,这些将使你有最好的机会为你的团队做出最好的决定。 

添加子实体
让我们来看看如何将ReviewResponse实体的集合添加到我们的Review集合中。下面是ReviewResponse实体的样子。

public class ReviewResponse : Entity
{
    private ReviewResponse(Customer customer, Comment comment)
    {
        Customer = customer;
        Comment = comment;
    }

    internal static ReviewResponse Create(Customer customer, Comment comment)
    {
        return new ReviewResponse(customer, comment);
    }

    public Guid ReviewId { get; private set; }
    public Customer Customer { get; private set; }
    public Comment Comment { get; private set; }

    internal void Update(Comment comment)
    {
        Comment = comment;
    }
}

现在,这里的东西看起来应该很熟悉。我们甚至已经能够重复使用我们以前创建的值对象。

你会注意到Factory和Update方法是内部的,而不是公开的。请记住,在DDD中,对聚合的所有更改都必须通过聚合根来完成。通过使这些方法成为内部方法,我们确保Domain项目之外的消费者不能改变Review的状态或创建ReviewResponse。

下面是我们最终的评论Review集合的样子:
Response响应被添加为一个IReadOnlyCollection,它由一个私有的List支持。
这是在DDD中公开集合的常见模式,同时保持封装,因为实体只能从集合内部添加/删除/更新。

public class Review : AggregateRoot
{
    private Review(Guid productId, Rating rating, Comment comment,
        Customer customer, Guid orderId)
    {
        ProductId = productId;
        Rating = rating;
        Comment = comment;
        Customer = customer;
        OrderId = orderId;
    }

    public static Review Create(Guid productId, Rating rating, Comment comment,
        Customer customer, Guid orderId)
    {
        return new Review(productId, rating, comment, customer, orderId);
    }

    public Guid ProductId { get; private set; }
    public Rating Rating { get; private set; }
    public Comment Comment { get; private set; }
    public Customer Customer { get; private set; }
    public Guid OrderId { get; private set; }
    public const int ResponseDeadlineMonths = 6;

    private readonly List<ReviewResponse> _responses = new List<ReviewResponse>();
    public IReadOnlyCollection<ReviewResponse> Responses => _responses.AsReadOnly();

    public void Update(Rating rating, Comment comment)
    {
        Rating = rating;
        Comment = comment;
    }

    public void Respond(Customer customer, Comment comment, DateTime responseDate)
    {
        CheckIfResponseDeadlinePassed(responseDate);
        var response = ReviewResponse.Create(customer, comment);
        _responses.Add(response);
    }

    public void EditResponse(Guid responseId, Customer customer, Comment comment, DateTime responseDate)
    {
        var response = GetCustomerResponse(responseId, customer, responseDate);
        response.Update(comment);
    }

    public void DeleteResponse(Guid responseId, Customer customer, DateTime responseDate)
    {
        var response = GetCustomerResponse(responseId, customer, responseDate);
        _responses.Remove(response);
    }

    private ReviewResponse GetCustomerResponse(Guid responseId, Customer customer, DateTime responseDate)
    {
        CheckIfResponseDeadlinePassed(responseDate);
        var response = _responses.FirstOrDefault(e => e.Id == responseId);
        if (response == null)
        {
            throw new NotFoundException($"Response not found: {responseId}");
        }
        if (!response.Customer.Equals(customer))
        {
            throw new UnauthorizedAccessException();
        }
        return response;
    }

    private void CheckIfResponseDeadlinePassed(DateTime responseDate)
    {
        if(responseDate > CreatedDate.AddMonths(ResponseDeadlineMonths))
        {
            throw new DomainException($
"Cannot respond to review that is over {ResponseDeadlineMonths} months old");
        }
    }
}

现在我们可以满足我们所有的不变式,并在单元测试中测试我们的业务规则。

使用日期
您会注意到,在处理响应ReviewResponse时,会传入响应ReviewResponse日期,这样就可以验证不变式了。
如果在聚合内部使用 DateTime.UtcNow 之类的东西来获取响应日期,那么我们将无法测试审查超过六个月的响应。
计算当前的DateTime是商业逻辑,它不属于我们的聚合内部。

如果你的应用程序有大量的DateTime验证,那么另一个选择可能是有一个DateTime服务,它被传递到方法中。这可以在测试时被模拟。

领域事件
目前的设计给我们提供了一个很好的基础,以便在未来添加新的功能。如果我们想建立一个审查/批准的过程,那么现在就可以很容易地添加。

最后一件事我要补充的是领域事件。即使你不打算将任何东西挂到这些事件中,这意味着人们可以在未来使用它们,而不需要对你的聚合体进行任何代码修改。下面是我们在这个聚合体中可能使用域事件的种类。

  • 当评论被发布时,向评论团队或自动流程发送电子邮件,以检查是否有攻击性内容。
  • 当评论被添加时,向客户发送一个警告。
  • 更新产品的平均评价快照。

最终解决方案
这是Review有界上下文( Bounded Context)应用我们讨论过的概念和原则后的最终结果。

我确信我们可以使用许多其他概念来增强Review这里的聚合。要研究的其他事情是使用Result Monad 类返回错误而不是抛出异常。我会把它留给你自己调查。
您可以在我的 GitHub 上找到最终解决方案的代码。