领域驱动设计初识

Tommy Cheese | · 阅读约需 7 分钟

领域驱动设计概述

领域驱动设计(DDD,Domain-Driven Design)是一种围绕业务领域构建软件的设计方法。开发人员与领域专家共同探索业务知识,形成领域模型,并让模型中的概念、规则和语言体现在代码中,从而帮助团队理解和维护复杂业务。

DDD 包含战略设计与战术设计两个相互影响的层面:战略设计关注子域、限界上下文及其协作关系;战术设计关注上下文内部的实体、值对象、聚合、领域服务和领域事件等。它们需要在实践中持续迭代。

电商示例:业务子域与限界上下文相对应,订单上下文内部包含订单聚合,聚合根本身是实体。
图中是一种可能的设计:子域与限界上下文的对应关系需要建模决定;聚合位于具体上下文内部。

战略设计相关概念

领域

领域是软件所关注的业务知识与活动范围。例如,电商系统可能涉及商品销售、支付结算与订单履约。理解领域需要讨论业务目标、流程和规则,而不只是列出系统功能。

子域

可以按业务能力将领域划分为子域,再根据它们对企业竞争优势的贡献以及是否存在成熟通用解法,区分以下类型:

  • 核心子域:体现企业差异化和竞争优势的业务能力,值得投入更多建模与研发资源。
  • 支撑子域:业务运行所需、具有企业特定需求,但不直接提供核心竞争优势的能力。
  • 通用子域:行业已有成熟解决方案、通常可以购买或复用的能力。

这种分类取决于企业的业务。例如,身份认证对普通电商可能是通用子域,对身份服务提供商却可能是核心子域。“被多个模块使用”本身并不能证明某项能力属于通用子域。

通用语言 Ubiquitous Language

领域专家与开发人员应在同一限界上下文内建立并持续使用一套共同语言,让讨论、文档与代码中的术语含义一致。例如,“订单已支付”是否要求全额付款、何时允许取消,都需要形成共同理解。通用语言也常译为统一语言;它在上下文内保持一致,不要求整个企业使用一个模型。

限界上下文

限界上下文(Bounded Context)是一个特定领域模型及其语言保持一致、有效的适用边界。子域描述业务问题的划分,限界上下文描述模型的边界,两者不应被定义成固定的包含关系。设计时可以争取清晰的对应关系,但也需要考虑既有系统和组织约束。

例如,同一个“商品”在销售上下文中可能关注售价与优惠规则,在仓储上下文中关注库存位置与出入库规则。它们可以通过商品标识关联,却不必共享同一个包含所有属性和行为的商品类。

完整业务流程可以跨越多个上下文:下单、支付和发货可以由订单、支付和履约上下文协作完成。上下文映射(Context Map)用于明确这些关系与集成契约;必要时可以通过防腐层转换外部模型,保护自己的模型。

限界上下文也是划分服务的重要依据,但不等于部署单元。DDD 可以用于模块化单体,也可以用于微服务;一个上下文可以由多个模块或物理服务实现,不能机械地要求“一个上下文对应一个微服务”。

战术设计相关概念

值对象和实体

区分实体与值对象,要看业务是否需要持续追踪它的身份:

  • 实体(Entity):通过身份识别的领域对象。例如,订单金额或状态发生变化后,具有同一订单标识的对象仍代表同一笔订单;不同标识的两笔订单即使金额相同,也不是同一实体。
  • 值对象(Value Object):没有需要追踪的独立身份,按有业务意义的属性值比较。例如,在相同金额与币种下,两个金额对象可以互相替代;金额相同但币种不同则不相等。

值对象通常设计为不可变对象,变化时用新值替换旧值,避免共享引用带来的意外修改。实体也可以采用不可变的实现;相等性由身份或值语义决定,不由是否可变决定。同一个概念在不同上下文中也可能有不同建模方式。

在复杂业务中,实体、值对象和聚合根应封装适合自身的业务规则。例如,订单可以通过“取消订单”行为检查是否允许取消,而不是让调用方任意修改状态。应用层负责用例编排,数据库访问等技术细节由基础设施承担;不需要让实体直接调用数据库才能拥有丰富的领域行为。

只有数据和 getter/setter、业务规则全部外置的设计通常被称为贫血领域模型。它不应作为复杂领域建模的普遍推荐,但简单 CRUD 场景可以采用较简单的数据模型。POJO 指普通 Java 对象,本身并不限制对象是否包含业务逻辑。

资源库 Repository

