← 返回文章列表

DDD 实战(五):账号、组织、关系与群组的边界划分

2026年7月11日架构设计
DDD限界上下文IM架构取舍

海外 AI 服务代充推荐:BeWild(推广链接)。

上一篇《协同办公 IM 的领域全景》画的是整张地图,这一篇钻进最容易吵架的那一格。

账号、组织、联系人、群成员都和“人”有关,于是很容易被放进一个用户中心。早期这样做未必有问题——一张 user 表加几个字段,登录、通讯录、好友、群成员全都能查,改起来也快。真正需要重新划边界的时刻,通常是一次看似简单的修改开始牵动不相干的规则。

比如成员离职:要取消他在企业里的身份,却不一定要注销账号;要回收企业群访问权,却未必解除私人好友关系。若数据库里只有一个 user.status,这些不同的后果就只能靠各处的条件判断补出来。

这一篇比较三种组织方式,也顺便说明边界是怎么一步步收紧的。它们都是设计选项,不是飞书或钉钉内部实现的描述。

先把几个身份分清

概念它记录的事实典型变化
Account登录主体、凭证及账号状态更换凭证、冻结登录、注销
Member账号在某个组织里的身份入职、调岗、离职
Contact / Friend某一方的联系人记录或双方关系备注、申请、确认、解除、拉黑
ExternalContact产品定义的外部联系人身份与归属客户分配、资料更新、跟进权限变化
GroupMember某个主体在某个群里的成员资格入群、退群、角色调整、禁言

一个人可以同时对应这些模型。它们通过 ID 关联,不表示必须共用一个对象。

这三组概念两两之间都不能划等号:Account 不等于 Member,Member 不等于 Contact,Contact 不等于 GroupMember。每一条“不等于”背后都有具体场景撑着——一个人可以是某家企业的成员却不在任何一个群里,可以是某人的好友却不属于同一家企业,也可以是群成员却根本没有企业成员身份(比如外部协作方)。

其中 ExternalContact 尤其要先问清产品定义。有的系统把它当作外部账号的联系人视图,有的系统允许录入没有账号的客户。后者不能强行依赖 Account 才能存在。

方案一:先留在用户中心

产品只有登录、基础通讯录和简单好友关系时,可以在同一模块下组织代码:

internal/user/
├── account.go
├── member.go
├── contact.go
├── friendship.go
└── group_member.go

它的好处很直接:一次查询容易组合,本地事务容易使用,也不需要维护跨服务协议。团队可以把时间花在尚未稳定的业务上。

但“留在同一模块”不等于“所有字段放进同一个 User”。即使暂时不拆包,也可以让账号、组织成员和好友关系有不同的修改入口。否则,导入通讯录的人可能顺手改了账号资料,处理好友请求的人又得了解企业成员状态。

是否继续保留用户中心,笔者会看两件事:这些规则是否经常一起变,修改入口是否仍然能解释清楚。如果已经出现独立的成员生命周期和复杂关系权限,再把它们塞进一个 Service,省下的只是目录数量。

方案二:按事实所有权划分上下文

对于前文讨论的企业协作 IM,可以先采用下面的边界。它们可以部署在同一个进程里。

上下文拥有的事实需要引用、但不负责修改的事实
Identity & Access账号、凭证、设备、登录会话企业成员身份
Tenant & Organization租户、部门、成员、组织角色登录凭证、私人好友关系
Contact & Relationship申请、好友、拉黑、备注、关系权限账号与组织成员主数据
Group & Conversation群资料、成员资格、群角色、群治理消息正文、组织架构

以“修改姓名”为例,先要区分账号昵称、企业通讯录姓名和群昵称。三者可以有默认继承关系,但覆盖规则和修改权限未必相同。直接同步成一个字段,反而会丢失这种差异。

拆开之后,组合查询会变多。一个联系人页面可能同时需要组织资料、个人备注和在线状态。可以由查询接口组合,也可以维护专门的读模型;不需要为了让页面一次取完数据,就把事实的写入口重新合并。

方案三:抽象成关系图

另一种思路是用统一关系模型表达“主体之间的关系”:

Subject --[relation_type]--> Subject

belongs_to_department   reports_to          friend_with
blocks                  member_of_group
admin_of_group          external_contact_of

同一个存储结构可以记录好友、关注、拉黑、组织隶属或群成员关系——上面这七种关系在这个模型里长得一模一样。对于多种关系的遍历查询,这种表达有价值。

问题是,边的形状相似,不代表建立和删除它们的规则相同。好友可能要双方确认,拉黑通常是单向操作,部门隶属受组织管理员控制,群主转让还要保护群内不变量。把它们全部交给一个通用 AddRelation,调用方仍得在外面补齐业务判断。

所以笔者更愿意把关系图当作存储或查询手段,而不是默认的业务边界。Organization 和 Group 可以继续拥有各自的规则,再把允许传播的关系投影到图里。只有产品本身就围绕统一关系规则运行时,才值得进一步抽象。

