从三层架构到 DDD:用领域驱动设计处理复杂业务

June . 01 . 2024

从三层架构到 DDD:用领域驱动设计处理复杂业务

DDD(Domain-Driven Design,领域驱动设计)是一种把软件模型对齐业务的方法论。它适合业务规则复杂、概念容易混淆、团队协作成本高的系统;如果只是简单 CRUD,DDD 往往会带来不必要的复杂度。

1. 从三层架构说起

传统三层架构通常是:

Controller / UI
Service
DAO / Repository
Database

它的优点是简单直接,但随着业务增长,常见问题会出现:

  • Service 越来越胖,业务规则散落在各种方法里。
  • 数据表结构反过来主导代码模型。
  • “订单”“客户”“库存”等词在不同团队里含义不同。
  • 测试业务逻辑必须依赖数据库、缓存、消息队列等基础设施。

例如:

OrderService.createOrder()
  - 校验用户
  - 校验优惠券
  - 计算价格
  - 锁库存
  - 调支付
  - 写订单表
  - 发消息

这类代码看似有分层,实际上领域规则没有稳定的归属。所有事情都堆进 Service,最后会形成所谓的“贫血模型”:实体只有字段和 getter/setter,真正的业务规则全部散落在服务层。

2. 四层架构:独立领域层

DDD 常见的四层架构是:

接口层 Interface / UI
应用层 Application
领域层 Domain
基础设施层 Infrastructure

职责大致如下:

  • 接口层:接收 HTTP、RPC、消息等输入,处理协议转换和参数校验。
  • 应用层:编排用例,处理事务、权限、调用顺序,不写核心业务规则。
  • 领域层:表达业务概念、规则、不变量,是系统核心。
  • 基础设施层:数据库、缓存、MQ、第三方 API、ORM 实现等。

以“创建配送单”为例:

DeliveryController
  -> CreateDeliveryApplicationService
    -> Delivery.create(...)
    -> DeliveryRepository.save(...)
    -> DomainEventPublisher.publish(DeliveryCreated)

关键变化是:业务规则进入 DeliveryRoutePlanPriceTimeWindow 等领域对象,而不是堆在应用服务里。应用层负责“这一件事怎么完成”,领域层负责“这件事在业务上是否合法”。

3. 领域模型:聚焦于业务

领域模型关注“业务如何成立”,不是“数据库如何存”。

例如配送领域里:

  • Delivery:配送单,有生命周期。
  • Rider:骑手,有接单能力、当前位置、状态。
  • DeliveryFee:配送费,是值对象。
  • TimeWindow:预约时间窗,是值对象。
  • DeliveryCanceled:配送被取消,是领域事件。

一个好的领域模型应该能说业务语言:

delivery.assignTo(rider)
delivery.cancel(reason)
delivery.markPickedUp()
delivery.markDelivered()

而不是到处写:

delivery.status = 3
delivery.rider_id = xxx

最佳实践是:优先使用值对象表达概念;只有确实需要长期身份和生命周期时,才建模为实体。例如金额、地址、时间窗、坐标、手机号通常更适合建模为值对象,因为它们的重点是值本身,而不是身份。

4. 聚合与聚合根:划出一致性边界

聚合(Aggregate)是一组需要保持强一致的领域对象。聚合根(Aggregate Root)是外部访问这个聚合的唯一入口。

例如 Delivery 可以作为聚合根:

Delivery
  - DeliveryId
  - Address
  - TimeWindow
  - DeliveryStatus
  - RiderId

它负责保证不变量:

  • 已完成的配送单不能取消。
  • 未分配骑手的配送单不能标记为已取货。
  • 配送时间窗不能早于当前时间。
  • 同一个配送单不能重复完成。

聚合设计的实践原则:

  • 聚合要小,只放必须在同一事务内一致的数据。
  • 聚合之间通过 ID 引用,不直接持有对象引用。
  • 跨聚合协作使用领域事件和最终一致性。
  • 微服务边界通常不应小于一个聚合,也不应大于一个限界上下文。

例如“订单”和“库存”不一定要放在同一个聚合里。创建订单后发布 OrderCreated,库存上下文消费事件并执行锁库存。如果锁库存失败,再通过补偿流程取消订单或提示用户。这比试图在一个大事务里同时修改订单、库存、支付和配送更容易演进。

5. DDD 的数量关系:先按业务关系建模,不要先按外键建模

在关系型数据库里,我们习惯用数量关系描述表之间的连接:

