聚合根
# ABP vNext 领域驱动设计之聚合根完整学习指南
本报告将通过系统化的分模块、分步骤的学习方式,帮助您从零开始彻底掌握ABP vNext框架中的聚合根设计,最终能够构建支撑生产级应用的领域模型。通过理论讲解、代码示例和实战案例相结合的方式,您将深入理解聚合根的核心概念、在ABP框架中的具体实现方式、与其他领域对象的协作关系,以及如何在实际项目中应用这些知识来构建高质量的业务系统。本学习指南采用由浅入深的方式,从最基础的聚合根概念开始,逐步演进到包含领域事件、仓储模式、值对象等复杂场景的综合设计方案。
# 第一部分:聚合根的理论基础
# 聚合根概念的核心理解
聚合根是领域驱动设计(DDD)中最重要的概念之一,它指的是一组相关对象的集合[2 (opens new window)]。在ABP框架中,聚合根代表了一个业务单元的边界,所有对该业务单元数据的修改都必须通过聚合根这个唯一入口进行[1 (opens new window)][8 (opens new window)]。聚合(Aggregate)是业务和逻辑紧密关联的实体和值对象组合而成的基本单元[8 (opens new window)],它定义了哪些对象在事务中应该被一起修改,哪些对象应该保持最终一致性[2 (opens new window)]。
聚合根的引入解决了传统设计中的一个重大问题:在没有聚合根的设计中,每一个实体都是对等的,任何对象都可以随意修改任何实体的状态,这样很容易导致数据的不一致[39 (opens new window)]。想象一个订单系统,如果订单明细可以被任意修改而不经过订单本身的检查,就可能出现订单总金额与明细金额不匹配的情况。聚合根通过集中管理的方式确保聚合内部的所有数据变更都遵循统一的业务规则[2 (opens new window)][41 (opens new window)]。
# 聚合根在微服务架构中的价值
聚合根不仅仅是一个技术概念,它对整个系统架构有着深远的影响[2 (opens new window)]。在微服务拆分时,聚合是拆分微服务的最小单位[39 (opens new window)][56 (opens new window)]。当一个业务需求涉及多个聚合的数据修改时,应该采用最终一致性而非强一致性[39 (opens new window)][56 (opens new window)],这使得微服务能够更加独立地演进和扩展。从聚合到微服务的演进过程中,原本在聚合内部通过对象引用实现的操作会变成通过ID引用实现的跨服务调用[39 (opens new window)],这就是为什么我们强调聚合内部使用对象引用,聚合之间使用ID引用[39 (opens new window)]。
# 第二部分:ABP框架中的聚合根实现
# ABP中的聚合根类型体系
在ABP vNext框架中,提供了一个完整的聚合根类型体系,从最简单到最复杂,满足不同场景的需求[1 (opens new window)][9 (opens new window)]。理解这个类型体系是掌握ABP聚合根的第一步。
BasicAggregateRoot 类型[1 (opens new window)][9 (opens new window)] 是最基础的聚合根实现,它只包含一个作为主键的Id属性。这个类型适合那些不需要任何审计信息的简单业务对象。在ABP中,BasicAggregateRoot是一个泛型类,您可以指定主键的类型,比如Guid、int、string等[1 (opens new window)]。
AggregateRoot 类型[1 (opens new window)][9 (opens new window)] 在BasicAggregateRoot的基础上添加了对乐观并发和对象扩展特性的支持。乐观并发是一种处理并发更新的机制,通过给每个聚合根维护一个并发版本号[10 (opens new window)],当多个用户同时修改同一个聚合根时,框架可以检测到冲突并予以提示[1 (opens new window)]。对象扩展特性允许您在不修改原始类定义的情况下为聚合根添加额外的属性[1 (opens new window)]。
CreationAuditedAggregateRoot 类型[1 (opens new window)][9 (opens new window)] 继承自AggregateRoot类,添加了CreationTime(创建时间)和CreatorId(创建人)属性,用于记录聚合根的创建审核信息。当一个聚合根被创建时,ABP框架会自动填充这两个属性[1 (opens new window)]。
AuditedAggregateRoot 类型[1 (opens new window)][9 (opens new window)] 继承自CreationAuditedAggregateRoot类,进一步添加了LastModificationTime(最后修改时间)和LastModifierId(最后修改人)属性。这使得系统可以追踪每一次数据修改的时间和操作者[1 (opens new window)]。
FullAuditedAggregateRoot 类型[1 (opens new window)][9 (opens new window)] 是最完整的聚合根实现,继承自AuditedAggregateRoot类,添加了DeletionTime(删除时间)、DeleterId(删除人)属性,以及通过实现ISoftDelete接口添加了IsDeleted(是否已删除)属性。这支持软删除机制,即数据不会从数据库中物理删除,而是被标记为已删除[1 (opens new window)]。ABP框架会自动处理所有的软删除逻辑,在查询时自动过滤已删除的数据[1 (opens new window)]。
# 聚合根的并发控制机制
在ABP中,每一个聚合根都维护着一个并发令牌(ConcurrencyStamp)[10 (opens new window)]。这个令牌在聚合根初始化时被设置为一个GUID值[10 (opens new window)]。并发控制是处理高并发场景的重要机制。当多个用户同时尝试修改同一个聚合根时,ABP使用乐观锁的方式来检测并发冲突。具体来说,每当聚合根被修改并保存到数据库时,并发令牌会被更新。如果两个用户同时读取同一个聚合根,然后都尝试修改并保存,第二个保存操作会失败,因为聚合根的并发令牌已经发生了变化[10 (opens new window)]。
# 第三部分:聚合根的核心设计原则
# 原则一:单一聚合根管理原则
每个聚合只有一个聚合根[2 (opens new window)][41 (opens new window)]。聚合根作为聚合对外的唯一入口点,所有对聚合内部对象的访问和修改都必须通过聚合根进行[2 (opens new window)][39 (opens new window)][41 (opens new window)]。这个原则的核心在于集中管理。当外部需要访问聚合内的某个子实体时,不能直接访问该实体,而必须先访问聚合根,然后通过聚合根导航到所需的子实体[39 (opens new window)][41 (opens new window)]。
这个设计有几个重要的好处。首先,它确保了所有数据修改都要经过业务规则的验证。比如在订单聚合中,如果要修改订单明细的数量,不能直接修改明细对象的数量属性,而必须调用订单聚合根提供的修改订单明细的方法,这个方法会检查订单是否处于可编辑状态、修改后的总金额是否超过限额等业务规则[35 (opens new window)][52 (opens new window)]。其次,它简化了外部与聚合的交互,外部只需要与一个对象(聚合根)交互,不用关心聚合内部的复杂结构[2 (opens new window)]。
# 原则二:通过ID引用其他聚合的原则
聚合之间只能通过ID相互引用,不能通过对象引用[2 (opens new window)][39 (opens new window)][41 (opens new window)][52 (opens new window)]。这是DDD中非常重要的设计原则。当一个聚合需要引用另一个聚合时,应该存储对方的ID而不是对方的整体对象引用[39 (opens new window)][52 (opens new window)]。例如,在订单聚合中,如果需要关联客户聚合,应该只存储客户的ID,而不是存储整个客户对象[39 (opens new window)][52 (opens new window)]。
这个原则的好处是什么呢?首先,它松耦合了聚合之间的关系。当客户聚合发生变化时,不会直接影响到订单聚合的结构[39 (opens new window)]。其次,它为后续的微服务拆分奠定了基础。当聚合最终演进成独立的微服务时,原本的ID引用会自然地转变成服务间的调用[39 (opens new window)]。再次,它避免了对象图的无限扩展。如果允许聚合之间直接对象引用,那么加载一个订单对象可能会级联加载客户对象,客户对象又可能引用其他对象,最终可能导致整个数据库都被加载到内存中[39 (opens new window)]。
# 原则三:聚合一致性的原则
聚合内部数据强一致性,聚合之间数据最终一致性[39 (opens new window)][56 (opens new window)]。这个原则定义了聚合内部和聚合之间数据修改的事务边界。在一个数据库事务中,最多只能修改一个聚合的数据[39 (opens new window)][56 (opens new window)]。如果一个业务操作涉及多个聚合的修改,应该采用领域事件的异步处理方式,通过事件驱动实现最终一致性[5 (opens new window)][39 (opens new window)][56 (opens new window)]。
例如,在电商系统中,下单操作涉及订单聚合和库存聚合。正确的做法是:首先在一个事务中创建订单聚合并保存到数据库,同时发布一个"订单已创建"的领域事件;然后库存聚合通过订阅这个事件,异步地扣减库存。这样,即使库存扣减失败,订单已经被创建了,系统可以通过补偿机制或者重试机制来处理这种情况[5 (opens new window)][50 (opens new window)]。
# 原则四:不变性规则的原则
聚合根要维护聚合内的不变性规则[8 (opens new window)]。不变性规则是指在聚合的生命周期中必须始终为真的约束条件[8 (opens new window)]。例如,订单的不变性规则包括:订单的总金额必须等于所有订单明细金额的总和,订单的明细集合不能为空,订单只能在特定的状态下进行修改等[35 (opens new window)]。
维护这些不变性规则是聚合根的核心责任[8 (opens new window)][35 (opens new window)]。所有可能违反这些规则的操作都应该被聚合根所阻止[8 (opens new window)]。这样做的好处是确保了数据的完整性和业务的正确性。如果不变性规则被违反,就意味着数据可能处于一个无效状态,后续的业务操作就建立在错误的基础上[8 (opens new window)]。
# 第四部分:分步骤学习—从零开始构建聚合根
# 学习模块一:最基础的聚合根实现
让我们从最简单的场景开始,逐步深入。假设我们要构建一个博客系统,第一步是创建一篇文章(Article)的聚合根。
using Volo.Abp.Domain.Entities;
using System;
namespace BlogSystem.Domain.Articles
{
/// <summary>
/// 文章聚合根 - 学习模块一:基础聚合根
///
/// 这是我们学习之旅的第一步,展示了最基础的聚合根实现。
/// 聚合根继承自BasicAggregateRoot,它只包含最必要的功能:
/// - 一个主键ID属性(自动继承)
/// - 文章本身的业务属性(标题、内容等)
///
/// 为什么选择BasicAggregateRoot而不是其他类型?
/// 因为在这个阶段,我们只需要关注聚合根的最基础概念,
/// 不需要审计信息、并发控制等复杂功能。
/// </summary>
public class Article : BasicAggregateRoot<Guid>
{
/// <summary>
/// 文章标题
/// 这是一个私有字段,外部不能直接修改,只能通过公开方法修改。
/// 这体现了聚合根的第一个原则:通过方法来控制状态变更。
/// </summary>
private string _title;
/// <summary>
/// 文章内容
/// 同样是私有的,确保数据修改都经过业务规则检查。
/// </summary>
private string _content;
/// <summary>
/// 文章作者ID
/// 注意:这里存储的是作者的ID,而不是作者对象本身。
/// 这遵循了DDD中"通过ID引用其他聚合"的原则。
/// 如果我们存储整个作者对象,就会造成聚合之间的紧耦合。
/// </summary>
private Guid _authorId;
/// <summary>
/// 文章发布状态
/// 这个字段用来管理文章的生命周期:
/// 0 = 草稿(Draft)
/// 1 = 已发布(Published)
/// 2 = 已删除(Deleted)
/// 这个状态管理体现了聚合根对业务规则的控制。
/// </summary>
private int _status;
/// <summary>
/// 创建时间 - 一旦创建就不能修改
/// </summary>
private DateTime _createdAt;
public string Title => _title;
public string Content => _content;
public Guid AuthorId => _authorId;
public int Status => _status;
public DateTime CreatedAt => _createdAt;
/// <summary>
/// 构造函数 - 私有的
///
/// 为什么是私有的?
/// 为了防止外部直接创建Article对象,确保所有创建逻辑都遵循
/// 我们定义的业务规则。后面我们会学习工厂模式来控制创建过程。
///
/// 特别注意:这个构造函数被EF Core使用,所以必须存在,
/// 即使我们不直接调用它。这是ORM框架的要求。
/// </summary>
private Article()
{
// EF Core 使用的无参构造函数
}
/// <summary>
/// 创建新文章的私有构造函数
///
/// 这个构造函数体现了"值对象模式"和"不变性"的概念。
/// 在创建时传入所有必要的数据,之后通过公开方法来修改状态。
/// </summary>
private Article(Guid id, string title, string content, Guid authorId)
{
// 验证业务规则 - 这是聚合根的核心职责
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("文章标题不能为空", nameof(title));
}
if (title.Length > 200)
{
throw new ArgumentException("文章标题不能超过200个字符", nameof(title));
}
if (string.IsNullOrWhiteSpace(content))
{
throw new ArgumentException("文章内容不能为空", nameof(content));
}
if (authorId == Guid.Empty)
{
throw new ArgumentException("作者ID不能为空", nameof(authorId));
}
// 赋值
Id = id;
_title = title;
_content = content;
_authorId = authorId;
_status = 0; // 初始状态为草稿
_createdAt = DateTime.UtcNow;
}
/// <summary>
/// 公开的工厂方法 - 创建新文章
///
/// 这是"工厂方法"模式的应用。外部不能直接new Article(),
/// 而必须通过这个方法创建,确保所有创建逻辑都遵循业务规则。
/// </summary>
public static Article Create(Guid id, string title, string content, Guid authorId)
{
// 在这里集中处理创建时的所有业务逻辑
return new Article(id, title, content, authorId);
}
/// <summary>
/// 修改文章标题的方法
///
/// 为什么要通过方法来修改,而不是直接暴露Title属性的setter?
/// 因为这样我们可以在修改时检查业务规则。
/// 例如:文章发布后就不能修改标题(这是一个常见的业务规则)。
/// </summary>
public void ChangeTitle(string newTitle)
{
// 业务规则检查:只有草稿状态的文章才能修改标题
if (_status != 0) // 0 表示草稿状态
{
throw new InvalidOperationException("只有草稿状态的文章才能修改标题");
}
// 验证输入
if (string.IsNullOrWhiteSpace(newTitle))
{
throw new ArgumentException("新标题不能为空", nameof(newTitle));
}
if (newTitle.Length > 200)
{
throw new ArgumentException("标题不能超过200个字符", nameof(newTitle));
}
// 修改状态
_title = newTitle;
// 这里我们可以选择发布领域事件,但在学习阶段我们先不做这个。
// 后续的模块中我们会加入领域事件。
}
/// <summary>
/// 修改文章内容
///
/// 类似的业务规则保护:只有草稿可以修改。
/// </summary>
public void ChangeContent(string newContent)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改内容");
}
if (string.IsNullOrWhiteSpace(newContent))
{
throw new ArgumentException("新内容不能为空", nameof(newContent));
}
_content = newContent;
}
/// <summary>
/// 发布文章
///
/// 这个方法改变了文章的状态,从草稿变为已发布。
/// 一旦发布,文章就不能再被编辑(根据我们的业务规则)。
/// </summary>
public void Publish()
{
// 业务规则:只有草稿状态才能发布
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能发布");
}
_status = 1; // 标记为已发布
}
/// <summary>
/// 检查文章是否已发布
///
/// 这是一个查询方法,不改变聚合根的状态。
/// 这样的方法对于外部了解聚合根的状态很有用。
/// </summary>
public bool IsPublished => _status == 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
这个基础模块有几个关键点需要理解:
第一,聚合根的属性都是私有的。这确保了外部不能绕过聚合根的方法直接修改内部状态[2 (opens new window)][39 (opens new window)][41 (opens new window)]。第二,所有的数据修改都通过公开方法进行,这些方法包含了业务规则的验证[2 (opens new window)][8 (opens new window)]。第三,我们存储的是作者ID而不是作者对象,这遵循了"通过ID引用其他聚合"的原则[39 (opens new window)][52 (opens new window)]。第四,状态通过字段来管理,而不是通过属性,这给了我们完全的控制权[41 (opens new window)]。
# 学习模块二:包含子实体的聚合根设计
在第二个模块中,我们需要理解聚合根如何管理其内部的子实体。让我们为文章添加评论功能。
using Volo.Abp.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace BlogSystem.Domain.Articles
{
/// <summary>
/// 文章评论 - 这是一个实体,但不是聚合根
///
/// 为什么是实体而不是值对象?
/// 因为评论有独立的ID,我们需要单独追踪每条评论。
/// 如果评论没有独立的标识,我们会把它设计为值对象。
///
/// 注意:这个类继承自Entity而不是AggregateRoot。
/// 评论的生命周期由文章聚合根管理,不是独立的。
/// </summary>
public class ArticleComment : Entity<Guid>
{
/// <summary>
/// 评论内容
/// 这是私有的,因为评论的修改应该通过聚合根(文章)来进行,
/// 确保所有修改都遵循文章级别的业务规则。
/// </summary>
private string _content;
/// <summary>
/// 评论人ID
/// 存储ID而不是对象引用
/// </summary>
private Guid _commenterId;
/// <summary>
/// 评论创建时间
/// 一旦创建就不能修改
/// </summary>
private DateTime _createdAt;
/// <summary>
/// 评论状态
/// 0 = 正常,1 = 已删除(软删除)
/// </summary>
private int _status;
public string Content => _content;
public Guid CommenterId => _commenterId;
public DateTime CreatedAt => _createdAt;
// 这是EF Core使用的构造函数
private ArticleComment()
{
}
/// <summary>
/// 创建新评论
/// 这个构造函数是私有的,只能通过文章的公开方法创建。
/// </summary>
private ArticleComment(Guid id, string content, Guid commenterId)
{
if (string.IsNullOrWhiteSpace(content))
{
throw new ArgumentException("评论内容不能为空", nameof(content));
}
if (content.Length > 1000)
{
throw new ArgumentException("评论内容不能超过1000个字符", nameof(content));
}
if (commenterId == Guid.Empty)
{
throw new ArgumentException("评论人ID不能为空", nameof(commenterId));
}
Id = id;
_content = content;
_commenterId = commenterId;
_createdAt = DateTime.UtcNow;
_status = 0;
}
/// <summary>
/// 供文章聚合根调用的工厂方法
/// </summary>
public static ArticleComment Create(Guid id, string content, Guid commenterId)
{
return new ArticleComment(id, content, commenterId);
}
/// <summary>
/// 删除评论(软删除)
/// </summary>
public void Delete()
{
if (_status == 1)
{
throw new InvalidOperationException("评论已经被删除");
}
_status = 1;
}
public bool IsDeleted => _status == 1;
}
/// <summary>
/// 文章聚合根 - 学习模块二:包含子实体
///
/// 在这个模块中,我们学习如何在聚合根中管理子实体。
/// 关键概念:
/// 1. 聚合根负责管理其内部所有实体的生命周期
/// 2. 外部不能直接修改子实体,只能通过聚合根的公开方法
/// 3. 聚合内部的数据修改应该在一个事务中完成
/// </summary>
public class ArticleV2 : BasicAggregateRoot<Guid>
{
private string _title;
private string _content;
private Guid _authorId;
private int _status;
private DateTime _createdAt;
/// <summary>
/// 评论集合
///
/// 这是一个很重要的设计决策:
/// 1. 使用ICollection而不是List,这样可以隐藏内部实现
/// 2. 返回只读集合给外部,防止外部直接修改
/// 3. 内部维护完整的List,用于管理和操作
/// </summary>
private readonly List<ArticleComment> _comments = new List<ArticleComment>();
/// <summary>
/// 对外暴露只读的评论列表
/// 外部可以查看评论,但不能直接修改或添加评论
/// </summary>
public IReadOnlyList<ArticleComment> Comments => _comments.AsReadOnly();
public string Title => _title;
public string Content => _content;
public Guid AuthorId => _authorId;
public int Status => _status;
public DateTime CreatedAt => _createdAt;
private ArticleV2()
{
}
private ArticleV2(Guid id, string title, string content, Guid authorId)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("文章标题不能为空", nameof(title));
}
if (title.Length > 200)
{
throw new ArgumentException("文章标题不能超过200个字符", nameof(title));
}
if (string.IsNullOrWhiteSpace(content))
{
throw new ArgumentException("文章内容不能为空", nameof(content));
}
if (authorId == Guid.Empty)
{
throw new ArgumentException("作者ID不能为空", nameof(authorId));
}
Id = id;
_title = title;
_content = content;
_authorId = authorId;
_status = 0;
_createdAt = DateTime.UtcNow;
}
public static ArticleV2 Create(Guid id, string title, string content, Guid authorId)
{
return new ArticleV2(id, title, content, authorId);
}
public void ChangeTitle(string newTitle)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改标题");
}
if (string.IsNullOrWhiteSpace(newTitle))
{
throw new ArgumentException("新标题不能为空", nameof(newTitle));
}
if (newTitle.Length > 200)
{
throw new ArgumentException("标题不能超过200个字符", nameof(newTitle));
}
_title = newTitle;
}
public void ChangeContent(string newContent)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改内容");
}
if (string.IsNullOrWhiteSpace(newContent))
{
throw new ArgumentException("新内容不能为空", nameof(newContent));
}
_content = newContent;
}
public void Publish()
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能发布");
}
_status = 1;
}
/// <summary>
/// 添加评论到文章
///
/// 这是一个关键的方法,展示了聚合根如何管理其子实体。
///
/// 业务规则包括:
/// 1. 只有已发布的文章才能接收评论
/// 2. 评论内容长度要验证(这在ArticleComment构造中已验证)
/// 3. 每条评论都要通过聚合根来创建和管理
/// </summary>
public void AddComment(Guid commentId, string commentContent, Guid commenterId)
{
// 业务规则:只有已发布的文章才能接收评论
if (_status != 1)
{
throw new InvalidOperationException("只有已发布的文章才能接收评论");
}
// 业务规则:评论数量限制(这是一个示例规则)
var activeComments = _comments.Count(c => !c.IsDeleted);
if (activeComments >= 1000)
{
throw new InvalidOperationException("评论数量已达上限");
}
// 创建新评论
var comment = ArticleComment.Create(commentId, commentContent, commenterId);
// 添加到评论集合
_comments.Add(comment);
}
/// <summary>
/// 删除指定的评论
///
/// 这个方法展示了如何在聚合内部进行更复杂的操作。
/// 虽然这里的实现比较简单,但在实际项目中,删除评论可能会涉及:
/// 1. 权限检查(只有评论人或管理员才能删除)
/// 2. 依赖关系检查(如果评论有回复,是否也要删除)
/// 3. 审计日志记录
/// </summary>
public void DeleteComment(Guid commentId, Guid deleterId)
{
// 查找评论
var comment = _comments.FirstOrDefault(c => c.Id == commentId);
if (comment == null)
{
throw new InvalidOperationException("评论不存在");
}
if (comment.IsDeleted)
{
throw new InvalidOperationException("评论已经被删除");
}
// 这里可以添加权限检查逻辑
// if (comment.CommenterId != deleterId && !isAdmin)
// {
// throw new InvalidOperationException("没有权限删除此评论");
// }
// 执行删除
comment.Delete();
}
/// <summary>
/// 获取有效的评论数量
/// 这是一个查询方法,用于统计活跃的评论
/// </summary>
public int GetActiveCommentCount()
{
return _comments.Count(c => !c.IsDeleted);
}
public bool IsPublished => _status == 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
这个模块展示了几个新的概念:
首先,子实体(ArticleComment)也是私有的,其构造函数只能被聚合根调用[41 (opens new window)][56 (opens new window)]。第二,聚合根维护了一个内部的子实体集合,但对外只暴露只读视图[41 (opens new window)][56 (opens new window)]。第三,所有对子实体的修改操作都必须通过聚合根的公开方法进行[2 (opens new window)][41 (opens new window)]。第四,聚合根可以检查涉及多个子实体的复杂业务规则,比如评论数量上限[8 (opens new window)]。
# 学习模块三:使用值对象设计不可变数据
在第三个模块中,我们引入值对象的概念,这些是不可变的、没有独立ID的业务对象。
using System;
using Volo.Abp.Domain.Values;
namespace BlogSystem.Domain.Articles
{
/// <summary>
/// 文章分类 - 值对象
///
/// 为什么这是值对象而不是实体?
/// 1. 分类没有独立的ID,我们按照分类名称来判断相等性
/// 2. 分类是不可变的,一旦创建就不能修改
/// 3. 我们只关心分类的内容,而不是追踪特定的分类对象
///
/// 值对象的设计遵循这些原则:
/// 1. 不可变 - 创建后不能改变状态
/// 2. 无标识 - 没有ID,相等性基于属性值
/// 3. 轻量级 - 不需要独立的生命周期管理
/// </summary>
public class Category : ValueObject
{
/// <summary>
/// 分类代码
/// 例如:"tech", "business", "life"
/// </summary>
public string Code { get; }
/// <summary>
/// 分类名称
/// 例如:"技术", "商业", "生活"
/// </summary>
public string Name { get; }
/// <summary>
/// 构造函数
/// 值对象的构造函数用来创建和验证对象
/// 一旦创建,属性就是只读的
/// </summary>
public Category(string code, string name)
{
if (string.IsNullOrWhiteSpace(code))
{
throw new ArgumentException("分类代码不能为空", nameof(code));
}
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("分类名称不能为空", nameof(name));
}
if (code.Length > 20)
{
throw new ArgumentException("分类代码不能超过20个字符", nameof(code));
}
if (name.Length > 50)
{
throw new ArgumentException("分类名称不能超过50个字符", nameof(name));
}
Code = code;
Name = name;
}
/// <summary>
/// 实现GetAtomicValues方法
/// 这个方法定义了值对象的相等性判断
/// 两个Category相等当且仅当它们的Code和Name都相同
/// </summary>
protected override IEnumerable<object> GetAtomicValues()
{
yield return Code;
yield return Name;
}
}
/// <summary>
/// 发布时间范围 - 值对象
///
/// 这是另一个值对象的例子,展示了如何组合多个相关的数据。
/// </summary>
public class PublishedTimeRange : ValueObject
{
/// <summary>
/// 开始时间
/// </summary>
public DateTime? StartTime { get; }
/// <summary>
/// 结束时间
/// </summary>
public DateTime? EndTime { get; }
public PublishedTimeRange(DateTime? startTime, DateTime? endTime)
{
if (startTime.HasValue && endTime.HasValue && startTime > endTime)
{
throw new ArgumentException("开始时间不能晚于结束时间");
}
StartTime = startTime;
EndTime = endTime;
}
/// <summary>
/// 业务方法:检查指定时间是否在范围内
/// 值对象也可以包含业务逻辑,但这些逻辑应该是无状态的
/// </summary>
public bool IsInRange(DateTime dateTime)
{
if (StartTime.HasValue && dateTime < StartTime)
{
return false;
}
if (EndTime.HasValue && dateTime > EndTime)
{
return false;
}
return true;
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return StartTime;
yield return EndTime;
}
}
/// <summary>
/// 文章聚合根 - 学习模块三:使用值对象
///
/// 在这个版本中,我们使用值对象来代表文章的分类信息。
/// 这样做的好处是:
/// 1. 分类信息被设计为不可变的,确保数据一致性
/// 2. 分类的创建和验证逻辑被封装在值对象中
/// 3. 代码更具表达性,一眼能看出分类是值对象,不需要单独管理
/// </summary>
public class ArticleV3 : BasicAggregateRoot<Guid>
{
private string _title;
private string _content;
private Guid _authorId;
private int _status;
private DateTime _createdAt;
/// <summary>
/// 文章分类
/// 使用值对象来代表分类
/// </summary>
private Category _category;
/// <summary>
/// 文章的发布时间范围
/// 这是另一个值对象的例子
/// </summary>
private PublishedTimeRange _publishedTimeRange;
private readonly List<ArticleComment> _comments = new List<ArticleComment>();
public string Title => _title;
public string Content => _content;
public Guid AuthorId => _authorId;
public int Status => _status;
public DateTime CreatedAt => _createdAt;
public Category Category => _category;
public PublishedTimeRange PublishedTimeRange => _publishedTimeRange;
public IReadOnlyList<ArticleComment> Comments => _comments.AsReadOnly();
private ArticleV3()
{
}
private ArticleV3(
Guid id,
string title,
string content,
Guid authorId,
Category category)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("文章标题不能为空", nameof(title));
}
if (title.Length > 200)
{
throw new ArgumentException("文章标题不能超过200个字符", nameof(title));
}
if (string.IsNullOrWhiteSpace(content))
{
throw new ArgumentException("文章内容不能为空", nameof(content));
}
if (authorId == Guid.Empty)
{
throw new ArgumentException("作者ID不能为空", nameof(authorId));
}
if (category == null)
{
throw new ArgumentNullException(nameof(category), "文章分类不能为空");
}
Id = id;
_title = title;
_content = content;
_authorId = authorId;
_category = category;
_status = 0;
_createdAt = DateTime.UtcNow;
_publishedTimeRange = new PublishedTimeRange(null, null);
}
public static ArticleV3 Create(
Guid id,
string title,
string content,
Guid authorId,
Category category)
{
return new ArticleV3(id, title, content, authorId, category);
}
/// <summary>
/// 修改文章分类
///
/// 虽然Category是值对象(不可变的),但我们可以在聚合根中
/// 替换它为一个新的Category对象。
/// 这不是修改值对象本身,而是用新的值对象替换旧的。
/// </summary>
public void ChangeCategory(Category newCategory)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改分类");
}
if (newCategory == null)
{
throw new ArgumentNullException(nameof(newCategory));
}
_category = newCategory;
}
/// <summary>
/// 设置发布时间范围
/// </summary>
public void SetPublishedTimeRange(DateTime? startTime, DateTime? endTime)
{
_publishedTimeRange = new PublishedTimeRange(startTime, endTime);
}
/// <summary>
/// 检查文章是否可以在指定时间发布
/// 这个方法利用了值对象的业务逻辑
/// </summary>
public bool CanPublishAt(DateTime dateTime)
{
return _publishedTimeRange.IsInRange(dateTime);
}
public void ChangeTitle(string newTitle)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改标题");
}
if (string.IsNullOrWhiteSpace(newTitle))
{
throw new ArgumentException("新标题不能为空", nameof(newTitle));
}
if (newTitle.Length > 200)
{
throw new ArgumentException("标题不能超过200个字符", nameof(newTitle));
}
_title = newTitle;
}
public void ChangeContent(string newContent)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改内容");
}
if (string.IsNullOrWhiteSpace(newContent))
{
throw new ArgumentException("新内容不能为空", nameof(newContent));
}
_content = newContent;
}
public void Publish()
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能发布");
}
_status = 1;
}
public void AddComment(Guid commentId, string commentContent, Guid commenterId)
{
if (_status != 1)
{
throw new InvalidOperationException("只有已发布的文章才能接收评论");
}
var activeComments = _comments.Count(c => !c.IsDeleted);
if (activeComments >= 1000)
{
throw new InvalidOperationException("评论数量已达上限");
}
var comment = ArticleComment.Create(commentId, commentContent, commenterId);
_comments.Add(comment);
}
public void DeleteComment(Guid commentId, Guid deleterId)
{
var comment = _comments.FirstOrDefault(c => c.Id == commentId);
if (comment == null)
{
throw new InvalidOperationException("评论不存在");
}
if (comment.IsDeleted)
{
throw new InvalidOperationException("评论已经被删除");
}
comment.Delete();
}
public int GetActiveCommentCount()
{
return _comments.Count(c => !c.IsDeleted);
}
public bool IsPublished => _status == 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
值对象模块展示了以下关键点:
值对象是不可变的,一旦创建就不能改变[23 (opens new window)]。值对象通过属性而不是ID来判断相等性[20 (opens new window)][23 (opens new window)]。聚合根可以包含值对象,这样可以将相关的不可变数据组织在一起[23 (opens new window)]。值对象可以包含业务逻辑,但这些逻辑应该是无状态的[23 (opens new window)]。
# 第五部分:聚合根的高级设计
# 学习模块四:领域事件与聚合根
现在我们进入更高级的阶段,引入领域事件的概念。领域事件是聚合根在其状态发生变化时发起的通知[5 (opens new window)][14 (opens new window)]。
using System;
using Volo.Abp.Domain.Entities.Events.Distributed;
using System.Collections.Generic;
namespace BlogSystem.Domain.Articles.Events
{
/// <summary>
/// 领域事件基类
///
/// 这个基类代表了聚合根中发生的重要事件。
/// 当聚合根的状态发生改变时,会创建并发布这个事件。
/// 其他的聚合或服务可以订阅这些事件来实现解耦。
/// </summary>
public abstract class ArticleDomainEventBase : DomainEventBase
{
/// <summary>
/// 事件发生的聚合根ID
/// </summary>
public Guid ArticleId { get; set; }
/// <summary>
/// 事件发生的时间
/// </summary>
public DateTime EventTime { get; set; }
protected ArticleDomainEventBase(Guid articleId)
{
ArticleId = articleId;
EventTime = DateTime.UtcNow;
}
}
/// <summary>
/// 文章已发布事件
///
/// 这个事件在文章被发布时发起。
/// 其他系统可以订阅这个事件来执行相关操作,比如:
/// 1. 发送邮件通知订阅者
/// 2. 更新统计信息
/// 3. 触发推荐算法
/// 4. 创建搜索索引
/// </summary>
public class ArticlePublishedDomainEvent : ArticleDomainEventBase
{
/// <summary>
/// 文章标题
/// 事件携带的业务数据
/// </summary>
public string Title { get; set; }
/// <summary>
/// 文章作者ID
/// </summary>
public Guid AuthorId { get; set; }
/// <summary>
/// 文章分类
/// </summary>
public string Category { get; set; }
public ArticlePublishedDomainEvent(
Guid articleId,
string title,
Guid authorId,
string category) : base(articleId)
{
Title = title;
AuthorId = authorId;
Category = category;
}
}
/// <summary>
/// 评论已添加事件
/// </summary>
public class CommentAddedDomainEvent : ArticleDomainEventBase
{
public Guid CommentId { get; set; }
public Guid CommenterId { get; set; }
public string CommentContent { get; set; }
public CommentAddedDomainEvent(
Guid articleId,
Guid commentId,
Guid commenterId,
string commentContent) : base(articleId)
{
CommentId = commentId;
CommenterId = commenterId;
CommentContent = commentContent;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
现在让我们修改聚合根来发布这些事件:
using Volo.Abp.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using BlogSystem.Domain.Articles.Events;
namespace BlogSystem.Domain.Articles
{
/// <summary>
/// 文章聚合根 - 学习模块四:支持领域事件
///
/// 关键变化:
/// 1. 继承自AggregateRoot而不是BasicAggregateRoot
/// 2. 在状态变更时发布领域事件
/// 3. 事件被临时存储在聚合根中,待提交时由框架处理
/// </summary>
public class ArticleV4 : AggregateRoot<Guid>
{
private string _title;
private string _content;
private Guid _authorId;
private int _status;
private DateTime _createdAt;
private Category _category;
private PublishedTimeRange _publishedTimeRange;
private readonly List<ArticleComment> _comments = new List<ArticleComment>();
public string Title => _title;
public string Content => _content;
public Guid AuthorId => _authorId;
public int Status => _status;
public DateTime CreatedAt => _createdAt;
public Category Category => _category;
public PublishedTimeRange PublishedTimeRange => _publishedTimeRange;
public IReadOnlyList<ArticleComment> Comments => _comments.AsReadOnly();
private ArticleV4()
{
}
private ArticleV4(
Guid id,
string title,
string content,
Guid authorId,
Category category)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("文章标题不能为空", nameof(title));
}
if (title.Length > 200)
{
throw new ArgumentException("文章标题不能超过200个字符", nameof(title));
}
if (string.IsNullOrWhiteSpace(content))
{
throw new ArgumentException("文章内容不能为空", nameof(content));
}
if (authorId == Guid.Empty)
{
throw new ArgumentException("作者ID不能为空", nameof(authorId));
}
if (category == null)
{
throw new ArgumentNullException(nameof(category), "文章分类不能为空");
}
Id = id;
_title = title;
_content = content;
_authorId = authorId;
_category = category;
_status = 0;
_createdAt = DateTime.UtcNow;
_publishedTimeRange = new PublishedTimeRange(null, null);
}
public static ArticleV4 Create(
Guid id,
string title,
string content,
Guid authorId,
Category category)
{
return new ArticleV4(id, title, content, authorId, category);
}
public void ChangeTitle(string newTitle)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改标题");
}
if (string.IsNullOrWhiteSpace(newTitle))
{
throw new ArgumentException("新标题不能为空", nameof(newTitle));
}
if (newTitle.Length > 200)
{
throw new ArgumentException("标题不能超过200个字符", nameof(newTitle));
}
_title = newTitle;
}
public void ChangeContent(string newContent)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改内容");
}
if (string.IsNullOrWhiteSpace(newContent))
{
throw new ArgumentException("新内容不能为空", nameof(newContent));
}
_content = newContent;
}
public void ChangeCategory(Category newCategory)
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改分类");
}
if (newCategory == null)
{
throw new ArgumentNullException(nameof(newCategory));
}
_category = newCategory;
}
public void SetPublishedTimeRange(DateTime? startTime, DateTime? endTime)
{
_publishedTimeRange = new PublishedTimeRange(startTime, endTime);
}
public bool CanPublishAt(DateTime dateTime)
{
return _publishedTimeRange.IsInRange(dateTime);
}
/// <summary>
/// 发布文章 - 现在会发布领域事件
///
/// 这是最重要的变化。当文章被发布时,我们:
/// 1. 检查业务规则
/// 2. 改变聚合根的状态
/// 3. 发布领域事件
///
/// 发布事件的好处是什么?
/// 1. 实现解耦:其他模块不需要知道发布文章的细节
/// 2. 实现最终一致性:订阅者可以异步处理事件
/// 3. 建立完整的审计日志:所有发生的事件都被记录
/// 4. 支持微服务架构:事件可以跨服务传播
/// </summary>
public void Publish()
{
if (_status != 0)
{
throw new InvalidOperationException("只有草稿状态的文章才能发布");
}
_status = 1;
// 发布领域事件
// AddDomainEvent方法由AggregateRoot继承而来
// 这个事件会被临时存储在聚合根中,待仓储保存时由框架处理
AddDomainEvent(new ArticlePublishedDomainEvent(
this.Id,
this._title,
this._authorId,
this._category.Name));
// 在实际业务中,您可能还想发布更多的事件
// 例如,记录谁在何时发布了这篇文章
}
public void AddComment(Guid commentId, string commentContent, Guid commenterId)
{
if (_status != 1)
{
throw new InvalidOperationException("只有已发布的文章才能接收评论");
}
var activeComments = _comments.Count(c => !c.IsDeleted);
if (activeComments >= 1000)
{
throw new InvalidOperationException("评论数量已达上限");
}
var comment = ArticleComment.Create(commentId, commentContent, commenterId);
_comments.Add(comment);
// 发布评论已添加事件
AddDomainEvent(new CommentAddedDomainEvent(
this.Id,
commentId,
commenterId,
commentContent));
}
public void DeleteComment(Guid commentId, Guid deleterId)
{
var comment = _comments.FirstOrDefault(c => c.Id == commentId);
if (comment == null)
{
throw new InvalidOperationException("评论不存在");
}
if (comment.IsDeleted)
{
throw new InvalidOperationException("评论已经被删除");
}
comment.Delete();
// 这里可以发布"评论已删除"事件,但为了简洁起见暂时省略
}
public int GetActiveCommentCount()
{
return _comments.Count(c => !c.IsDeleted);
}
public bool IsPublished => _status == 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
这个模块引入的关键概念是:
领域事件使聚合根能够通知其他部分系统有重要事件发生[5 (opens new window)][14 (opens new window)]。事件包含了事件发生时的业务数据,使得订阅者有足够的信息来处理事件[5 (opens new window)]。事件被发布到事件总线,其他的聚合或应用服务可以异步地订阅和处理这些事件[5 (opens new window)][14 (opens new window)]。这实现了聚合之间的解耦,支持最终一致性[5 (opens new window)][39 (opens new window)]。
# 学习模块五:生产级的完整聚合根实现
最后一个模块展示了一个生产级的聚合根实现,包含所有我们学到的内容,以及处理实际项目中常见场景的最佳实践。
using Volo.Abp.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using BlogSystem.Domain.Articles.Events;
namespace BlogSystem.Domain.Articles
{
/// <summary>
/// 文章状态枚举
/// 使用枚举代替魔数,提高代码可读性和可维护性
/// </summary>
public enum ArticleStatus
{
/// <summary>草稿</summary>
Draft = 0,
/// <summary>已发布</summary>
Published = 1,
/// <summary>已归档</summary>
Archived = 2,
/// <summary>已删除(软删除)</summary>
Deleted = 3
}
/// <summary>
/// 生产级文章聚合根
///
/// 这个版本结合了之前所有学习的内容,包括:
/// 1. 完整的业务规则验证
/// 2. 子实体管理
/// 3. 值对象使用
/// 4. 领域事件发布
/// 5. 生产环境常见的功能:软删除、版本管理、审计信息等
/// 6. 最佳实践:不可变设计、业务规则集中管理等
/// </summary>
public class Article : FullAuditedAggregateRoot<Guid>
{
// ==================== 私有字段 ====================
private string _title;
private string _content;
private Guid _authorId;
private ArticleStatus _status;
private DateTime _createdAt;
private DateTime? _publishedAt;
private Category _category;
private PublishedTimeRange _publishedTimeRange;
private readonly List<ArticleComment> _comments = new List<ArticleComment>();
// ==================== 公开属性(只读) ====================
public string Title => _title;
public string Content => _content;
public Guid AuthorId => _authorId;
public ArticleStatus Status => _status;
public DateTime CreatedAt => _createdAt;
public DateTime? PublishedAt => _publishedAt;
public Category Category => _category;
public PublishedTimeRange PublishedTimeRange => _publishedTimeRange;
public IReadOnlyList<ArticleComment> Comments => _comments.AsReadOnly();
/// <summary>
/// 文章阅读数
/// 这是一个计算属性,不需要存储,根据实际业务可能存储在缓存或单独的统计表中
/// </summary>
private int _readCount;
public int ReadCount => _readCount;
// ==================== 构造函数 ====================
/// <summary>
/// EF Core 使用的无参构造函数
/// 必须存在但不能被外部调用
/// </summary>
private Article()
{
}
/// <summary>
/// 私有构造函数
/// 确保聚合根的创建遵循业务规则
/// </summary>
private Article(
Guid id,
string title,
string content,
Guid authorId,
Category category)
{
ValidateAndSetBasicInfo(title, content, authorId, category);
Id = id;
_title = title;
_content = content;
_authorId = authorId;
_category = category;
_status = ArticleStatus.Draft;
_createdAt = DateTime.UtcNow;
_publishedTimeRange = new PublishedTimeRange(null, null);
_readCount = 0;
}
// ==================== 工厂方法 ====================
/// <summary>
/// 创建新文章
///
/// 使用工厂方法而不是暴露构造函数的好处:
/// 1. 封装创建逻辑
/// 2. 便于后续维护:如果创建规则改变,只需改一个地方
/// 3. 支持多种创建方式:可以添加CreateFromTemplate等变体
/// </summary>
public static Article Create(
Guid id,
string title,
string content,
Guid authorId,
Category category)
{
return new Article(id, title, content, authorId, category);
}
// ==================== 业务方法 ====================
/// <summary>
/// 修改文章标题
///
/// 这个方法演示了:
/// 1. 业务规则检查(只能修改草稿)
/// 2. 参数验证
/// 3. 状态改变
/// 4. 事件发布(可选,为了简洁省略)
/// </summary>
public void ChangeTitle(string newTitle)
{
// 业务规则:不能修改非草稿文章
if (_status != ArticleStatus.Draft)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改标题");
}
// 参数验证
ValidateTitle(newTitle);
_title = newTitle;
}
/// <summary>
/// 修改文章内容
/// </summary>
public void ChangeContent(string newContent)
{
if (_status != ArticleStatus.Draft)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改内容");
}
ValidateContent(newContent);
_content = newContent;
}
/// <summary>
/// 修改文章分类
/// </summary>
public void ChangeCategory(Category newCategory)
{
if (_status != ArticleStatus.Draft)
{
throw new InvalidOperationException("只有草稿状态的文章才能修改分类");
}
if (newCategory == null)
{
throw new ArgumentNullException(nameof(newCategory));
}
if (_category.Equals(newCategory))
{
// 如果分类没有改变,不做任何操作
return;
}
_category = newCategory;
}
/// <summary>
/// 设置发布时间范围
/// 这允许文章在未来的特定时间内自动发布
/// </summary>
public void SetPublishedTimeRange(DateTime? startTime, DateTime? endTime)
{
_publishedTimeRange = new PublishedTimeRange(startTime, endTime);
}
/// <summary>
/// 发布文章
///
/// 这是最重要的业务操作之一,需要:
/// 1. 检查文章是否处于可发布状态
/// 2. 验证必要的数据完整性
/// 3. 记录发布时间
/// 4. 发布领域事件
/// </summary>
public void Publish()
{
// 业务规则:只有草稿可以发布
if (_status != ArticleStatus.Draft)
{
throw new InvalidOperationException("只有草稿状态的文章才能发布");
}
// 可选的业务规则:验证文章的完整性
ValidatePublishability();
// 改变状态
_status = ArticleStatus.Published;
_publishedAt = DateTime.UtcNow;
// 发布领域事件
AddDomainEvent(new ArticlePublishedDomainEvent(
this.Id,
this._title,
this._authorId,
this._category.Name));
}
/// <summary>
/// 将文章归档
///
/// 归档是一个重要的状态,表示文章不再活跃但也不是删除
/// 这在实际业务中很常见,比如旧的文章需要归档但不能丢失
/// </summary>
public void Archive()
{
// 只有已发布的文章才能归档
if (_status != ArticleStatus.Published)
{
throw new InvalidOperationException("只有已发布的文章才能归档");
}
_status = ArticleStatus.Archived;
// 这里可以发布"文章已归档"事件
// AddDomainEvent(new ArticleArchivedDomainEvent(this.Id));
}
/// <summary>
/// 添加评论
///
/// 这个方法展示了复杂的业务规则检查:
/// 1. 只有已发布的文章才能接收评论
/// 2. 文章没有被删除
/// 3. 评论数量有限制
/// 4. 可以添加更多规则,如评论频率限制、垃圾评论过滤等
/// </summary>
public void AddComment(Guid commentId, string commentContent, Guid commenterId)
{
// 文章必须是已发布状态且未被删除
if (_status != ArticleStatus.Published)
{
throw new InvalidOperationException("只有已发布的文章才能接收评论");
}
if (_status == ArticleStatus.Deleted || IsDeleted)
{
throw new InvalidOperationException("已删除的文章无法接收评论");
}
// 验证评论内容
if (string.IsNullOrWhiteSpace(commentContent))
{
throw new ArgumentException("评论内容不能为空", nameof(commentContent));
}
if (commentContent.Length > 1000)
{
throw new ArgumentException("评论内容不能超过1000个字符", nameof(commentContent));
}
// 业务规则:评论数量限制
var activeComments = _comments.Count(c => !c.IsDeleted);
if (activeComments >= 10000) // 设置一个合理的上限
{
throw new InvalidOperationException("评论数量已达上限");
}
// 创建评论
var comment = ArticleComment.Create(commentId, commentContent, commenterId);
_comments.Add(comment);
// 发布事件
AddDomainEvent(new CommentAddedDomainEvent(
this.Id,
commentId,
commenterId,
commentContent));
}
/// <summary>
/// 删除评论
/// </summary>
public void DeleteComment(Guid commentId)
{
var comment = _comments.FirstOrDefault(c => c.Id == commentId);
if (comment == null)
{
throw new InvalidOperationException("评论不存在");
}
if (comment.IsDeleted)
{
throw new InvalidOperationException("评论已经被删除");
}
comment.Delete();
}
/// <summary>
/// 增加阅读数
///
/// 这是一个简单但重要的业务操作。
/// 在实际项目中,可能会有更复杂的规则,比如:
/// 1. 同一用户在短时间内多次访问只计一次
/// 2. 机器人访问不计数
/// 3. 阅读数的更新可能是异步的,存储在缓存中
/// </summary>
public void IncrementReadCount()
{
if (_status != ArticleStatus.Published)
{
// 已删除或未发布的文章不应该增加阅读数
throw new InvalidOperationException("无法增加非发布状态文章的阅读数");
}
_readCount++;
}
// ==================== 查询方法 ====================
public bool IsPublished => _status == ArticleStatus.Published;
public bool IsArchived => _status == ArticleStatus.Archived;
public bool CanPublish => _status == ArticleStatus.Draft;
public int GetActiveCommentCount() => _comments.Count(c => !c.IsDeleted);
public bool CanAcceptComments => IsPublished && !IsDeleted;
// ==================== 私有验证方法 ====================
/// <summary>
/// 这些私有验证方法将验证逻辑集中在一个地方
/// 便于维护和复用
/// </summary>
private void ValidateAndSetBasicInfo(string title, string content, Guid authorId, Category category)
{
ValidateTitle(title);
ValidateContent(content);
if (authorId == Guid.Empty)
{
throw new ArgumentException("作者ID不能为空", nameof(authorId));
}
if (category == null)
{
throw new ArgumentNullException(nameof(category), "文章分类不能为空");
}
}
private void ValidateTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("文章标题不能为空", nameof(title));
}
if (title.Length > 200)
{
throw new ArgumentException("文章标题不能超过200个字符", nameof(title));
}
}
private void ValidateContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
throw new ArgumentException("文章内容不能为空", nameof(content));
}
// 可以添加最小长度限制
if (content.Length < 10)
{
throw new ArgumentException("文章内容至少需要10个字符", nameof(content));
}
}
/// <summary>
/// 验证文章是否满足发布的所有条件
/// 这可以包含更复杂的业务规则
/// </summary>
private void ValidatePublishability()
{
// 这里可以添加更多验证,比如:
// - 文章是否包含敏感词汇
// - 作者是否有发布权限
// - 文章是否被审核通过
// 等等
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# 结论
通过这五个循序渐进的学习模块,您已经掌握了ABP vNext框架中聚合根设计的完整体系[1 (opens new window)][2 (opens new window)][6 (opens new window)]。从最基础的BasicAggregateRoot到包含领域事件和复杂业务规则的FullAuditedAggregateRoot,您学习了如何设计满足生产环境需求的聚合根[9 (opens new window)]。
关键要点总结:聚合根是DDD中最重要的概念,它通过集中管理业务对象来确保数据一致性[2 (opens new window)][41 (opens new window)]。聚合内使用强一致性,聚合之间使用最终一致性和ID引用[39 (opens new window)][56 (opens new window)]。领域事件使聚合根能够与其他部分系统解耦通信[5 (opens new window)][14 (opens new window)]。聚合根应该包含所有的业务规则验证,防止对象进入无效状态[8 (opens new window)][35 (opens new window)]。值对象用来表示不可变的、无标识的业务概念[20 (opens new window)][23 (opens new window)]。
这份完整的学习指南为您构建生产级的DDD应用奠定了坚实基础[6 (opens new window)]。