用生命周期检查 Account 和 Member

假设一个账号加入企业 A 和 B。它从 A 离职后:

  • Account 仍然存在,能否继续登录由账号规则决定。
  • A 下的 Member 失效,企业访问资格随之收回。
  • B 下的 Member 不应因此改变。
  • A 的历史消息、审计记录是否保留,要按留存与访问规则处理,不能直接级联删除。

这说明账号和组织成员有各自的生命周期。离职只是其中一种变化,另外四种同样值得并排看:

  • 成员被禁用,但账号仍能登录个人空间。
  • 转岗,只改组织上下文里的部门和角色。
  • 账号注销,组织里的成员记录要按留存规则单独处理。
  • 设备异常,只影响身份上下文里的登录会话和安全策略。

放在一起看,判据就清楚了:每种变化都只应该搅动一个上下文。如果任何一条都要同时改四五处,说明模型还没真正分开。

但拆成两个模型以后,还需要在请求中明确当前租户和当前成员身份。认证成功只能证明“是谁”,不能证明“在这家企业有权做什么”。

组织撤权也不能只依赖一条不受时限约束的异步事件。关键入口需要约定缓存有效期、复核方式和失败策略。校验与写入之间仍可能发生并发撤权,是否允许已经受理的请求继续完成,也应写进规则。

通讯录和好友列表可以一起展示,不能混成一份事实

组织通讯录来自企业管理关系。好友列表来自个人或双方建立的联系关系。前者可能受部门可见性约束,后者可能涉及备注、隐私和跨企业联系。

通讯录里的成员事实通常有三条来源:管理员手工维护、HR 系统同步、组织架构同步流程。来源一多,规则就得先写清楚——同步过来的部门被人手工改过之后,下一次同步要不要覆盖回去?这类问题既不属于账号,也不属于群组,只能由组织上下文回答。部门可见性和字段脱敏规则同理,它们决定的是“谁能看到这条成员事实”,而不是“谁拥有它”。

“把同事加入联系人”因此可以只是建立一个个人视图,不必复制组织成员。展示时组合组织资料与个人备注;成员离职后,再按规则决定隐藏资料、保留联系入口还是解除关系。

这里最容易遗漏的是关系两端的身份。跨企业好友可能关联 Account,企业客户关系可能关联 Member 或 ExternalContact。两个没有解释的 user_id,会把这个决定推迟到每次业务判断里。

群组和消息也要分别问规则

群组决定谁能加入、谁能管理、是否允许发言。消息记录发送内容、撤回状态和投递相关事实。群里消息再多,也不该因此让每次修改群公告都加载消息集合。

会话是另一层概念:它为通信提供标识和入口。置顶、草稿、免打扰通常属于每个参与者的会话设置,不是所有群成员共享的群状态。这里同样有两条“不等于”:Group 不等于 Conversation,一个群可以对应不同的会话形态,单聊也有会话;Conversation 也不等于 Message,前者是入口和标识,后者才是事实。

早期可以把 Group 与 Conversation 放在同一个模块。等会话列表、多端同步和个人设置明显独立变化,再考虑拆分。模块名称不是重点,重要的是群成员资格、个人设置和消息内容分别由谁修改。

什么时候值得拆出去

笔者会把下面这些现象当作拆分信号,而不是等代码行数达到某个阈值:

  • 好友申请、外部联系人和关系权限已有独立的状态变化。
  • 组织规则的修改频繁影响登录或聊天发布。
  • 多个入口在直接改同一份数据,规则难以集中验证。
  • 某块能力确实需要独立扩容、发布或故障隔离。
  • 某些字段只服务少数几个场景,却被所有入口读写。
  • 团队职责和代码边界对不上,改一处要找两个组。
  • 一个模块出故障,影响范围远超它的业务职责。

后三条不像前四条那么显眼,但往往来得更早。

回看这一篇讨论的两个边界,都是这样一步步收紧的。好友关系最初只是账号上的一个双向字段,直到好友申请、外部联系人、客户关系、黑名单和跨企业协作陆续出现,才独立成关系上下文。群组最初只是消息的投递目标,直到群治理、群机器人、超大群、群权限、群文件和群公告一件件加上来,才从消息里拆出去。

这两次调整都发生在部署变化之前:先有包边界和写入口,再谈要不要拆服务。模型边界要比部署边界先出现。反过来做,通常只是把一团乱麻切成几团,中间还得靠网络调用重新连回去。

如果目前只有简单登录和通讯录,先保留一个部署单元即可。若已经有前面这些压力,可以先建立 Identity & Access、Tenant & Organization、Contact & Relationship、Group & Conversation、Messaging 的包边界与写入口,再决定哪些值得承担跨服务成本。

接下来打算沿着一次消息发送,看通知、搜索和开放平台怎样参与,又不把所有后续动作塞进发送接口。

← 返回文章列表