领域建模的原则(战术篇)
如果团队和系统的规模不大,可以根据一两个人的经验设计出足够合适的模型。但是,当团队规模非常大、系统极其复杂的时,我们就需要制定一些原则来评审、检查各个各个团队产出的模型是否合适。
这些原则也许不能指导所有的场景,但是能在一定范围内做出约束。年轻的工程师总是喜欢自由,经过历练的工程师开始理解到约束的好处,想法也变得成熟。
这里我整理了一些 DDD 战术建模中的一些原则,作为软件领域建模中的基本要求。
1. 当一个【实体】被多个聚合根使用时,需要将其设计为【聚合根】或者将其拆开,不能再作为实体使用。
如果我们将聚合理解为系统中业务一致性、生命周期相对独立的一组实体,可以作为系统设计的基本单位,那么,一旦出现被多个聚合共享的实体,聚合就不再有意义了。
当两个聚合中出现了相同、相似的实体,有时候我们可能想要减少实体的数量,于是有了将其合并在一起的想法。比如,在分销系统中,销售和退货由两个不同模型实现,但是它们有类似的操作记录。如果将操作记录作为实体,但是处于不同的两个聚合,就会让这两个聚合耦合,让开发人员在开发时摸不着头脑。
类似的,在不同的业务场景中都会使用到附件,如果将附件作为实体存在,会造成混乱,与其这样不如直接设定一个原则,不允许出现共享实体的聚合。
2. 不允许使用【中间表】处理多对多关系,探明多对多原因,明确中间模型的归属。
多对多关系是领域建模的杀手,但在有些地方缺会是消除系统耦合的钥匙。
一个多空间系统,用户可以出现在不同的空间下,空间也可以容纳多个用户。看似是一个典型的多对多关系,我们大多数情况下会使用简单的中间表处理。
使用中间表往往意味着没有创建时间、状态等额外字段了。但是我们仔细一分析会发现,这个中间表的创建时间就是用户加入空间的时间,也就是说它是具有业务含义的,只不过被我们疏忽了。
当出现禁用空间下的用户业务时,只是删除中间表无法表达合适的业务需要,于是我们可以在中间表加上状态以满足业务需求。随着业务的丰满,中间模型就会显露出来,慢慢体现其重要意义。
多对多关系的存在,让我们无法建立合适的聚合。也就是说,无法将中间模型的归属问题明确下来。查询空间时,可以获得用户列表,同样的查询用户时,也可以获得空间列表。
那么,是用户拥有空间,还是空间拥有用户呢?
这就变得混沌,我们明确中间的模型为“成员”,明确空间拥有“成员”。当需要根据用户查询所属空间时,本质上是根据用户在空间下的成员信息来筛选空间。
当然,中间模型可能会归属到任何一边,这就需要架构师来拿捏和设计了,但是重要的是,中间模型的归属问题需要尽早的明确下来。
3. 区分【关联】和【拥有】,避免将本应该关联的模型设计到聚合之下,否则聚合非常大。
本条原则可以避免聚合设计过大,也可以避免不合适的生命周期。
以银行信用卡开户流程来说,代入到具体场景,银行账户是一个核心的模型,可以构成一个聚合。相关的,在开户时,会提交一个开户申请,银行的工作人员会对信息做出审核,完成审核后进行开户。
一个不佳的设计是,账户不能将开户申请纳入聚合中,因为申请的生命周期和账户并没有关系。开户申请和账户之间可以存在关联,但是不应该具有拥有关系。
4. 领域模型和数据库保持一致。
本条原则约束了领域模型落地实现的处理方式。
在理想的情况下,领域模型、数据库、API 都能体现系统状态(RESTful 叫做表征状态转移)的变化,如果能一一对应能让系统的复杂性降低,换个时髦的说法是让“熵”足够低。
有时候,我们会偷懒,想要将不同的模型持久化到同一张数据库表中,节省数据库设计。但是,这种差异造成了团队认知负载。如果没有必要,不建议这样操作。
5. 聚合的层级保持在 2 级,最多不超过 3 级。
这条原则非常好理解,层级过会带来落地上的巨大成本。
聚合的大小是领域模型设计中非常难取舍的地方。过大的聚合持久化,更新操作都不好处理;过小的聚合业务一致性得不到保证。
根据经验,2-3 层的聚合已经能满足大部分场景,如果超过 3 级,考虑将部分模型进行分解。
6. 事实数据快照化。
这条原则往往容易被初级的工程师忽略,但是非常重要。
根据范式理论,如果想做到很高的一致性,就不应该冗余过多的数据,这是大学数据库课程的基本内容。但是现实情况不能一概而论,对于重要的交易业务来说,完成业务后不会再更新,不存在一致性要求,反而是应该锁定交易时发生的关键数据。
这是因为一些事实数据本质上是业务合同。举个例子来说,合同的甲方乙方会记录下身份证号码、以及名字,即使当事人去派出所变更了姓名,也不会影响到合同中的主体。
7. 核心交易,设计交易流水或日志,用于审计。
接上一条原则,交易发生后,可能会对一些账户、库存、积分等信息进行变更,需要意识到为这些重要的信息记录流水、操作记录或者日志。
这是因为大部分信息系统都有商业契约性质,为了保护用户利益,需要在系统中留下足够的痕迹,避免未来“扯皮”,在纠纷发生时能提供证据。
8. 抽象类核心模型,提供拓展策略。
如果我们将一类相似的模型抽象统一后,注意设计良好的拓展策略,避免抽象后的模型无法支持拓展。
每位工程师都应该听过编程中追求复用的原则,但是并非所有的工程师认识到策复用和抽象带来的制约。当抽象发生时,意味着放弃了一些个性化的数据和行为,被抽象的模型在以后的命运中被绑定到一起。
如果我们想清楚了需要将一组抽象到一起,应该通过“不变点”找到共性,然后通过各种设计模式(例如,适配器模式,策略模式)为“变化点”提供拓展。
举个例子来说,餐饮领域中外卖、堂吃是两种不同的订单,外卖具有送货信息,堂吃具有座位信息。如果我们将两种订单抽象为一起,设计了订单模型,这是合理的,因为订单是“不变点”,和金额、结账、支付有关。
对于送货信息、座位信息可以使用适配器模式隔离出来作为独立的聚合并关联订单,避免订单上挂载送货信息、座位信息这类和场景相关的信息。
9. 当业务变化时,分而治之;当业务稳定后,抽象统一。
接上一条原则,如果在是否将相似模型抽象到一起而犹豫时,说明没有足够的信息输入,以至于缺乏信心。
抽象的模型是通过归纳产生的,如果没有信息做出归纳,可以优先分而治之,待业务明确后再重构为统一的方式。
10. 让合适的人做合适的决策,并做好决策记录,为后续决策提供背景信息。
最后一条是写给架构师的原则。
如果一个团队存在专门的架构师,而且团队又非常庞大时,架构师无法获得完整、足够细致的信息,需要承认无法在任何场景下做出合理的决策。
架构师应该只关注系统核心的模型,以及划定上下文边界附近的模型归属,确保系统作为一个有机的整体。而对于系统某个角落的模型设计应该交给具体的开发人员来决定,记得做好决策记录就行。
因为架构师认识到什么重要,什么不重要比事无巨细的决策更有意义。