本文针对的是对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 上找到最终解决方案的代码。