一对多:一个订单有多个订单行
多对一:多个配送单可以分配给同一个骑手
多对多:多个学生对应多个老师
一对一:一个用户对应一份用户详情

但在 DDD 里,不能直接把数据库关系搬进领域模型。数据库关心外键和表连接,领域模型关心业务规则、行为入口和一致性边界。

一个典型误区是:数据库里有外键,代码里就一定要做双向对象引用。例如:

Order -> OrderLine[]
OrderLine -> Order

这种双向关系看起来完整,但会带来两个问题:

  • 对象图越来越大,加载一个对象可能牵出一片对象。
  • 两端状态可能不一致,例如 OrderLine 指向订单 A,却又被加入订单 B 的明细集合。

更符合 DDD 的做法是:尽量让关系单向化,只保留业务真正需要的一端。订单聚合内部通常只需要:

Order
  - OrderLine[]

OrderLine 不一定需要反向持有 Order。如果它只是订单聚合内部的明细实体,它可以由 Order 统一创建、修改和删除。

一对多关系通常要结合聚合边界判断。如果“多”的一侧没有独立生命周期,可以放在同一个聚合里:

Order
  - OrderLine
  - OrderLine

这时 Order 是聚合根,负责保证订单总价、明细数量、优惠分摊等不变量。

但如果“多”的一侧有独立生命周期,就不应该强行塞进同一个聚合。例如骑手和配送单:

Rider
Delivery

一个骑手一天可能有很多配送单,但配送单有自己的状态流转、取消规则、结算规则。此时 Delivery 更适合作为独立聚合,通过 RiderId 引用骑手,而不是在 Rider 聚合里维护一个巨大的 Delivery[] 集合。

一对一关系在 DDD 中也要谨慎。很多数据库中的一对一表拆分,在领域模型里其实更适合值对象。例如:

Recipient
  - Name
  - Phone
  - Address

如果 Address 没有独立身份,只是收件人的地址信息,它就不需要建成一个独立实体,也不一定需要单独一张表。从领域角度看,它更像值对象:

Recipient
  - Address

Address
  - Province
  - City
  - Detail
  - Coordinate

值对象的好处是语义清晰、不可变、不会引入额外身份和生命周期。

多对多关系最容易被误建模。数据库里可以用中间表表达:

student_instructor(student_id, instructor_id)

但领域模型要先问一个问题:这个关系本身有没有业务含义?

如果中间表只是两个 ID,没有额外信息,很多时候不需要把它建成领域对象。可以选择一个业务上更自然的入口来维护关系,并尽量保持单向。

如果关系本身有业务属性,就应该把中间关系提升为领域对象。例如配送场景中,配送单和骑手之间不是简单多对多,因为存在指派时间、改派原因、接单状态、是否主骑手等信息:

DeliveryAssignment
  - DeliveryId
  - RiderId
  - AssignedAt
  - Status
  - Reason

这时所谓“多对多”其实已经变成两个一对多:

Delivery -> DeliveryAssignment
Rider -> DeliveryAssignment

是否暴露 DeliveryAssignment,取决于业务是否需要直接操作它。如果运营需要查询改派历史、计算骑手接单时长、追溯取消责任,那么它就是一个有意义的领域概念,而不只是数据库中间表。

建模数量关系时,可以遵循几个实践原则:

  • 不要从外键出发建模,要从业务行为和不变量出发。
  • 尽量减少双向关系,能单向就单向。
  • 一对一优先考虑值对象,除非确实有独立身份和生命周期。
  • 多对多如果只有两个 ID,可以隐藏在持久化层;如果有业务属性,就建成显式领域对象。
  • 跨聚合关系优先使用 ID 引用,不要用对象引用把聚合粘在一起。
  • 关系的维护入口要明确,避免两个聚合根都能随意修改同一段关系。

简单说,数据库关系回答“数据怎么连”,领域关系回答“业务怎么成立”。DDD 的关系建模不是追求对象图完整,而是让模型刚好表达业务规则,同时避免不必要的耦合。

6. 事件风暴:先发现业务,再设计模型

在进入代码之前,DDD 更强调先理解业务。事件风暴(EventStorming)是一种协作建模方法:把业务专家、产品、开发、测试放在一起,用事件串起业务流程。

以同城履约为例,先写领域事件:

订单已创建
支付已完成
库存已锁定
配送单已创建
骑手已分配
商品已取货
配送已完成
结算已生成

