Skip to content

DDD 模型详细说明

DDD 的模型不是数据库模型的换名,也不是把对象分成几种类型后放进不同包里。它是一套从业务现场提炼出来的表达方式:哪些词在同一个语境里成立,哪些对象有身份,哪些描述可以当作值,哪些规则必须同时满足,哪些事实发生后要通知其他边界。

下面用“订单支付后锁定库存并安排发货”这条业务线贯穿说明。它足够常见,也足够复杂:订单、库存、支付、履约都参与其中,但每个部分关心的事实不同。

DDD 模型关系

统一语言

统一语言是 DDD 的入口。它不是写一份词汇表放在文档里,而是让需求讨论、代码命名、接口字段、事件名称使用同一套业务词汇。业务说“订单已支付”,代码里就应能看到 OrderPaidmarkPaidpaidAt 这样的表达,而不是只看到 status = 2

统一语言要跟着上下文走。同一个“客户”在会员上下文里强调账号、等级和联系方式;在订单上下文里强调购买人和收货信息;在风控上下文里强调风险标签和行为轨迹。强行把这些含义塞进一个全局 Customer,会让对象越来越胖,也会让每个团队都害怕改它。

可落地的做法是把核心词汇写进代码和测试。需求评审中发现新词时,先确认它是否已有概念;同词不同义时,要给它划上下文;一个字段名需要额外注释才能解释业务含义时,优先修改命名或模型,而不是继续补注释。

子域和限界上下文

子域描述业务版图,限界上下文描述模型边界。子域更像业务地图,告诉团队哪些部分是核心竞争力,哪些只是支撑能力;限界上下文更像模型围栏,告诉代码里某个词在哪个范围内有明确含义。

在电商系统中,交易是核心域,库存、履约、营销是支撑域,短信、文件、审计日志更接近通用域。进入模型设计时,可以形成订单上下文、库存上下文、支付上下文、履约上下文。它们不必一开始就是独立微服务,但应该先成为清晰模块。

边界划分要看业务规则是否独立变化。订单状态机经常和支付、取消、售后相关;库存规则关心占用、释放、盘点、仓库批次;支付规则关心渠道、流水、退款和对账。如果这些规则变化节奏不同,模型也不应绑在一起。边界不是为了隔离团队,而是为了让每个模型在自己的语境里保持干净。

实体和值对象

实体有身份。订单从创建、支付、发货到完成,金额、状态、收货地址会不断变化,但订单号标识的仍是同一个订单。支付单、库存记录、会员账户也属于实体,因为它们有生命周期,业务会追踪“这一个对象”发生了什么变化。

值对象没有独立身份,它表达一组不可拆开的描述。金额由数值和币种组成,地址由省市区、详细地址、收件人和手机号组成,时间范围由开始和结束时间组成。业务比较它们时看值是否相等,而不是看它们是不是同一个对象。值对象应设计为不可变,修改地址时创建一个新地址,而不是在原对象上随意改字段。

一个实用判断是:业务会不会追问“是哪一个”。如果会追问“是哪一个订单”“是哪一笔支付流水”,多半是实体;如果只关心“金额是否相同”“地址是否相同”“时间段是否重叠”,更适合值对象。值对象能把散落的校验收拢起来,例如金额不能为负、币种不能为空、时间范围结束时间不能早于开始时间。

聚合和聚合根

聚合是一致性边界,不是对象集合的美化名称。它回答的是:哪些对象必须一起修改,才能保证业务规则不被破坏。聚合根是这道边界的门,外部只能通过它改变内部状态。

订单聚合可以包含订单项和订单金额,因为“订单总金额等于订单项小计之和”“已支付订单不能随意增删订单项”这些规则需要在订单内部同时成立。外部代码不应该直接修改订单项数量,而应该调用订单聚合根的行为,例如 changeItemQuantity,由订单自己重新计算金额并检查状态。

库存记录不应直接放进订单聚合。订单支付成功后需要锁定库存,但库存可售数量、仓库批次、释放策略属于库存上下文的规则。把库存对象塞进订单聚合,会让订单修改背负库存事务,也会让库存规则被订单代码牵制。更清晰的方式是订单产生“订单已支付”事件,库存上下文根据事件锁定库存,并在失败时通过补偿流程处理。

