DDD 实战(三):战术设计,实体、值对象、聚合根与领域事件
DDD 实战(三):战术设计,实体、值对象、聚合根与领域事件
推荐 BeWild 代充 Codex ,工作猛猛提效。
战略设计解决“系统如何按业务边界拆开”,战术设计解决“一个边界内部如何表达业务规则”。
很多团队实践 DDD 失败,问题不在于不知道概念,而在于把战术概念直接映射成技术对象:
- 数据库主表就是聚合根。
- 数据库从表就是实体。
- 所有字段对象都是值对象。
- 所有 service 都叫领域服务。
- 所有 MQ 消息都叫领域事件。
这样做没有真正改变模型,只是换了一套名词。战术设计的关键,是围绕业务不变量和行为来建模,而不是围绕表结构建模。
Microsoft 的战术 DDD 文档把实体、值对象、聚合、领域服务、领域事件作为 bounded context 内部的建模模式,并强调聚合是事务一致性边界,而不是对象层级装饰。Microsoft: Tactical DDD
1. 实体:身份比属性更重要
实体的核心特征是有身份。只要身份不变,属性变化以后仍然是同一个业务对象。
在协同办公 IM 里,下面这些通常是实体:
- Account:账号 ID 不变,手机号、头像、昵称可以变化。
- Member:组织成员 ID 不变,部门、岗位、状态可以变化。
- Group:群 ID 不变,群名称、公告、头像可以变化。
- Message:消息 ID 不变,已读状态、撤回状态、编辑状态可以变化。
判断一个对象是不是实体,不要先问它有没有数据库 ID,而要问:业务是否需要跨时间追踪它的连续身份?
比如“群昵称”通常不是实体。它只是某个群成员在某个群里的展示值,变了以后不需要追踪为一个独立对象。它更像群成员实体上的一个属性,或者一个值对象。
实体不应该只是字段集合。实体应该承载和自己身份紧密相关的业务行为。
例如群成员可以有这些行为:
func (m *GroupMember) Mute(by MemberID, until time.Time) error
func (m *GroupMember) ChangeRole(by MemberID, role GroupRole) error
func (m *GroupMember) Leave(now time.Time) error
这些行为背后可以封装规则:谁能禁言、管理员是否能移除群主、退出后是否保留历史消息权限。不要把所有规则都挪到外部 service。
2. 值对象:用值表达业务概念
值对象没有独立身份,只由属性值定义。两个值对象只要值相同,就可以认为相等。
协同办公 IM 里常见的值对象包括:
- PhoneNumber:手机号。
- EmailAddress:邮箱。
- MessageContent:消息内容。
- TimeRange:时间范围。
- TenantID、GroupID、MessageID 这类强类型 ID。
- PermissionScope:权限范围。
值对象的价值,是让代码表达业务含义,而不是到处传裸字符串、裸整数、裸 map。
比如消息内容不应该只是一个 string。真实 IM 里,消息可能有文本、图片、文件、卡片、引用、表情、富文本、加密载荷。更好的做法是让 MessageContent 明确表达内容类型、大小限制、摘要生成、安全策略和序列化边界。
值对象通常应该不可变。更新一个值对象时,创建一个新值替换旧值,而不是在原对象上到处修改。这样更容易推理,也更适合并发和事件记录。
3. 聚合:一致性边界,不是表集合
聚合是战术设计中最容易被误用的概念。
很多人把“主表 + 从表”看成聚合,比如 group 表加 group_member 表就是 Group 聚合。这个判断可能对,也可能不对。真正要问的是:哪些对象必须在一次业务事务中保持一致?
聚合关注的是业务不变量。
以群组为例,Group 聚合可能需要维护这些规则:
- 群必须有且只有一个群主。
- 群主不能被普通管理员移除。
- 群成员数量不能超过群容量。
- 禁言状态必须满足群治理规则。
- 入群审批通过后才能成为正式成员。
如果这些规则要求 Group 和 GroupMember 在一次事务中一起校验和修改,那么它们可以处于同一个聚合内。
但消息通常不应该放进 Group 聚合。因为一个群可以有海量消息,消息有独立生命周期,消息发送、撤回、已读、搜索、归档都不应该锁住整个群聚合。群组聚合只需要提供“这个会话是否允许发送”的规则,消息事实属于消息聚合或会话消息上下文。
这就是聚合设计的关键:小聚合优先,只把必须强一致的对象放在一起。
4. 聚合根:外部访问聚合的入口
一个聚合只能有一个聚合根。外部对象应该通过聚合根访问聚合内部对象,而不是直接修改内部实体。
比如 Group 是聚合根,GroupMember 是聚合内部实体。外部不应该直接调用 GroupMemberRepository.Save(member) 去改群成员角色,而应该通过 Group 聚合:
func (g *Group) ChangeMemberRole(operator MemberID, target MemberID, role GroupRole) error {
if !g.canManage(operator, target) {
return ErrNoPermission
}
if target == g.ownerID && role != GroupRoleOwner {
return ErrCannotDemoteOwner
}
g.members.ChangeRole(target, role)
g.addEvent(GroupMemberRoleChanged{
GroupID: g.id,
MemberID: target,
Role: role,
})
return nil
}
这样做的意义不是“代码更面向对象”,而是防止业务不变量被绕过。
如果任何地方都能直接改 group_member.role,那群主唯一性、权限校验、审计事件就很难保证。
5. 领域服务:放那些不属于单个实体的规则
领域服务不是普通 service 的新名字。
只有当某个业务规则不自然属于单个实体或值对象时,才需要领域服务。它应该表达领域行为,而不是编排数据库、调用 RPC、发 MQ。
比如“两个成员是否允许建立好友关系”,可能需要考虑:
- 双方是否在同一租户。
- 是否已被对方拉黑。
- 组织策略是否允许外部联系。
- 是否命中风控策略。
- 是否需要审批。
这个规则不完全属于 Account,也不完全属于 ContactRelation。可以抽象成领域服务:
type RelationshipPolicy interface {
CanCreateFriendship(ctx context.Context, applicant MemberID, target MemberID) (Decision, error)
}
但如果只是“查询用户并保存数据库”,那不是领域服务,那是应用服务或基础设施。
6. Repository:保存聚合,不是封装任意 SQL
Repository 的职责是以领域模型的方式加载和保存聚合。它不是 DAO 的同义词,也不是给每张表都配一个仓储。
更合理的接口通常长这样:
type GroupRepository interface {
Get(ctx context.Context, id GroupID) (*Group, error)
Save(ctx context.Context, group *Group) error
}
它隐藏了 Group 聚合如何落库。也许背后是一张 groups 表和一张 group_members 表,也许还有缓存、版本号、乐观锁。应用层不关心这些细节。
Repository 接口通常属于领域层或应用层的端口,具体实现属于基础设施层。这能保持依赖方向从外向内,避免领域模型依赖数据库框架。
7. 应用服务:编排用例,不承载核心规则
应用服务负责完成一个用例的编排:
- 校验请求身份和基本参数。
- 加载聚合。
- 调用领域行为。
- 保存聚合。
- 发布领域事件或写 outbox。
- 返回 DTO。
例如创建群聊:
func (s *CreateGroupService) Handle(ctx context.Context, cmd CreateGroupCommand) (GroupID, error) {
creator, err := s.members.Get(ctx, cmd.CreatorID)
if err != nil {
return "", err
}
group, err := groupdomain.NewGroup(cmd.Name, creator.ID(), cmd.MemberIDs)
if err != nil {
return "", err
}
if err := s.groups.Save(ctx, group); err != nil {
return "", err
}
return group.ID(), s.events.Publish(ctx, group.PullEvents())
}
这里的应用服务不应该决定“群主是否能被移除”“群人数是否超限”“入群是否需要审批”。这些规则应该在领域模型里。
应用服务可以处理事务、幂等、权限入口、日志和调用编排,但不能变成所有业务规则的垃圾桶。
8. 领域事件:业务事实,不是普通消息
领域事件表达的是领域中已经发生的业务事实。它通常用过去式命名:
GroupCreatedGroupMemberJoinedMessageSentMessageRecalledOrganizationMemberRemovedContactRelationBlocked
领域事件不是“我要通知谁做什么”,而是“某件业务事实已经发生”。订阅方根据这个事实决定自己的动作。
例如 MessageSent 发生后:
- 通知上下文可以生成离线推送。
- 搜索上下文可以更新索引。
- 开放平台上下文可以触发机器人和 Webhook。
- 审计上下文可以记录审计日志。
- AI 上下文可以异步生成摘要。
消息发送主链路不应该同步等待这些全部完成。领域事件的价值,就是把核心事实和派生动作解耦。
但领域事件也要谨慎使用。不要把所有方法调用都改成事件。强一致规则仍然应该在聚合内完成;跨上下文、可异步、可重试、可补偿的动作,才适合事件化。
9. Outbox:让领域事件可靠落地
真实系统里,最危险的情况是:数据库写成功了,但事件没发出去。比如消息已经入库,但搜索、通知、开放平台都不知道这条消息存在。
常见做法是使用 outbox 模式:
- 在同一个数据库事务中保存业务数据和待发布事件。
- 后台任务扫描 outbox 表。
- 将事件发布到 MQ 或事件总线。
- 发布成功后标记事件状态。
- 消费端按事件 ID 做幂等。
这样不要求数据库和 MQ 做分布式事务,也能显著提高事件可靠性。
对于协同办公 IM 这种事件密集型系统,outbox 几乎是领域事件落地时必须考虑的基础能力。
10. 战术设计的底线
战术设计不是为了把代码写得复杂,而是为了守住几个底线:
- 业务规则有明确归属。
- 聚合保护强一致不变量。
- 应用服务只编排用例,不吞掉领域模型。
- Repository 保存聚合,不暴露表结构。
- 领域事件表达业务事实,而不是技术通知。
- 跨聚合协作默认考虑最终一致和幂等。
如果这些底线守不住,就算项目目录看起来非常 DDD,本质上仍然是普通事务脚本。
下一篇开始,我们把这些概念放进完整的协同办公 IM 平台里,看如何从钉钉、飞书/Lark 等公开能力反推业务域地图。