[{"data":1,"prerenderedAt":753},["ShallowReactive",2],{"blog-ddd-tactical-design":3},{"id":4,"title":5,"body":6,"category":739,"date":740,"description":111,"extension":741,"meta":742,"navigation":523,"path":743,"seo":744,"series":745,"seriesOrder":131,"stem":746,"tags":747,"__hash__":752},"blog\u002Fblog\u002Fddd-tactical-design.md","DDD 实战（三）：战术设计，实体、值对象、聚合根与领域事件",{"type":7,"value":8,"toc":727},"minimark",[9,13,28,31,34,53,56,64,69,72,75,89,96,99,102,105,135,138,142,145,148,168,171,182,185,189,192,206,209,212,229,232,235,241,245,248,255,346,349,356,360,363,366,369,386,389,408,411,415,418,421,445,456,459,463,466,487,490,574,577,580,584,587,619,622,628,645,648,651,655,658,661,678,681,684,688,691,711,714,717,723],[10,11,5],"h1",{"id":12},"ddd-实战三战术设计实体值对象聚合根与领域事件",[14,15,16],"blockquote",{},[17,18,19,20,27],"p",{},"推荐 ",[21,22,26],"a",{"href":23,"rel":24},"https:\u002F\u002Fbewild.ai?code=BYZDOTME",[25],"nofollow","BeWild"," 代充 Codex ，工作猛猛提效。",[17,29,30],{},"战略设计解决“系统如何按业务边界拆开”，战术设计解决“一个边界内部如何表达业务规则”。",[17,32,33],{},"很多团队实践 DDD 失败，问题不在于不知道概念，而在于把战术概念直接映射成技术对象：",[35,36,37,41,44,47,50],"ul",{},[38,39,40],"li",{},"数据库主表就是聚合根。",[38,42,43],{},"数据库从表就是实体。",[38,45,46],{},"所有字段对象都是值对象。",[38,48,49],{},"所有 service 都叫领域服务。",[38,51,52],{},"所有 MQ 消息都叫领域事件。",[17,54,55],{},"这样做没有真正改变模型，只是换了一套名词。战术设计的关键，是围绕业务不变量和行为来建模，而不是围绕表结构建模。",[17,57,58,59],{},"Microsoft 的战术 DDD 文档把实体、值对象、聚合、领域服务、领域事件作为 bounded context 内部的建模模式，并强调聚合是事务一致性边界，而不是对象层级装饰。",[21,60,63],{"href":61,"rel":62},"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fazure\u002Farchitecture\u002Fmicroservices\u002Fmodel\u002Ftactical-domain-driven-design",[25],"Microsoft: Tactical DDD",[65,66,68],"h2",{"id":67},"_1-实体身份比属性更重要","1. 实体：身份比属性更重要",[17,70,71],{},"实体的核心特征是有身份。只要身份不变，属性变化以后仍然是同一个业务对象。",[17,73,74],{},"在协同办公 IM 里，下面这些通常是实体：",[35,76,77,80,83,86],{},[38,78,79],{},"Account：账号 ID 不变，手机号、头像、昵称可以变化。",[38,81,82],{},"Member：组织成员 ID 不变，部门、岗位、状态可以变化。",[38,84,85],{},"Group：群 ID 不变，群名称、公告、头像可以变化。",[38,87,88],{},"Message：消息 ID 不变，已读状态、撤回状态、编辑状态可以变化。",[17,90,91,92],{},"判断一个对象是不是实体，不要先问它有没有数据库 ID，而要问：",[93,94,95],"strong",{},"业务是否需要跨时间追踪它的连续身份？",[17,97,98],{},"比如“群昵称”通常不是实体。它只是某个群成员在某个群里的展示值，变了以后不需要追踪为一个独立对象。它更像群成员实体上的一个属性，或者一个值对象。",[17,100,101],{},"实体不应该只是字段集合。实体应该承载和自己身份紧密相关的业务行为。",[17,103,104],{},"例如群成员可以有这些行为：",[106,107,112],"pre",{"className":108,"code":109,"language":110,"meta":111,"style":111},"language-go shiki shiki-themes github-dark","func (m *GroupMember) Mute(by MemberID, until time.Time) error\nfunc (m *GroupMember) ChangeRole(by MemberID, role GroupRole) error\nfunc (m *GroupMember) Leave(now time.Time) error\n","go","",[113,114,115,123,129],"code",{"__ignoreMap":111},[116,117,120],"span",{"class":118,"line":119},"line",1,[116,121,122],{},"func (m *GroupMember) Mute(by MemberID, until time.Time) error\n",[116,124,126],{"class":118,"line":125},2,[116,127,128],{},"func (m *GroupMember) ChangeRole(by MemberID, role GroupRole) error\n",[116,130,132],{"class":118,"line":131},3,[116,133,134],{},"func (m *GroupMember) Leave(now time.Time) error\n",[17,136,137],{},"这些行为背后可以封装规则：谁能禁言、管理员是否能移除群主、退出后是否保留历史消息权限。不要把所有规则都挪到外部 service。",[65,139,141],{"id":140},"_2-值对象用值表达业务概念","2. 值对象：用值表达业务概念",[17,143,144],{},"值对象没有独立身份，只由属性值定义。两个值对象只要值相同，就可以认为相等。",[17,146,147],{},"协同办公 IM 里常见的值对象包括：",[35,149,150,153,156,159,162,165],{},[38,151,152],{},"PhoneNumber：手机号。",[38,154,155],{},"EmailAddress：邮箱。",[38,157,158],{},"MessageContent：消息内容。",[38,160,161],{},"TimeRange：时间范围。",[38,163,164],{},"TenantID、GroupID、MessageID 这类强类型 ID。",[38,166,167],{},"PermissionScope：权限范围。",[17,169,170],{},"值对象的价值，是让代码表达业务含义，而不是到处传裸字符串、裸整数、裸 map。",[17,172,173,174,177,178,181],{},"比如消息内容不应该只是一个 ",[113,175,176],{},"string","。真实 IM 里，消息可能有文本、图片、文件、卡片、引用、表情、富文本、加密载荷。更好的做法是让 ",[113,179,180],{},"MessageContent"," 明确表达内容类型、大小限制、摘要生成、安全策略和序列化边界。",[17,183,184],{},"值对象通常应该不可变。更新一个值对象时，创建一个新值替换旧值，而不是在原对象上到处修改。这样更容易推理，也更适合并发和事件记录。",[65,186,188],{"id":187},"_3-聚合一致性边界不是表集合","3. 聚合：一致性边界，不是表集合",[17,190,191],{},"聚合是战术设计中最容易被误用的概念。",[17,193,194,195,198,199,202,203],{},"很多人把“主表 + 从表”看成聚合，比如 ",[113,196,197],{},"group"," 表加 ",[113,200,201],{},"group_member"," 表就是 Group 聚合。这个判断可能对，也可能不对。真正要问的是：",[93,204,205],{},"哪些对象必须在一次业务事务中保持一致？",[17,207,208],{},"聚合关注的是业务不变量。",[17,210,211],{},"以群组为例，Group 聚合可能需要维护这些规则：",[35,213,214,217,220,223,226],{},[38,215,216],{},"群必须有且只有一个群主。",[38,218,219],{},"群主不能被普通管理员移除。",[38,221,222],{},"群成员数量不能超过群容量。",[38,224,225],{},"禁言状态必须满足群治理规则。",[38,227,228],{},"入群审批通过后才能成为正式成员。",[17,230,231],{},"如果这些规则要求 Group 和 GroupMember 在一次事务中一起校验和修改，那么它们可以处于同一个聚合内。",[17,233,234],{},"但消息通常不应该放进 Group 聚合。因为一个群可以有海量消息，消息有独立生命周期，消息发送、撤回、已读、搜索、归档都不应该锁住整个群聚合。群组聚合只需要提供“这个会话是否允许发送”的规则，消息事实属于消息聚合或会话消息上下文。",[17,236,237,238],{},"这就是聚合设计的关键：",[93,239,240],{},"小聚合优先，只把必须强一致的对象放在一起。",[65,242,244],{"id":243},"_4-聚合根外部访问聚合的入口","4. 聚合根：外部访问聚合的入口",[17,246,247],{},"一个聚合只能有一个聚合根。外部对象应该通过聚合根访问聚合内部对象，而不是直接修改内部实体。",[17,249,250,251,254],{},"比如 Group 是聚合根，GroupMember 是聚合内部实体。外部不应该直接调用 ",[113,252,253],{},"GroupMemberRepository.Save(member)"," 去改群成员角色，而应该通过 Group 聚合：",[106,256,258],{"className":108,"code":257,"language":110,"meta":111,"style":111},"func (g *Group) ChangeMemberRole(operator MemberID, target MemberID, role GroupRole) error {\n    if !g.canManage(operator, target) {\n        return ErrNoPermission\n    }\n    if target == g.ownerID && role != GroupRoleOwner {\n        return ErrCannotDemoteOwner\n    }\n    g.members.ChangeRole(target, role)\n    g.addEvent(GroupMemberRoleChanged{\n        GroupID:  g.id,\n        MemberID: target,\n        Role:     role,\n    })\n    return nil\n}\n",[113,259,260,265,270,275,281,287,293,298,304,310,316,322,328,334,340],{"__ignoreMap":111},[116,261,262],{"class":118,"line":119},[116,263,264],{},"func (g *Group) ChangeMemberRole(operator MemberID, target MemberID, role GroupRole) error {\n",[116,266,267],{"class":118,"line":125},[116,268,269],{},"    if !g.canManage(operator, target) {\n",[116,271,272],{"class":118,"line":131},[116,273,274],{},"        return ErrNoPermission\n",[116,276,278],{"class":118,"line":277},4,[116,279,280],{},"    }\n",[116,282,284],{"class":118,"line":283},5,[116,285,286],{},"    if target == g.ownerID && role != GroupRoleOwner {\n",[116,288,290],{"class":118,"line":289},6,[116,291,292],{},"        return ErrCannotDemoteOwner\n",[116,294,296],{"class":118,"line":295},7,[116,297,280],{},[116,299,301],{"class":118,"line":300},8,[116,302,303],{},"    g.members.ChangeRole(target, role)\n",[116,305,307],{"class":118,"line":306},9,[116,308,309],{},"    g.addEvent(GroupMemberRoleChanged{\n",[116,311,313],{"class":118,"line":312},10,[116,314,315],{},"        GroupID:  g.id,\n",[116,317,319],{"class":118,"line":318},11,[116,320,321],{},"        MemberID: target,\n",[116,323,325],{"class":118,"line":324},12,[116,326,327],{},"        Role:     role,\n",[116,329,331],{"class":118,"line":330},13,[116,332,333],{},"    })\n",[116,335,337],{"class":118,"line":336},14,[116,338,339],{},"    return nil\n",[116,341,343],{"class":118,"line":342},15,[116,344,345],{},"}\n",[17,347,348],{},"这样做的意义不是“代码更面向对象”，而是防止业务不变量被绕过。",[17,350,351,352,355],{},"如果任何地方都能直接改 ",[113,353,354],{},"group_member.role","，那群主唯一性、权限校验、审计事件就很难保证。",[65,357,359],{"id":358},"_5-领域服务放那些不属于单个实体的规则","5. 领域服务：放那些不属于单个实体的规则",[17,361,362],{},"领域服务不是普通 service 的新名字。",[17,364,365],{},"只有当某个业务规则不自然属于单个实体或值对象时，才需要领域服务。它应该表达领域行为，而不是编排数据库、调用 RPC、发 MQ。",[17,367,368],{},"比如“两个成员是否允许建立好友关系”，可能需要考虑：",[35,370,371,374,377,380,383],{},[38,372,373],{},"双方是否在同一租户。",[38,375,376],{},"是否已被对方拉黑。",[38,378,379],{},"组织策略是否允许外部联系。",[38,381,382],{},"是否命中风控策略。",[38,384,385],{},"是否需要审批。",[17,387,388],{},"这个规则不完全属于 Account，也不完全属于 ContactRelation。可以抽象成领域服务：",[106,390,392],{"className":108,"code":391,"language":110,"meta":111,"style":111},"type RelationshipPolicy interface {\n    CanCreateFriendship(ctx context.Context, applicant MemberID, target MemberID) (Decision, error)\n}\n",[113,393,394,399,404],{"__ignoreMap":111},[116,395,396],{"class":118,"line":119},[116,397,398],{},"type RelationshipPolicy interface {\n",[116,400,401],{"class":118,"line":125},[116,402,403],{},"    CanCreateFriendship(ctx context.Context, applicant MemberID, target MemberID) (Decision, error)\n",[116,405,406],{"class":118,"line":131},[116,407,345],{},[17,409,410],{},"但如果只是“查询用户并保存数据库”，那不是领域服务，那是应用服务或基础设施。",[65,412,414],{"id":413},"_6-repository保存聚合不是封装任意-sql","6. Repository：保存聚合，不是封装任意 SQL",[17,416,417],{},"Repository 的职责是以领域模型的方式加载和保存聚合。它不是 DAO 的同义词，也不是给每张表都配一个仓储。",[17,419,420],{},"更合理的接口通常长这样：",[106,422,424],{"className":108,"code":423,"language":110,"meta":111,"style":111},"type GroupRepository interface {\n    Get(ctx context.Context, id GroupID) (*Group, error)\n    Save(ctx context.Context, group *Group) error\n}\n",[113,425,426,431,436,441],{"__ignoreMap":111},[116,427,428],{"class":118,"line":119},[116,429,430],{},"type GroupRepository interface {\n",[116,432,433],{"class":118,"line":125},[116,434,435],{},"    Get(ctx context.Context, id GroupID) (*Group, error)\n",[116,437,438],{"class":118,"line":131},[116,439,440],{},"    Save(ctx context.Context, group *Group) error\n",[116,442,443],{"class":118,"line":277},[116,444,345],{},[17,446,447,448,451,452,455],{},"它隐藏了 Group 聚合如何落库。也许背后是一张 ",[113,449,450],{},"groups"," 表和一张 ",[113,453,454],{},"group_members"," 表，也许还有缓存、版本号、乐观锁。应用层不关心这些细节。",[17,457,458],{},"Repository 接口通常属于领域层或应用层的端口，具体实现属于基础设施层。这能保持依赖方向从外向内，避免领域模型依赖数据库框架。",[65,460,462],{"id":461},"_7-应用服务编排用例不承载核心规则","7. 应用服务：编排用例，不承载核心规则",[17,464,465],{},"应用服务负责完成一个用例的编排：",[467,468,469,472,475,478,481,484],"ol",{},[38,470,471],{},"校验请求身份和基本参数。",[38,473,474],{},"加载聚合。",[38,476,477],{},"调用领域行为。",[38,479,480],{},"保存聚合。",[38,482,483],{},"发布领域事件或写 outbox。",[38,485,486],{},"返回 DTO。",[17,488,489],{},"例如创建群聊：",[106,491,493],{"className":108,"code":492,"language":110,"meta":111,"style":111},"func (s *CreateGroupService) Handle(ctx context.Context, cmd CreateGroupCommand) (GroupID, error) {\n    creator, err := s.members.Get(ctx, cmd.CreatorID)\n    if err != nil {\n        return \"\", err\n    }\n\n    group, err := groupdomain.NewGroup(cmd.Name, creator.ID(), cmd.MemberIDs)\n    if err != nil {\n        return \"\", err\n    }\n\n    if err := s.groups.Save(ctx, group); err != nil {\n        return \"\", err\n    }\n\n    return group.ID(), s.events.Publish(ctx, group.PullEvents())\n}\n",[113,494,495,500,505,510,515,519,525,530,534,538,542,546,551,555,559,563,569],{"__ignoreMap":111},[116,496,497],{"class":118,"line":119},[116,498,499],{},"func (s *CreateGroupService) Handle(ctx context.Context, cmd CreateGroupCommand) (GroupID, error) {\n",[116,501,502],{"class":118,"line":125},[116,503,504],{},"    creator, err := s.members.Get(ctx, cmd.CreatorID)\n",[116,506,507],{"class":118,"line":131},[116,508,509],{},"    if err != nil {\n",[116,511,512],{"class":118,"line":277},[116,513,514],{},"        return \"\", err\n",[116,516,517],{"class":118,"line":283},[116,518,280],{},[116,520,521],{"class":118,"line":289},[116,522,524],{"emptyLinePlaceholder":523},true,"\n",[116,526,527],{"class":118,"line":295},[116,528,529],{},"    group, err := groupdomain.NewGroup(cmd.Name, creator.ID(), cmd.MemberIDs)\n",[116,531,532],{"class":118,"line":300},[116,533,509],{},[116,535,536],{"class":118,"line":306},[116,537,514],{},[116,539,540],{"class":118,"line":312},[116,541,280],{},[116,543,544],{"class":118,"line":318},[116,545,524],{"emptyLinePlaceholder":523},[116,547,548],{"class":118,"line":324},[116,549,550],{},"    if err := s.groups.Save(ctx, group); err != nil {\n",[116,552,553],{"class":118,"line":330},[116,554,514],{},[116,556,557],{"class":118,"line":336},[116,558,280],{},[116,560,561],{"class":118,"line":342},[116,562,524],{"emptyLinePlaceholder":523},[116,564,566],{"class":118,"line":565},16,[116,567,568],{},"    return group.ID(), s.events.Publish(ctx, group.PullEvents())\n",[116,570,572],{"class":118,"line":571},17,[116,573,345],{},[17,575,576],{},"这里的应用服务不应该决定“群主是否能被移除”“群人数是否超限”“入群是否需要审批”。这些规则应该在领域模型里。",[17,578,579],{},"应用服务可以处理事务、幂等、权限入口、日志和调用编排，但不能变成所有业务规则的垃圾桶。",[65,581,583],{"id":582},"_8-领域事件业务事实不是普通消息","8. 领域事件：业务事实，不是普通消息",[17,585,586],{},"领域事件表达的是领域中已经发生的业务事实。它通常用过去式命名：",[35,588,589,594,599,604,609,614],{},[38,590,591],{},[113,592,593],{},"GroupCreated",[38,595,596],{},[113,597,598],{},"GroupMemberJoined",[38,600,601],{},[113,602,603],{},"MessageSent",[38,605,606],{},[113,607,608],{},"MessageRecalled",[38,610,611],{},[113,612,613],{},"OrganizationMemberRemoved",[38,615,616],{},[113,617,618],{},"ContactRelationBlocked",[17,620,621],{},"领域事件不是“我要通知谁做什么”，而是“某件业务事实已经发生”。订阅方根据这个事实决定自己的动作。",[17,623,624,625,627],{},"例如 ",[113,626,603],{}," 发生后：",[35,629,630,633,636,639,642],{},[38,631,632],{},"通知上下文可以生成离线推送。",[38,634,635],{},"搜索上下文可以更新索引。",[38,637,638],{},"开放平台上下文可以触发机器人和 Webhook。",[38,640,641],{},"审计上下文可以记录审计日志。",[38,643,644],{},"AI 上下文可以异步生成摘要。",[17,646,647],{},"消息发送主链路不应该同步等待这些全部完成。领域事件的价值，就是把核心事实和派生动作解耦。",[17,649,650],{},"但领域事件也要谨慎使用。不要把所有方法调用都改成事件。强一致规则仍然应该在聚合内完成；跨上下文、可异步、可重试、可补偿的动作，才适合事件化。",[65,652,654],{"id":653},"_9-outbox让领域事件可靠落地","9. Outbox：让领域事件可靠落地",[17,656,657],{},"真实系统里，最危险的情况是：数据库写成功了，但事件没发出去。比如消息已经入库，但搜索、通知、开放平台都不知道这条消息存在。",[17,659,660],{},"常见做法是使用 outbox 模式：",[467,662,663,666,669,672,675],{},[38,664,665],{},"在同一个数据库事务中保存业务数据和待发布事件。",[38,667,668],{},"后台任务扫描 outbox 表。",[38,670,671],{},"将事件发布到 MQ 或事件总线。",[38,673,674],{},"发布成功后标记事件状态。",[38,676,677],{},"消费端按事件 ID 做幂等。",[17,679,680],{},"这样不要求数据库和 MQ 做分布式事务，也能显著提高事件可靠性。",[17,682,683],{},"对于协同办公 IM 这种事件密集型系统，outbox 几乎是领域事件落地时必须考虑的基础能力。",[65,685,687],{"id":686},"_10-战术设计的底线","10. 战术设计的底线",[17,689,690],{},"战术设计不是为了把代码写得复杂，而是为了守住几个底线：",[35,692,693,696,699,702,705,708],{},[38,694,695],{},"业务规则有明确归属。",[38,697,698],{},"聚合保护强一致不变量。",[38,700,701],{},"应用服务只编排用例，不吞掉领域模型。",[38,703,704],{},"Repository 保存聚合，不暴露表结构。",[38,706,707],{},"领域事件表达业务事实，而不是技术通知。",[38,709,710],{},"跨聚合协作默认考虑最终一致和幂等。",[17,712,713],{},"如果这些底线守不住，就算项目目录看起来非常 DDD，本质上仍然是普通事务脚本。",[17,715,716],{},"下一篇开始，我们把这些概念放进完整的协同办公 IM 平台里，看如何从钉钉、飞书\u002FLark 等公开能力反推业务域地图。",[17,718,719],{},[21,720,722],{"href":721},"\u002Fblog\u002F","返回博客列表",[724,725,726],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":111,"searchDepth":125,"depth":125,"links":728},[729,730,731,732,733,734,735,736,737,738],{"id":67,"depth":125,"text":68},{"id":140,"depth":125,"text":141},{"id":187,"depth":125,"text":188},{"id":243,"depth":125,"text":244},{"id":358,"depth":125,"text":359},{"id":413,"depth":125,"text":414},{"id":461,"depth":125,"text":462},{"id":582,"depth":125,"text":583},{"id":653,"depth":125,"text":654},{"id":686,"depth":125,"text":687},"架构设计","2026-06-25","md",{},"\u002Fblog\u002Fddd-tactical-design",{"title":5,"description":111},"DDD 实战","blog\u002Fddd-tactical-design",[748,749,750,751],"DDD","战术设计","聚合根","领域事件","4vcJ-nzbgLRK9ommXg67oakqge4N3K716APHRP2KemU",1784193099131]