DDD 实战(二):战略设计,从业务语言到限界上下文
海外 AI 服务代充推荐:BeWild(推广链接)。
上一篇《业务变复杂之后,规则该归谁管》留下的是“规则归谁管”,这一篇要回答的是“边界怎么划”。
笔者在这个阶段犯过一个很典型的错:把产品菜单直接抄成服务清单。通讯录一个、聊天一个、工作台一个,画完看上去清爽又对称。直到有人问“群成员离职以后还能否查看历史消息”,几个方框之间就开始互相推责任——谁都能说这不是自己的事。
战略设计要做的,是在选部署形态之前理清业务能力、模型含义和数据所有权。本文沿用前一篇的 IM 案例,讨论怎样形成一版可供团队验证的边界方案。
先描述业务问题,再给模块命名
领域可以先粗略理解为“系统要解决的业务问题空间”。
电商有交易领域、支付领域、履约领域、库存领域、营销领域。支付系统有收银台、支付单、渠道路由、清结算、账务、风控。协同办公 IM 有账号、组织、关系、群组、消息、通知、工作台、审批、开放平台、搜索、安全治理。
在这个案例里,MySQL、Redis、MQ 和 HTTP 网关是实现手段,不能仅凭它们决定业务归属。同样,现有“用户表”也只能作为调查线索:它可能同时承载了账号资料、企业成员身份和个人偏好,需要再按规则区分。
领域划分应该先从业务能力出发,再落到系统边界。Microsoft 的领域分析文档也强调,微服务边界应围绕业务能力,而不是围绕数据访问、消息等水平技术层。Microsoft: Domain analysis for microservices
子域分类决定把精力花在哪里
一个大领域可以继续拆成多个子域。DDD 通常把子域分成三类:核心域、支撑域、通用域。
核心域承载产品差异化,通常值得投入更多建模和验证精力。支撑域为主业务服务,需要满足产品自己的规则,但未必形成竞争优势。通用域已有较成熟的解法,可以评估采购或复用;供应商约束、数据安全和接入成本仍然要算进去。
分错类的代价是不对称的。把通用域当核心域做,浪费的是团队最贵的注意力;把核心域当通用域做,丢掉的往往是产品差异本身。以这个 IM 为例,如果产品主打的是安全通信,密钥管理和会话安全就不能按“接个现成库”处理;而工作台里那些标准化的日程、审批组件,投入再多建模也不会变成卖点。
以协同办公 IM 为例,如果我们把它定位成“企业沟通协作平台”,那核心域可能包括:
- 会话与消息。
- 组织身份与通讯录。
- 群组协作。
- 开放平台生态。
- 安全治理和权限体系。
支撑域可能包括:
- 通知。
- 搜索。
- 日程。
- 审批。
- 工作台应用入口。
通用域可能包括:
- 短信验证码。
- 基础文件预览。
- 通用对象存储。
- 第三方登录协议适配。
这份分类是假设,不是协同办公产品的固定答案。定位置的时候,比“它重不重要”更好用的问法有两个:换掉它会不会让产品失去差异?它挂掉会不会让主流程停摆?通知和搜索都不构成差异,但通知挂掉用户立刻能感觉到,搜索挂掉只是暂时查不到历史消息,所以它们虽然同属支撑域,可用性要求并不一样。反过来,审批对沟通协作产品来说算不上差异,可对一家靠流程运转的客户,它可能比聊天更要紧,这时候把审批从支撑域挪进核心域就不奇怪。强调保密通信的产品,可能把密钥和安全策略作为主要差异化;强调业务流程的产品,则可能更看重审批编排。即使登录是通用能力,它仍然可能有很高的可用性要求。“是否构成差异化”和“故障是否严重”需要分别判断。
子域与限界上下文也不必一一对应:子域描述业务问题,限界上下文划定模型有效范围。已有系统里,一个上下文可能混合了多个子域;拆分方案应先承认现状,再决定怎样调整。
把“用户”换成能决定行为的词
DDD 非常重视统一语言。所谓统一语言,不是写一份术语表放在文档里,而是让业务、产品、开发、测试在同一个上下文里使用同一套词,并把这套词落实到代码、接口、事件、表名和文档中。
协同办公 IM 里,“用户”这个词就应该被拆开看。
在账号上下文里,我们可能说:
- Account:一个可登录账号。
- Credential:账号凭证。
- Device:登录设备。
- LoginSession:登录会话。
在组织上下文里,我们说:
- Tenant:企业或租户。
- Department:部门。
- Member:组织成员。
- OrgRole:组织角色。
在关系上下文里,我们说:
- ContactRelation:联系人关系。
- FriendRequest:好友申请。
- BlockRelation:拉黑关系。
- ExternalContact:外部联系人。
在群组上下文里,我们说:
- Group:群。
- GroupMember:群成员。
- GroupRole:群角色。
- GroupPolicy:群治理规则。
这些词都可能指向同一个人,但决定的行为不同。修改 Credential 不该顺便修改部门,退出一个群也不应解除好友关系。术语表是否有用,可以拿真实用例检查:调用方能否仅凭操作名称知道它会改变什么。
一个模型在哪些规则下成立
限界上下文是战略设计里最关键的概念之一。
Martin Fowler 对 Bounded Context 的解释是:一个模型只在特定上下文内保持一致和明确,不同上下文之间需要显式映射。Martin Fowler: Bounded Context
换句话说,限界上下文回答的是:这套模型在哪个范围内成立?
比如“成员”在组织上下文里,关注企业、部门、岗位、入职状态、组织角色和字段可见性。在群组上下文里,它关注群昵称、群角色、入群方式和禁言状态。最后阅读位置则可以归消息或会话模型,不能仅因它关联成员 ID 就放进群成员实体。
如果两个上下文都叫 Member,也可以接受,只要它们处在不同包、不同服务或不同模块边界内,并且交互时使用明确的防腐层或 DTO。问题不在于重名,而在于没有边界。
组织变更怎样传到消息上下文
限界上下文不是孤岛。复杂系统的关键恰恰在于上下文之间如何协作。
DDD 里常见的上下文关系有五种,套到这个 IM 上都不难找到例子。
客户方/供应方:上下游团队协商需求和交付安排,下游需求会影响上游计划;并非所有接口依赖都属于这种关系。消息上下文需要知道“这个成员还在不在企业里”,组织上下文就得考虑怎么提供、什么时候能改。开放主机服务:上游用稳定 API 暴露能力,组织上下文对外提供成员查询就属于这一类。发布语言:上下文之间约定好接口、事件或协议再沟通,OrganizationMemberChanged 这个名字连同它的字段,就是发布语言的一部分。防腐层:下游用转换层隔离上游模型,消息上下文不该直接持有组织那边的 Member 结构。各行其是:两个上下文没有必要集成,搜索和审批之间通常就是这种关系。
分清这五种关系,是为了回答一个具体问题:改动会往哪个方向传。上游一改,下游要不要跟着改,取决于当初定的是哪一种关系。
协同办公 IM 中,组织上下文和消息上下文就不应该共享组织表。更合理的方式是:
- 组织上下文负责组织成员事实。
- 组织上下文发布
OrganizationMemberChanged事件。 - 消息上下文维护自己需要的成员快照或权限缓存。
- 消息发送时只依赖消息上下文内的发送规则。
- 权限强校验需要调用组织上下文时,通过明确接口完成。
本地快照减少了发送路径上的远程依赖,也引入了更新延迟。成员改昵称可以容忍短暂陈旧;离职撤权是否同样可延迟,需要单独约定。若要求发送前在线校验,组织服务的延迟和不可用又会影响发送结果,不能同时宣称获得了完全隔离。
上下文映射最好把这些条件一起记录:数据由谁维护,事件多久应被消费,缓存多久失效,关键权限校验失败时拒绝还是放行。只画一根调用箭头,还不足以让接入方据此实现。
把分类落实到设计投入
如果会话与消息是核心域,就值得认真设计:
- 消息 ID 与幂等。
- 消息状态机。
- 已读未读模型。
- 撤回、编辑、引用、转发规则。
- 大群扇出策略。
- 多端同步游标。
- 加密消息和普通消息的差异。
如果短信验证码是通用域,可以先接成熟供应商,把接口留在应用需要的位置。发送限额、验证码有效期和防滥用仍属于产品要负责的规则,不能随供应商接入一起略过。
上面那份消息清单里,消息 ID 与幂等决定消息会不会重复,消息状态机决定撤回和编辑之后各端看到什么,大群扇出和多端同步游标决定系统能撑多大。这几项都在核心域里,很难指望接一个现成组件解决;而工作台里的日程展示、文件预览这类能力,用成熟方案就够了。
如果审批是支撑域,但未来可能成为核心域,那早期可以先保持模块化边界,不必马上设计成独立流程引擎。等审批规则、表单编排、自动化流转真正成为产品竞争力,再提升建模投入。
用变化检验边界,而非按名词拆分
领域划分没有唯一标准答案。尤其在账号、组织、关系、群组这些边界上,不同团队很容易给出不同方案,而且各有道理。
比如好友关系应该放在账号域吗?
如果产品很简单,好友只是账号之间的一个双向关系,放在账号模块里可以接受。这样实现快、调用少、事务简单。
但如果好友关系逐渐演化出申请、备注、分组、黑名单、外部联系人、客户关系、关系权限、隐私设置,它就不再只是账号的附属字段,而是一个独立关系上下文。
再比如群聊应该属于消息域吗?
如果群只是消息投递目标,把群放在消息模块里似乎合理。但如果群有群主、管理员、入群审批、群公告、群禁言、群机器人、群文件、群权限,群组模型就应该独立出来,消息域只消费“可发送到某个会话”的结果。
所以判断一个边界是否合理,要看:
- 业务语言是否清晰。
- 变化是否能局部化。
- 一致性边界是否自然。
- 团队职责是否匹配。
- 部署和调用成本是否可接受。
- 未来演进时是否有可迁移路径。
这几个判断可能相互冲突。前三条看的是模型,后三条看的是组织和运行成本,它们经常打架:语言上分得越干净,一致性和调用成本往往越高。遇到冲突时,值得先满足前三条——模型错了,后面怎么补都别扭;部署方式和团队归属可以慢慢调整,模型改起来要动的地方多得多。
好友模型有独立生命周期,却还没有独立团队或部署需求时,可以先拆包和写入口,保留同一个部署单元。模型独立与运维独立不必同时完成。
留下一份能约束实现的设计记录
第一版设计不必准备一套庞大的文档。用一张领域表记录上下文、核心模型和数据所有者,再补上关键流程的协作关系,通常就能开始评审。子域分类和拆分优先级可以直接写在同一份记录里。
更值得保留的是决定背后的条件。例如“关系先留在单体内,因为目前只有申请和拉黑两个用例;出现独立发布需求后再评估拆服务”,就比“未来采用微服务架构”更便于复盘。团队后来推翻这个决定时,也能知道究竟是哪项条件变了。
回到开头那个问题:“群成员离职以后还能否查看历史消息”。战略设计给出的不是一句“能”或“不能”,而是三个落点——组织上下文发出成员变更的事实,消息上下文决定历史消息对谁可见,这条规则写在哪个模型里。答案会随产品和法规变化,但责任的落点不该跟着变。
这个系列打算接着讲战术设计,看实体、值对象、聚合根、领域服务和领域事件如何把战略边界落到代码模型中。