聚合大小要围绕不变量判断。需要同一事务内严格成立的规则放进一个聚合;只需要流程协作或最终一致的规则,交给应用层编排、领域事件或流程管理器。聚合太大,保存一次订单会拖着库存、支付、履约一起更新;聚合太小,业务规则会散在外部服务里。设计时要不断追问:这条规则如果延迟几秒成立,业务是否还能接受;这条规则如果失败,能否补偿。

领域服务、应用服务和仓储

领域服务承载纯业务规则,应用服务编排用例流程,仓储负责保存和取回聚合。三者混在一起时,代码会变成事务脚本:接口进来后查库、判断、改字段、发消息,业务含义藏在一长串过程里。

应用服务适合处理“先取订单、再调用支付、再保存订单、再发布事件”这类流程。它知道一次用例要走哪些步骤,但不应把“订单能否支付”“金额是否满足规则”“状态能否流转”这些判断写死在自己身上。领域规则应回到订单、金额、库存策略或领域服务中。

领域服务适合处理跨多个领域对象但仍属于领域规则的行为。例如定价需要同时读取会员等级、商品促销、优惠券规则,但结果是“本次订单的应付金额”。如果把它放进订单实体,订单会知道太多外部规则;如果放进应用服务,业务计算会失去模型位置。此时可以建立定价领域服务,让它专注表达定价规则。

仓储看起来像数据访问对象,但目标不同。仓储以聚合为单位提供保存和查询能力,例如 OrderRepository.findById(orderId)save(order)。领域层定义仓储接口,基础设施层实现数据库、缓存和 ORM 细节。这样订单模型可以在不连接数据库的情况下进行单元测试。

领域事件

领域事件表达已经发生的业务事实。它的名字使用过去式,例如 OrderCreatedOrderPaidStockLockedShipmentCreated。事件不是命令,不能用 LockStock 表示“请去锁库存”;那是一个动作请求,不是事实。

事件的价值在于切开边界协作。订单完成支付后,库存、积分、消息通知、履约都会关心这个事实,但订单不应该知道所有后续动作。订单只发布“订单已支付”,其他上下文根据自己的职责订阅和处理。这样新增“支付后发放会员积分”时,不需要把积分逻辑塞回订单聚合。

领域事件也要有边界意识。并不是所有字段变化都需要事件,只有业务上值得被其他模型感知的事实才适合沉淀为事件。事件内容也不应暴露内部对象结构,应包含业务标识、发生时间、关键结果和必要的快照数据。跨上下文事件一旦发布,就会成为协作契约,命名和字段要比内部方法更慎重。

代码结构示例

DDD 不要求固定目录,但依赖方向要清楚:领域层不依赖数据库、消息队列、HTTP 框架和 ORM;应用层编排领域对象;基础设施层实现技术细节。一个订单模块可以按下面方式组织:

text
order/
├── interfaces/
│   └── OrderController.java
├── application/
│   ├── PayOrderCommand.java
│   └── OrderApplicationService.java
├── domain/
│   ├── Order.java
│   ├── OrderLine.java
│   ├── Money.java
│   ├── OrderRepository.java
│   ├── PricingService.java
│   └── event/
│       └── OrderPaid.java
└── infrastructure/
    ├── JpaOrderRepository.java
    └── OrderEventPublisher.java

一个更接近模型的支付用例,应把状态流转留给聚合:

java
public void pay(PayOrderCommand command) {
    Order order = orderRepository.findById(command.orderId());
    PaymentResult result = paymentGateway.pay(command.paymentRequest());

    order.markPaid(result.paidAt(), result.transactionNo());

    orderRepository.save(order);
    eventPublisher.publish(order.pullDomainEvents());
}

这段代码的应用服务只负责流程编排。订单能否支付、已取消订单如何处理、重复支付如何拒绝,都应由 Order 的行为保护。如果这些判断散落在应用服务、控制器和 SQL 更新语句里,模型就只是数据容器。

建模检查

检查一个 DDD 模型,可以从代码里找证据。核心业务词汇是否能直接出现;实体是否有业务行为,而不是只有属性;值对象是否收拢了校验和比较;聚合根是否控制内部对象修改;跨上下文协作是否通过清晰接口或领域事件完成;基础设施对象是否没有侵入领域层。

更重要的是看模型是否能解释变更。新增“部分发货”时,应该知道它主要影响履约上下文还是订单上下文;新增“支付后自动锁库存”时,应该知道订单事件和库存处理的边界;新增“企业客户月结”时,应该知道它改变支付模型还是结算模型。能把变更放到清晰位置,说明模型在承担系统演进的压力。

别急,先让缓存热一下。