资源库(也常译为仓储)提供类似领域对象集合的访问接口,隐藏查询、对象映射与存储细节。在 DDD 中,通常围绕需要持久化和直接访问的聚合根设计资源库,而不是为每张表或每个内部实体分别设计一个资源库。

接口应表达清楚的领域意图,例如按订单标识加载订单、保存订单聚合。方法名称是否简短不是设计标准;也应避免把数据库字段和任意单表更新直接暴露给领域层。

可以使用 MyBatis 实现资源库。Mapper 是否能直接承担资源库接口,取决于它是否满足聚合加载、保存与封装要求;普通单表 CRUD Mapper 不能仅因为提供存储操作就与 DDD 资源库等同。

聚合与聚合根

聚合(Aggregate)是维护业务不变量的事务一致性边界。不变量是每次业务更新提交时都必须成立的规则。聚合可以包含多个实体和值对象,也可以只有一个根实体。聚合根(Aggregate Root)本身是实体,负责作为访问入口并保护聚合内部的规则。

例如,假设某个订单模型要求订单总金额始终等于明细金额之和,可以把订单与订单明细放在同一聚合中,由订单根实体统一处理明细变更。这里决定边界的是一致性要求,不是“所有有关联的对象都应该放在一起”。

  • 需要修改聚合内部状态时,外部代码应通过聚合根的行为完成,避免绕过它修改内部实体、破坏业务规则。不可变值对象可以安全地作为返回值。
  • 一次业务更新提交时,聚合内必须满足需要立即保持的规则,例如明细数量必须为正、总金额计算正确。持久化时还需要适当的事务与并发控制。
  • 跨聚合关联通常优先使用标识;跨聚合协作可采用领域事件和最终一致性。是否需要更强的一致性,应由业务要求决定,不能仅凭对象有关联就把它们全部合并。

“取出、修改、保存”可以描述一次用例,但不意味着所有读取都必须包在覆盖全过程的数据库事务中。聚合边界表达一致性要求,具体事务和并发策略还需要在实现中确定。

领域事件 Domain Events

领域事件表达领域中已经发生、且有业务意义的事实,例如“订单已取消”。它应由业务语义与规则决定,而不是实体任意属性一变化就自动产生。名称常采用过去式,事件携带的事实通常保持不可变。

上下文内部的领域事件可以在进程内同步或异步分发。需要跨上下文传播时,通常将提交成功后的业务事实转换为集成事件,通过消息系统交给其他服务处理;消息队列是实现选择,不是领域事件定义的一部分。

例如,订单取消后,可以通知库存上下文释放预留库存。事件记录的“取消已发生”不能被改写成未发生,但业务可以有后续处理或补偿动作;不能将事件不可变理解为所有业务结果都无法调整。

领域服务 Domain Service

当某个重要的领域操作不能自然归属于一个实体或值对象时,可以将它建模为领域服务。例如,在一种需要综合目的地、运输方式和承运商报价的模型中,运费计算可以由领域服务承担。是否需要服务,取决于行为归属,而不是涉及的对象数量。

领域服务通常无状态,按通用语言命名服务及其操作。“将明细加入订单”如果自然属于订单聚合,就可以直接由订单行为完成,不必另建一个同步实体状态的服务。

应用服务负责接收用例请求、加载聚合、调用领域行为、协调保存与事务等流程;领域服务负责其中不能归属其他领域对象的业务规则。两者都不等同于一个独立部署的微服务。

DDD 领域建模

可以用下面的顺序开始建模,并随着新知识和实现反馈不断回到前面的步骤:

  1. 与领域专家讨论典型业务场景、规则和目标,形成通用语言,并识别初步子域。
  2. 确定模型在哪些边界内保持一致,划分限界上下文,描述上下文之间的协作与集成契约。
  3. 在每个上下文内识别实体、值对象和业务不变量,据此设计聚合边界、聚合根及其行为。
  4. 按需要设计领域服务、领域事件、资源库与对象创建方式,并明确应用层和基础设施的职责。
  5. 用具体场景检验模型,验证关键规则;当业务理解发生变化时,同时调整代码、语言和模型边界。

先建立初步战略划分、再深化战术模型是一种实用的起点,但不是不可回溯的两个阶段。DDD 的重点是通过持续协作和反馈,让软件模型准确表达业务;应根据业务复杂度选择模式,避免为了凑齐概念而增加结构。

参考资料

评论

使用 GitHub 账号参与讨论,所有语言版本共用此评论区。

输入首页、新闻、前沿研究等跳转页面,或搜索文章。

↑↓ 选择 ↵ 打开 Esc 关闭