DDD 实战(一):业务变复杂之后,规则该归谁管
海外 AI 服务代充推荐:BeWild(推广链接)。
笔者做的是协同办公 IM 的服务端。这类系统有个共同点:功能列表看上去平平无奇——发消息、建群、加好友、管通讯录,哪个 IM 都长这样;可每改一个需求,动过的地方总比预想的多。
印象最深的是“成员离职”。产品经理的描述只有一句话:把人从企业里去掉。真要落地,登录权限、通讯录可见性、历史消息、群成员身份、文档权限、开放平台回调、审计日志,一样都不能漏。
按技术分层写了几年的服务,遇到这种需求,第一反应通常是找一个 Service 把相关的表都改一遍。改完能跑,可下一次需求来了,没人说得清“这条规则到底归谁管”。给项目补上 domain、application、infrastructure、interfaces 这套目录,也回答不了这个问题。
DDD 值得讨论的地方,正是把这些决定分配给合适的模型,并说明模型之间如何合作。这个系列用协同办公 IM 展开,途中会经过限界上下文、核心域、聚合根、领域服务、仓储、领域事件这些名词——现在不必记住它们,先从一个更朴素的问题开始:什么样的业务变化,会让代码越来越难改。
什么样的变化会让系统难改
页面、接口和表的数量只能说明系统规模。一个后台即使有上百张表,只要规则相对独立,改动仍然容易定位;反过来,只有几十张表的后台,也可能让人不敢下手。麻烦通常来自下面这几种情况,而且它们经常一起出现。
第一种是同一个词在不同场景里含义不同。这类问题最隐蔽,因为讨论的时候每个人都觉得自己听懂了。
第二种是一个业务动作牵动多个模块,而每个模块有自己的规则。改群公告要过群权限,撤回消息要过时限和角色判断,把人移出企业则要同时处理登录、通讯录、群成员和文档权限。这些规则不属于同一个模型,改动却经常一起发生。
第三种是规则本身在变,而且变化点不集中。禁言策略今天按角色判断,明天要加白名单和时段;好友申请今天单向通过,后天要做双向确认。变化本身不可怕,可怕的是每一处变化都要在四五个地方同步修改。
第四种是不同团队对同一个对象有不同理解。研发以为“外部联系人”是一个账号,产品心里的外部联系人可能根本没有账号,只是录进来的一条客户资料。这类分歧在写代码之前就已经埋下了。
第五种是数据一致性、权限、审计、通知、搜索和开放接口同时参与同一个流程。一次成员离职,既要立刻收回访问权,又要保留审计记录,还要发事件通知下游,而这几件事对“什么时候必须生效”的要求并不一样。
拿协同办公 IM 来说,“用户”这个词就不稳定。
在登录场景里,用户是账号、凭证、设备、登录会话。在组织通讯录里,用户是某个企业下的成员。在外部联系人里,用户可能是客户、好友、供应商。在群聊里,用户又是群成员、群主、管理员、普通成员或被禁言成员。
如果这些场景共享一个 User,增加“离职”状态就会产生歧义:是这个账号不能登录了,还是它在一家企业里的成员身份失效了?一个账号加入两家企业时,两种解释会得到完全不同的处理结果。
把 Account 和 Member 分开,要付出关联查询、身份映射和数据同步的成本。但它也让“离职”有了明确的作用范围。这才是引入模型边界的理由。
三层架构没有替我们决定规则归属
经典三层架构通常分为 Controller、Service、DAO。对于简单业务,这种分层很有效:入口、业务逻辑、数据访问各司其职。
如果只约定技术分层,没有约定业务归属,代码容易逐渐变成这样:
- Controller 只负责转发请求。
- DAO 只负责读写数据库。
- 所有业务规则都堆到 Service。
- Service 之间互相调用,互相知道对方的数据结构。
- 数据库表结构逐渐变成事实上的业务模型。
这种结构最大的问题,不是代码不够优雅,而是业务知识没有稳定的位置。实体退化成数据库字段的搬运对象,规则散落在各种 service、handler 和 util 里,改一个判断得先把调用链读一遍。一个规则到底属于账号、组织、关系、群组还是消息,没人能说清楚,于是需求一来,就在最顺手的 Service 里补一个判断。这种状态可以叫模型失血。
例如群组模块直接更新组织成员资料,可以少走一次接口,却也必须跟着组织表结构和校验规则一起变。再加上 handler、定时任务、导入脚本都能写这张表,测试就很难覆盖所有修改入口。账号模块能改好友关系,消息模块能直接查组织表,都属于同一类问题——边界失焦。
另一种问题出现在需求沟通中。产品说“删除联系人”,开发理解成删除账号,测试理解成双方解除好友关系。三层代码即使各司其职,也阻止不了这类语义错误。三个角色说的是同一个词,心里装的是三个模型,这种错位可以叫语言断裂。
这些问题不要求废弃 Controller、Service、DAO。三层架构仍然可以保留,只是还需要回答:哪个模块拥有这条规则,其他模块可以通过什么方式使用它。
让业务词汇在一个范围内保持一致
Microsoft 的微服务领域分析文档强调,服务边界没有机械的标准答案,需要从业务域、需求、架构特征和目标中推导;它也把 DDD 分成战略设计和战术设计两部分。战略设计关注大系统结构,战术设计关注实体、聚合、领域服务等模型模式。Microsoft: Domain analysis for microservices
Martin Fowler 在解释 Bounded Context 时,也强调模型只有在某个上下文边界内才有明确含义,同一个术语在不同上下文里可以对应不同模型。Martin Fowler: Bounded Context
落实到代码里,可以先做一件很具体的事:把“禁用账号”和“移除组织成员”写成两个不同的操作,分别说明对象、权限和后果。需求文档、接口、事件与测试沿用这些名字,讨论时就不必反复猜测“禁用用户”指什么。
随后才需要决定规则放在哪里。组织模型负责成员在职状态,身份模型负责凭证和登录会话;它们之间存在关联,却不因此共享所有字段和修改入口。
用一次成员离职检查边界
协同办公 IM 能把这个问题展开得比较充分——毕竟它除了聊天,什么都有。聊天之外,它还会涉及:
- 账号、认证、设备、安全策略。
- 企业、部门、成员、角色、通讯录。
- 好友、联系人、外部联系人、黑名单。
- 单聊、群聊、群成员、群治理。
- 消息、已读未读、撤回、转发、引用、附件。
- 通知、离线推送、站内提醒。
- 工作台、审批、日程、任务、文档。
- 开放平台、机器人、Webhook、事件订阅。
- 搜索、审计、风控、合规。
这些能力在页面上可以合在一起,在规则上却未必适合由一个模块决定。“从企业组织中移除一个成员”就需要逐项确认:
- 登录权限是否立即失效。
- 通讯录是否还能搜索到这个人。
- 历史消息是否保留。
- 群成员身份是否移除。
- 文档权限是否回收。
- 机器人和开放平台事件是否通知。
- 审计日志是否记录。
笔者会先把离职后必须立即生效的限制,与允许稍后完成的清理分开。例如企业访问资格可能需要在授权入口立即撤销,搜索索引和开放平台回调则可以异步更新。组织上下文记录移除事实后,发布事件供群组、文档、搜索等上下文处理各自的后续动作。
这里不能简单地说“发一个事件就解耦了”。如果消息服务只看尚未更新的成员缓存,离职者仍可能继续发送消息。是否接受这个窗口、是否需要在线复核、校验后到写入前的并发变更如何处理,都必须作为产品规则和一致性约定写清楚。
由此再讨论下面的问题,才有具体对象:
- 哪些模型应该属于同一个限界上下文?
- 哪些操作必须在一个聚合内强一致?
- 哪些动作可以通过领域事件最终一致?
- 哪些能力是核心域,哪些是支撑域,哪些是通用域?
- 当前系统应该是模块化单体,还是拆成多个服务?
一个模型方案至少要能解释这次离职操作,否则分层图再完整,也还没有解决眼前的问题。
引入 DDD 也要计算成本
DDD 不是微服务。 微服务可以以 DDD 作为边界识别方法,但 DDD 不要求一开始就拆服务。很多系统更适合先做模块化单体,把领域边界在代码里立住,再按演进压力拆部署。
简单 CRUD 继续使用事务脚本也合理。只有当规则需要在多个入口复用、状态迁移有不变量,或者修改经常波及不相关场景时,才有必要投入更细的领域建模。把几个字段查询包装成聚合、仓储和领域服务,未必能得到维护收益。
DDD 不是数据库建模。 聚合边界不是外键关系,聚合根不是主表对象。聚合关注的是业务一致性边界,不是表结构层级。
边界切得过细,同样会让一次操作变成多次调用,让原来的本地事务需要重试、补偿和监控。评估时既要看变更能否局部化,也要看查询组合和一致性处理是否超出团队承受能力。把账号、组织、关系、群组、消息一次性拆成独立服务,换来的是每发一条消息都要跨网络确认一遍身份和权限;在这些边界真正成为瓶颈之前,模块化单体加上明确的写入口,通常更划算。
DDD 不是一次性设计。 业务演进几年后,今天合理的边界可能会变得别扭。领域模型需要持续复盘和重构。
接下来沿着这个案例展开
这个系列打算沿着一条从认知到落地的路线走:先讲 DDD 解决什么问题,再讲战略设计——领域、子域、核心域、限界上下文这些概念怎么用;接着讲战术设计,也就是实体、值对象、聚合根、领域服务、领域事件怎样把边界落到模型里。
方法讲完,打算基于钉钉、飞书/Lark 等公开产品和开放平台能力,推导一套协同办公 IM 的业务域地图;再往细处,讨论账号、组织、关系、群组这些最容易争议的边界该怎么划,以及消息、通知、搜索、开放平台这类跨域协作能力怎么接线。最后落到部署形态和 Go 项目的代码组织,看这些约定能不能在包和接口里立住。
读后面的边界方案时,可以一直带着“移除组织成员”这条流程检查:谁拥有事实,谁负责执行,哪个失败会影响请求结果。它比只对照术语更容易暴露设计里的空白。
这条路线本身也有边界:它谈的是怎么把业务规则分配到模型里,不涉及框架选型、部署工具和具体厂商的产品设计。至于这套边界放到真实的组织规模里会先在哪里出问题,笔者也还在摸索,等有了更确定的观察再补。