介绍
领域驱动设计 (DDD) 是一种强大的方法,用于构建复杂的软件系统,这些系统紧密地代表了它们所服务的现实世界领域。DDD 的基本概念之一是聚合,它在组织和管理领域模型中发挥着核心作用。在本文中,我们将使用 C# 代码示例深入探讨聚合根的世界、它们的重要性以及与其设计和使用相关的最佳实践。为了说明这些原则,我们将以任务管理系统为例,深入了解何时创建单独的聚合根、管理多对多关系以及在聚合内强制执行验证规则。
什么是根聚合?
DDD 中的聚合是被视为单个单元的相关对象的集群。每个聚合都有一个指定的入口点,称为聚合根。根聚合控制对聚合内对象的访问,并在域内强制执行不变量和业务规则。这个概念提供了一种封装相关领域对象的方法,确保数据的一致性和完整性。
在 DDD 中,确定聚合的边界及其聚合根是一个至关重要的设计决策,这一切都取决于业务用例。但是,以下场景可以帮助指导决策是创建单独的聚合还是使用现有的聚合:
在我们的任务管理系统中,任务可以自然地被视为一个聚合。为了表示这一点,我们需要决定聚合中的哪个实体将充当聚合根。通常,聚合根应该是最有意义并且最有可能从聚合外部访问的对象。在任务管理的上下文中,聚合根的一个不错的选择可能是Task实体本身。
何时将实体包含在现有聚合中?
交易边界
聚合根定义了事务边界,在该边界内更改必须以原子方式发生。换句话说,对聚合状态的任何修改(包括聚合根本身及其关联实体)必须要么完全成功,要么失败。这确保了聚合内的数据保持一致的状态。
强制不变量
不变量是聚合中必须维护的规则,以确保数据完整性。这些不变量代表应始终为真的业务规则或条件。例如,如果您正在对电子商务系统进行建模,则不变式可能是所有订单商品的总价不得增加订单限制允许的最大金额。因此,订单和订单项目通常都是同一聚合的一部分
💡根据经验,每次要对聚合根进行操作时,我们都必须检索完整的对象。
何时为实体创建单独的聚合?
不同的生命周期
当同一聚合中的实体具有不同的生命周期时,这可能表明它们应该属于单独的聚合。在任务管理系统中,Tag是一个可以附加到一个或多个 的实体Tasks。让我们首先尝试将 Task 和 Tag 保留在同一个聚合中,并通过查看下面的代码示例来观察会发生什么。
public class Task : AggregateRoot { public int Id { get; private set; } public string Title { get; private set; } public TaskStatus Status { get; private set; } // Other task attributes... private List<Tag> _tags = new List<Tag>(); public Task(int id, string title) { Id = id; Title = title; Status = TaskStatus.Active; // Initialize as active } public void AddTag(Tag tag) { // Ensure that the assignment meets business rules. if (!_tags.Contains(tag)) { _tags.Add(tag); } } } public class Tag { public int Id { get; private set; } public string Name { get; private set; } // Other tag attributes... public Tag(int id, string name) { Id = id; Name = name; } public void UpdateName(string newName) { // Ensure that the name can be updated based on business rules. Name = newName; } }
通过将任务和标签实体保持在同一聚合中,我们将它们视为一个单元。虽然您可以将这些实体保存在不同的数据库表中,但除非创建任务,否则无法创建标签。另一种思维方式是,当删除任务时,所有其他标签也将被删除。这通常不是任务管理系统中的用例,因此我们需要通过牢记它们的生命周期来重新设计。如果一个实体的存在与另一个聚合根紧密相关,那么它应该是该聚合的一部分。
以下是将任务和标签表示为单独聚合的 C# 代码:
public class Task : AggregateRoot { public int Id { get; private set; } public string Title { get; private set; } public TaskStatus Status { get; private set; } // Other task attributes... private List<int> _tagIds = new List<int>(); public Task(int id, string title, TaskStatus status) { Id = id; Title = title; Status = status; } public void AssignTag(int tagId) { // Ensure that the assignment meets business rules. if (!_tagIds.Contains(tagId)) { _tagIds.Add(tagId); } } public void RemoveTag(int tagId) { // Ensure that the unassignment meets business rules. _tagIds.Remove(tagId); } } public class Tag : AggregateRoot { public int Id { get; private set; } public string Name { get; private set; } // Other tag attributes... public Tag(int id, string name) { Id = id; Name = name; } }
这里的区别在于,我们现在只保留分配的标签的标识,而不是将整个对象保留在任务实体中。使用此代码,我们可以单独管理任务和标签,并从任何任务中自由分配或删除标签,而不会影响实际的标签对象。
💡 根据经验,聚合根应该通过身份相互引用。
一个常见的误解是根据实体之间的层次结构和关系来设计聚合。但是,主要关注点应该是您需要在聚合中强制执行的行为和不变量。驱动这些不变量的数据是最重要的,它应该基于所暴露的行为。在不考虑行为的情况下构建对象模型层次结构可能会导致不必要的复杂性。
聚合内的大型集合带来的并发访问和性能
创建聚合时的另一个规则是,我们必须在执行任何写入操作之前填充整个聚合。在任务管理系统中,任务可能有注释。与任务和标签不同,评论可能没有自己的生命周期,因为它与任务紧密耦合。换句话说,仅当存在任务时才会创建注释。删除任务时,必须删除与任务关联的所有注释。看看这个业务需求,我们可能会认为,这肯定是一个单元,确实如此,但根据规则,如果我们要随任务一起获取所有评论,那么我们可能会观察到并发和性能问题。因此,将它们保存在单独的集合中是一个很好的做法。
但是,如果存在需要通过返回聚合边界下的所有注释来保护的不变/业务规则,请全部返回。相反,如果没有需要保护的底层不变量,您可以通过不返回命令操作的整个集合来进行优化,因为这可能会导致性能问题。
public class Task : AggregateRoot { public int Id { get; private set; } public string Title { get; private set; } public TaskStatus Status { get; private set; } // Other task attributes... private List<int> _tagIds = new List<int>(); public Task(int id, string title, TaskStatus status) { Id = id; Title = title; Status = status; } public void AssignTag(int tagId) { // Ensure that the assignment meets business rules. if (!_tagIds.Contains(tagId)) { _tagIds.Add(tagId); } } public void RemoveTag(int tagId) { // Ensure that the unassignment meets business rules. _tagIds.Remove(tagId); } } public class Comment : AggregateRoot { public int Id { get; private set; } public string Text { get; private set; } public int taskId { get; private set; } // Other tag attributes... public Tag(int taskId, string name) { taskId = taskId; Name = name; } }
由于我们出于性能原因而不是基于其生命周期将这些项目分为两个不同的聚合,因此我们现在需要解决两个问题。
删除任务时如何删除所有评论?
为了确保任务和评论之间的一致性,我们应该拥抱最终一致性。这需要在任务删除时触发域事件。通过捕获事件,我们可以随后删除与已删除任务关联的任何评论。
如果业务规则规定用户只能创建 10 条评论怎么办?
在这种情况下,我们可以在任务聚合中仅保留一个表示评论数量的整数属性。如果计数增加到超过 10,我们可以引发异常。
💡 要记住的一个关键点是,您只需要在进行状态更改时强制执行不变量。如果实体内的数据与任何不变量无关或不需要在聚合内保持一致,则它在该聚合内不起作用。
不要使用域模型进行查询
一个常见的错误是使用域模型(包括聚合)来进行查询。域模型主要是为处理命令而设计的,您应该避免可能影响系统性能的复杂查询。对于查询,最好直接针对存储库执行它们或探索构建读取模型等技术。
DDD 和 CQRS 之间的关系
在 DDD 上下文中,区分命令查询职责分离 (CQRS) 和聚合非常重要。CQRS 并不是要使用不同的数据存储,而是要具有单独的读取和写入路径。聚合通过提供一种结构化的方式来处理域模型中的命令(数据更改)来适应这种情况。
当涉及查询(读取数据)时,您可以直接对存储库执行查询或探索读取模型的构造。这符合保持领域模型精简且不将其用于查询目的的原则。
public class TaskService { private readonly TaskListRepository taskListRepository; public void AddTask(TaskList taskList, Task task) { // Command side - modifies data. taskList.AddTask(task); taskListRepository.Save(taskList); } public Task GetTask(Guid taskId) { // Query side - retrieves data. return taskListRepository.GetTask(taskId); } }
管理多对多关系
多对多关系有两种类型。第一种类型就像任务和标签的示例。在这种情况下,我们只是为任务分配一个标签,而不需要任何有关关系的附加信息。此外,标签被分配给任务,而不是任务被分配给标签。从数据库的角度来看,它仍然被认为是多对多关系,但关系总是从一个方向建立的。
第二种类型称为“联结表”或“关联表”,涉及与关系级元数据的多对多关系。在这种类型的关系中,两个实体通过一个中间实体连接,该中间实体存储有关关系本身的附加信息。这种关系也可以在任一方向上建立。
想象一下,有一个业务需求,规定可以将任务分配给用户或(用户也可以分配给任务)以及其他关系级别信息(例如任务分配日期)。此外,还有一条业务规则规定,给定用户应具有 2 个以上具有相同分配日期的关系。
在这种情况下,一种可能的解决方案是创建一个单独的聚合根,如下所示。然而,在确定在哪里存储强制执行需求中概述的关系约束的业务规则时,会出现挑战。由于我们需要完整的关系列表来执行验证,因此一种选择是利用域服务。这些服务可以检索用户的所有关系,验证约束,然后添加关系。
public class TaskUserRelationship : AggregateRoot { public int RelationshipId { get; set; } public int TaskId { get; set; } public int UserId { get; set; } public DateTime AssignmentDate { get; set; } } public class TaskUserRelationshipService { public void CreateRelationship(Task task, User user, DateTime assignmentDate) { var userRelations = this.relationshipRepository.GetRelationsByUser(user.Id); if(!userRelations.CheckExist(assignmentDate)) { var newRelation = TaskUserRelationship(taskId, userId, assignmentDate); this.relationshipRepository.Save(newRelation); } } }
使用域服务的缺点
虽然领域服务在封装业务逻辑方面发挥着至关重要的作用,但完全依赖它们可能会导致领域模型贫乏。贫乏的领域模型缺乏行为,主要是一种数据结构。
为了维护丰富的领域模型,仔细评估哪些行为属于实体以及何时委托给领域服务非常重要。当领域模型中包含更多类可以产生更具表现力和可维护性的设计时,这是可以接受的。
或者,我们可以以这种形式形成一个聚合
public class TaskUserRelationship : AggregateRoot { public int TaskId { get; set; } public List<TaskAssignment> Existing { get; set; } public void AddRelation(User user, DateTime assignmentDate) { if(!userRelations.CheckExist(assignmentDate)) { var newRelation = TaskUserRelationship(taskId, userId, assignmentDate); this.relationshipRepository.Save(newRelation); } } } public class TaskAssignment : AggregateRoot { public int RelationshipId { get; set; } public int UserId { get; set; } public DateTime AssignmentDate { get; set; } }
两种方法都有各自的优点和缺点。最终归结为不变量和权衡。
聚合验证
💡 确保您的聚合始终有效并且永远不会进入无效状态。避免设计接受所有内容并要求开发人员调用 IsValid 方法进行验证的聚合。
聚合内的验证至关重要。需要一致地强制执行不变量以保持聚合的完整性。验证可以通过多种方式实现,例如验证数据并在验证失败时引发异常。如果没有太多业务规则,则尝试通过简单地抛出异常来保持聚合精简。
到期日有效期
假设有一条规则指定任务的截止日期必须是将来的日期。作为聚合根的实体Task应负责验证和执行此规则。
public class Task : AggregateRoot { public string Title { get; private set; } public DateTime DueDate { get; private set; } public void SetDueDate(DateTime dueDate) { if (dueDate <= DateTime.Now) { throw new ArgumentException("Due date must be in the future."); } DueDate = dueDate; } }
但是,如果您有许多复杂的验证规则并希望有一种更复杂的方法来管理它们,一个选择是利用规范模式并抛出异常(将在下一篇文章中讨论)。然而,为了提供全面的概述,让我们简要概述一下它的外观。规范模式的一大优点是单一职责原则和可重用性。
任务标题长度
public class Task { public int Id { get; private set; } public string Title { get; private set; } public Task(int id, string title) { Id = id; var specification = TaskTitleLengthSpecification(5, 100); if(!specification.IsSatisfiedBy(title)) throw new ValidationException("Invalid Title"); Title = title; } } // Specification interface public interface ISpecification<T> { bool IsSatisfiedBy(T entity); } // TaskTitleLengthSpecification checks if the task's title meets length criteria. public class TaskTitleLengthSpecification : ISpecification<Task> { private readonly int minTitleLength; private readonly int maxTitleLength; public TaskTitleLengthSpecification(int minTitleLength, int maxTitleLength) { this.minTitleLength = minTitleLength; this.maxTitleLength = maxTitleLength; } public bool IsSatisfiedBy(Task task) { int titleLength = task.Title.Length; return title length >= minTitleLength && titleLength <= maxTitleLength; } }
结论
总之,理解聚合根及其在强制不变量中的作用是领域驱动设计的基础。聚合定义事务边界,确保数据一致性,并为管理域模型内的更改提供清晰的结构。在域模型中设计和实现聚合根时,仔细考虑实体及其事务,以及不变量和性能优化之间的平衡至关重要。通过遵守这些原则,您可以构建强大且可维护的软件系统,准确反映您所在领域的复杂性和要求。