然后补充:

  • 触发事件的命令:创建订单、支付订单、分配骑手。
  • 参与者:用户、仓库系统、骑手、财务系统。
  • 外部系统:支付网关、地图服务、短信服务。
  • 规则热点:超时取消、库存不足、骑手改派、退款。

事件风暴的价值不只是画流程,而是暴露业务分歧。例如“订单已完成”在交易团队可能表示用户收货,在财务团队可能表示可结算,在售后团队可能表示过了退款保护期。这个分歧如果不提前暴露,最终会变成代码里的条件分支和数据修复脚本。

7. 子领域:先决定哪里值得复杂设计

DDD 会把大领域拆成子领域:

  • 核心子领域:形成竞争优势,值得重点建模。
  • 支撑子领域:业务需要,但不是核心差异。
  • 通用子领域:行业通用能力,能买就买,能复用就复用。

同城履约平台可以拆成:

核心子领域:履约调度、运力匹配、时效预测
支撑子领域:订单、库存、结算、售后
通用子领域:账号、权限、通知、支付通道接入

这一步很重要,因为不是所有模块都值得 DDD。账号权限这类通用能力,用成熟方案可能比自研复杂模型更合适。核心子领域才是团队应该投入最多建模精力的地方。

8. 限界上下文:同一个词,在不同边界里可以有不同模型

限界上下文(Bounded Context)是“一个模型适用的边界”。

例如“订单”在不同上下文中含义不同:

交易上下文:订单 = 商品、价格、优惠、支付状态
履约上下文:订单 = 待履约任务、收货地址、时效要求
财务上下文:订单 = 应收、应付、结算凭证
售后上下文:订单 = 可退金额、责任方、售后状态

不要强行设计一个全局 Order 模型。它会越来越大,最后谁都不满意。

更好的方式是:

Trading Context
Fulfillment Context
Finance Context
AfterSales Context

每个上下文有自己的模型、语言和数据所有权。上下文之间通过 API、消息、事件或防腐层集成。

防腐层(Anti-Corruption Layer)的作用是隔离外部模型。例如财务系统里的“订单”字段可能和交易系统完全不同,履约上下文不应该直接依赖它的数据结构,而应该通过转换层把外部概念翻译成自己的领域语言。

9. 六边形架构:让领域核心不依赖外部技术

四层架构解决了领域层的位置问题,但实际项目里,依赖方向仍然容易混乱。六边形架构,也叫 Ports and Adapters,由 Alistair Cockburn 提出,目标是创建松耦合架构,让应用核心可以脱离数据存储、用户界面和外部服务独立测试。

它的关键是把应用核心放在中心,把外部技术放在边缘。应用核心通过端口(Port)表达自己需要什么能力,外部世界通过适配器(Adapter)接入这些端口。

        HTTP Adapter
             |
Payment Adapter -> Application / Domain  PayOrderUseCase
MySQLOrderRepository -> OrderRepository
WeChatPayAdapter -> PaymentGateway
KafkaEventPublisher -> EventPublisher

如果未来 REST API 换成 GraphQL,或者 MySQL 换成 DynamoDB,理论上只需要替换对应适配器。只要端口契约稳定,应用层和领域层不需要知道这些技术变化。

AWS 的规范性指导里也用 Lambda 场景说明了这个模式:Lambda 函数经常把业务逻辑、数据库访问、外部 API 调用写在一起,导致函数难测试、难替换基础设施。使用六边形架构后,可以让 Lambda handler 只是输入适配器,DynamoDB 访问代码只是输出适配器,真正的预约、下单、校验规则留在领域模型和应用用例里。

简化后的结构可以是:

API Gateway
  -> Lambda Handler               # 输入适配器
    -> MakeReservationUseCase     # 输入端口 / 应用用例
      -> Recipient.addSlot(...)   # 领域模型
      -> RecipientRepository      # 输出端口
        -> DynamoDB Adapter       # 输出适配器

这样做的直接收益是:测试 Recipient.addSlot(...)MakeReservationUseCase 时,不需要真的创建 DynamoDB 表,也不需要启动 API Gateway。测试可以注入一个内存版 Repository 或 Mock Adapter,专注验证业务规则。

四层架构和六边形架构并不冲突。四层架构强调代码职责分层,六边形架构强调依赖方向和边界隔离。在实际项目中,两者经常组合使用:

interfaces
application
domain
infrastructure

其中 domain 不依赖任何外部技术,application 依赖领域接口,infrastructure 实现这些接口。

适合使用六边形架构的情况包括:

  • 同一套领域逻辑需要被 HTTP、消息、定时任务、CLI 等多个入口复用。
  • 数据库、外部服务或 UI 未来可能替换。
  • 核心业务规则复杂,需要大量单元测试。
  • 基础设施代码和业务逻辑已经互相污染,修改成本越来越高。

它也有代价:

  • 端口和适配器会增加代码层次。
  • 简单 CRUD 系统可能不需要完整六边形架构。
  • 如果端口设计过细,会产生大量样板代码。
  • 额外抽象可能带来维护开销,极端低延迟场景还要关注额外调用层的成本。

所以六边形架构是为了让业务规则摆脱 UI、数据库和云服务的技术锁定。它最适合用在领域逻辑有长期生命力、基础设施可能变化、测试价值高的核心模块。

10. CQRS:读写模型分离,但不要滥用

CQRS(Command Query Responsibility Segregation)把写模型和读模型分开:

Command Side:处理业务规则,维护聚合一致性
Query Side:面向查询、列表、报表、搜索优化

在履约平台中:

  • 写侧关心:配送单能否分配骑手、是否能取消、状态流转是否合法。
  • 读侧关心:运营大屏、骑手列表、用户订单详情、财务报表。

这些查询常常需要跨多个聚合甚至多个上下文。如果强行用领域聚合承载所有查询,会让模型变形。CQRS 可以让写模型保持干净,同时用投影表、搜索索引、缓存或 OLAP 模型服务查询。

一个常见实现是:

Command -> Aggregate -> Domain Event -> Projection -> Query Model

例如 DeliveryCompleted 事件发生后:

  • 更新用户订单详情页的配送状态。
  • 更新运营大屏的完成单量。
  • 更新骑手当日收入。
  • 推送财务结算投影。

但 CQRS 有成本:双模型、同步延迟、事件投影、数据一致性解释。最佳实践是只在读写复杂度明显不同、查询性能压力大、或模型冲突严重的局部使用。

11. 事件溯源:保存事实

事件溯源(Event Sourcing)把状态变化保存为事件序列:

DeliveryCreated
RiderAssigned
PackagePickedUp
DeliveryCompleted

当前状态可以由事件重放得到。

传统状态存储更像这样:

delivery.status = DELIVERED

事件溯源则保存“为什么变成已送达”:

1. 配送单已创建
2. 骑手已分配
3. 商品已取货
4. 配送已完成

适合事件溯源的场景:

  • 强审计要求,比如金融、交易、结算。
  • 需要追溯“为什么变成现在这样”。
  • 需要时间点查询、事件回放、调试历史问题。
  • 写入天然是追加日志,适合高吞吐事件流。

不适合的情况:

  • 普通 CRUD。
  • 业务团队不接受最终一致性。
  • 查询模型简单,审计要求低。
  • 团队缺少事件版本治理经验。

在履约系统中,结算、账户流水、库存流水比普通用户资料更适合事件溯源。

事件溯源和 CQRS 经常一起出现,但它们不是一回事。CQRS 是读写职责分离,事件溯源是状态持久化方式。可以只用 CQRS,不用事件溯源;也可以用事件溯源,再通过投影构建查询模型。

12. LMAX

LMAX 最初是一个面向零售用户的金融交易平台。它要解决的问题非常极端:大量用户同时下单,市场价格快速变化,订单处理必须低延迟、高吞吐,并且交易结果必须严格有序。

在这类系统里,一笔交易不是孤立的。前一笔交易会改变市场状态、盘口、价格和后续订单的撮合结果。所以它和普通“用户修改个人资料”不同,很多命令天然存在顺序依赖。

传统企业系统通常会这样设计:

多个请求线程
  -> 业务服务
  -> 数据库事务
  -> 锁 / 索引 / SQL / ORM

这种方式的问题是:

  • 并发线程越多,锁竞争越复杂。
  • 数据库成为核心状态和性能瓶颈。
  • ORM 映射让领域模型被数据库结构牵制。
  • 多线程业务逻辑难测试、难推理、难保证顺序。
  • 低延迟场景下,队列、锁、上下文切换、GC 抖动都会被放大。

LMAX 团队一开始也尝试过更常见的并发模型,例如 Actor、SEDA 这类基于队列的并发处理。但性能测试发现,系统花了太多时间在队列协调上,而不是业务逻辑本身。于是他们转向了一个看起来反直觉的方案:核心业务逻辑单线程执行。

它的大致结构是:

Input Disruptor
  -> Business Logic Processor
  -> Output Disruptor

其中最核心的是 Business Logic Processor,可以理解为“业务逻辑处理器”:

  • 所有核心交易逻辑在一个线程中按顺序执行。
  • 领域对象全部放在内存中,不在处理过程中访问数据库。
  • 输入命令被顺序处理,处理后产生输出事件。
  • 系统状态通过事件溯源恢复,而不是依赖数据库当前状态。

这听起来像是牺牲并发,实际上是把并发从最复杂的领域核心里移走。核心交易逻辑单线程,所以不需要锁;没有锁,就少了大量上下文切换和一致性问题。对于交易撮合这种“强顺序、强状态关联”的场景,单线程反而更简单、更快。

那数据怎么持久化?答案是事件溯源。

LMAX 不把数据库作为业务处理的中心,而是把输入事件持久化为日志:

OrderPlaced
OrderMatched
TradeCreated
PriceUpdated

只要事件日志可靠保存,内存中的领域模型就可以通过重放事件恢复出来。为了避免每次都从头重放,系统可以定期做快照,然后从最近快照开始回放后续事件。

这解决了几个关键问题:

  • 高性能:核心逻辑在内存中顺序执行,避免数据库 IO 和锁竞争。
  • 可恢复:事件日志是事实来源,系统崩溃后可以重放恢复。
  • 可诊断:线上问题可以把事件序列拿到测试环境重放。
  • 可复制:多个业务处理器可以消费同一批输入事件,用于热备和故障切换。
  • 领域纯粹:核心模型更像普通面向对象模型,不被 ORM 和表结构绑架。

Disruptor 则是 LMAX 为输入和输出环节设计的高性能并发组件。它不是领域模型本身,而是负责在外围高效地完成网络消息、日志写入、复制、序列化等 IO 工作。它的核心思想是使用环形缓冲区和序号协调,尽量避免传统队列里的锁竞争,并尽量符合现代 CPU 缓存的工作方式。

所以,LMAX 的重点不是“所有系统都应该单线程”,而是它给了 DDD 一个很有启发的案例:

外部 IO 可以并发
核心领域逻辑保持顺序、内存化、可重放

它和 CQRS、事件溯源的关系也很自然。LMAX 的内存业务处理器很像 CQRS 中的命令侧:负责处理命令、维护领域一致性、产生事件。查询侧、报表、风控分析等可以通过事件流构建独立模型,不干扰核心交易处理。

但 LMAX 不是通用模板。它适合低延迟、高吞吐、强顺序、强状态关联的后端系统,例如交易撮合、实时风控、行情处理、规则引擎。普通电商、后台管理、内容系统没有必要为了“看起来先进”而照搬这种架构。

更合理的学习方式是吸收它背后的判断:

  • 不要默认数据库就是领域模型的中心。
  • 不要默认多线程一定比单线程快。
  • 不要在核心领域逻辑里混入慢 IO。
  • 对强审计、强恢复要求的场景,事件日志可能比状态表更可靠。
  • 架构选择必须由业务约束和性能测试驱动,而不是由模式名驱动。

13. 一条实际落地路径

一个更稳妥的 DDD 落地顺序是:

1. 用事件风暴理解业务流程
2. 识别核心、支撑、通用子领域
3. 划分限界上下文
4. 为核心上下文建立统一语言
5. 在上下文内部设计领域模型
6. 用聚合根保护一致性
7. 用四层架构组织代码
8. 用六边形架构隔离基础设施
9. 在复杂读写场景局部引入 CQRS
10. 在强审计和回放场景局部引入事件溯源

落地时要避免几个常见误区:

  • 不要把 DDD 等同于目录分层。
  • 不要为所有表都创建聚合根。
  • 不要把领域服务变成新的上帝类。
  • 不要在没有业务复杂度的地方强行上事件溯源。
  • 不要让技术边界替代业务边界。
  • 不要追求全局统一模型,应该追求上下文内模型一致。

DDD 的核心是让复杂业务在长期演进中仍然能被理解。

14. 小结

领域模型让代码表达业务,聚合根保护一致性,事件风暴帮助团队发现真实流程,子领域和限界上下文帮助系统划清边界。CQRS、事件溯源和 LMAX 则是在更复杂、更高要求场景下的延伸工具。

真正成熟的 DDD 实践,不是把所有概念一次性塞进项目,而是根据业务复杂度逐步引入:先统一语言,再划边界,再建模型,最后才选择合适的架构和技术模式。

参考资料