在过往十几年做后端系统的过程里,我也算是亲眼看着代码从一个工程做到几十上百个服务、再被各种重构拆来拆去,最后发现一个无法回避的问题:业务复杂度一旦上来,技术怎么分都救不了代码的混乱。这也是我后来认真啃、并且在项目里反复用领域驱动设计(DDD)的原因。DDD不是一门“高级技术”,它不依赖框架、不限定语言,它是一套帮你在复杂业务里理清边界、定好模型的语言和方法论。说实话,第一次接触这个概念时我也觉得有点虚,什么聚合、限界上下文、通用语言,每一个词听起来都像“哲学”,不像是能直接写的代码。但当我真正在一个混乱的订单模块里用DDD的思路重做之后,才体会到它真正的价值。
这篇内容适合已经被业务复杂度折磨过的后端开发、架构师、技术Leader,也适合那些项目刚起步但已经预料到未来会失控的团队。我会用自己实际拆过的一个订单中台项目做主线,从概念讲到战略设计、再到战术落地、最后聊一聊工程里常见的坑和排查策略,尽量不做纯理论搬运。
1. DDD到底是什么,以及它要解决的真正问题
刚开始接触DDD的人很容易陷入对概念的背诵,比如实体、值对象、聚合、领域服务这些词,单个拿出来都能看懂,但合在一起就不知道从哪儿下手。我先从它要解决的问题说起。
软件系统的复杂度来源其实只有两类:业务复杂度与技术复杂度。技术复杂度通常是“可解决的”,框架不合适就换框架,存储有瓶颈就做缓存、分库分表、加消息队列,这些都有相对成熟的手段。业务复杂度才是真正让人头疼的:规则之间相互纠缠、不同部门对同一个词的理解完全不一致、需求文档写的是A但业务方嘴上说的是B、改一个简单字段可能牵扯出七八个隐藏状态。DDD的核心目的就是对付这种业务复杂度,它让你不只是把代码写出来,而是先把业务“想清楚”。
从另一个角度看,传统架构在很长一段时间里都是“数据驱动”的:先设计数据库表结构,再根据表的关联关系反推服务怎么写到一起,最后把业务逻辑塞进Service层里。这种做法在业务流程简单的时候没什么问题,但一旦业务增长,Service里面开始堆砌大量if-else和各种状态流转,表关系越连越复杂,最后没有任何人能说清楚整个系统是怎么运作的。DDD选择换一个方向,它把“业务模型”放在最核心的位置,数据库和技术细节都变成支撑模型的底层设施。
还有一个更贴近日常的痛点:沟通成本。业务方嘴里说的“订单”,和研发理解的“订单”可能完全不是一回事。业务方认为订单包含了支付信息、售后信息、物流信息,而研发这边可能把订单、支付单、发货单分成了三张表三个服务。DDD里的通用语言,本质上就是逼着业务人员和研发人员把每个关键业务名词掰开揉碎对齐一致,让代码里的模型和业务方脑子里的模型保持同一套词汇。光这一点,很多团队做到之后效率就有立竿见影的提升。
对比传统的CRUD开发模式,DDD带来的最大不同在于把你从“这张表怎么建”“这个接口怎么设计”的惯性思维里拉出来,强制先回答“这个业务到底有几个核心领域”“边界在哪里”“谁是不可变的规则,谁是会变化的流程”。一旦这些问题有了明确答案,再回去看技术方案,很多纠结自然消失。
关键认知:DDD不是银弹,它解决的是“业务复杂度高”的问题。如果项目只是简单增删改查,就没有必要强行上聚合根和事件溯源那一套,徒增成本。
2. 战略设计:比写代码更重要的一步
战略设计是做DDD的第一步,它不考虑类怎么设计、表怎么建、服务怎么拆,而是聚焦在“问题空间”和“解决方案空间”的划分上。绝大部分DDD落地失败,问题都出在战略设计没做好,就直接跳到战术细节,导致聚合划分一塌糊涂、上下文边界模糊、团队之间依然互相打架。
2.1 用事件风暴摸清业务全貌
事件风暴是一种工作坊形式,聚合业务人员、产品经理、研发一起,在墙边贴满黄色便签,把所有业务事件按时间顺序铺开。每一个事件都对应业务里真实发生的动作,比如“订单已提交”“库存已扣减”“支付已完成”“退款已发起”。然后在此基础上,找出触发事件的命令、执行命令的角色、以及事件发生后关联的数据和外部系统。
我在自己负责的订单中台项目里,第一次组织事件风暴花了差不多两天时间。十几个业务方和研发成员围在白板前吵得不可开交,但恰恰是这种吵,暴露了大量“我以为你懂,其实你根本不知道”的细节。比如运营说的“退款成功”其实是指原路退回支付渠道并且通知用户,而财务说的“退款成功”是指这笔钱已经进入对账清单并完成销账。同一个词,两种语义,如果不通过事件风暴以事件的方式梳理,做到后面必然碰撞。
做完事件风暴,你需要产出一张时间轴事件图。这张图是战略设计的原材料,它清晰说明了系统里有哪些核心流程、哪些环节依赖外部、哪些环节是内部的决策点。接下来可以根据这张图划分领域和限界上下文。
2.2 核心域、支撑域、通用域怎么区分
把业务事件归类以后,你会发现事件背后其实存在几组相对独立的业务能力组合。这些组合考虑命名时,需要主动去识别子域。子域一般分成三类:
第一类是核心域,这是公司业务的核心竞争力所在,决定了你在市场上的差异化优势。比如订单中台项目里,订单状态机、库存预占、价格计算就属于核心域,这些规则写得好不好直接决定了业务的灵活性和稳定性。第二类是支撑域,它支撑核心域运转但本身不是核心竞争力,比如订单里面的售后审核流程、自动分单逻辑、运营配置后台。这些能做,但不是你最需要花精力去精雕细琢的部分。第三类是通用域,属于市面上已经很成熟的通用能力,比如用户认证、消息推送、文件存储,这部分通常不需要自己做太深,能用成熟方案就用成熟方案。
分辨的标准其实很直接:如果这块业务换一种做法,用户能直接感受到明显差异,那大概率是核心域。如果怎么改用户都无感,那可能是支撑域或通用域。资源有限时,优先投入核心域,通用域尽量引入外部组件和成熟解决方案。
2.3 限界上下文是微服务划分的真正依据
很多人把限界上下文和微服务两个概念混在一起,觉得一个上下文就是一个服务。严格来说,限界上下文是业务层面的边界,而微服务是部署和架构层面的物理边界。通常一个限界上下文可以对应一个或多个微服务,也可以多个小上下文合并成一个服务,具体取决于团队规模、部署成本和数据一致性要求。
识别限界上下文有一条非常实用的经验:看“通用语言”是否发生了变化。同一个业务名词在A上下文里和B上下文里含义不一样时,它们之间就必然存在边界,不能强行放在一起。比如“商品”在商品中心上下文里指的是SPU和SKU的完整定义,包含图文、规格、属性;但在购物车上下文里,“商品”可能只需要一个ID、一个名称、一个价格、一个缩略图。如果强行共用一个商品模型,两个上下文的团队就永远在扯皮:为什么这么多字段,为什么这个字段含义变了。
有了上下文之后,还要确认上下文之间如何协作。这就是上下文映射图的工作。最常见的映射关系有防腐层(ACL)、开放主机服务(OHS)、发布语言(PL)等。防腐层是哪天实践里用得更频繁的:当你的上下文需要调用一个外部模型不匹配的系统(不管内部老系统还是外部第三方)时,在边界处加一层转换适配,把外部概念翻译成本上下文的语言,防止外部模型的复杂性污染自己的领域模型。
限制上下文A(订单) --调用--> 防腐层 --翻译--> 外部老库存系统这一段描述看起来简单,但实际写代码时,很多人容易把防腐层直接做成纯粹的数据搬运用DTO转换。真正合理的防腐层还应该承担协议适配、异常翻译、外部模型隔离等多重职责,而不只是把字段从一个对象拷到另一个对象。
战略设计的产出物应该包括:领域划分清单、限界上下文清单、上下文映射关系图。有了这些,架构师再去做服务拆分、接口规划、数据归属等工作就有了依据,不必像以前那样凭感觉拍脑袋。
3. 战术设计:把模型落到可执行的代码
战略设计定义了边界和关系,战术设计则是在边界内部设计具体的领域模型元素,包括实体、值对象、聚合、领域服务、领域事件、仓储等。这是DDD落地时写代码的核心环节。
3.1 实体与值对象:判断标准不是有没有ID
实体是领域里有唯一标识、并且会经历一系列状态变化的对象。比如订单有订单号,支付单有支付流水号,用户有用户ID。实体强调的是“身份和连续性”:同一个订单从新建到支付再到出库,状态一直在变,但拿着同一个订单号,始终定位到同一份数据。
值对象则完全不同,它没有唯一标识,由一组属性共同构成完整性,用来描述某个维度上的特征。典型例子是金额、地址、经纬度、颜色。两个值对象只要属性完全相同就可以视为等价,不需要知道它是谁。比如订单里的收货地址,就是一个值对象,北京海淀区的地址和上海浦东新区的地址是同一套结构的不同实例,不存在“这个地址的唯一身份”。
刚上手时最容易犯的错误是想给一切东西加ID。我见过有人给用户地址加地址ID,给商品标签加标签ID,给订单里的快照信息也加ID。地址本质上不具备独立生命周期的职责,它依附于订单或用户;标签更是如此。如果把值对象强行升级为实体,后面所有代码里都是无意义的ID传递和查询,模型复杂度会被凭空抬高。
一个实用判断方法:当一个对象只描述“是什么”而不关心“是哪一个”,并且修改它通常意味着整体替换而不是局部更新时,它就是值对象。比如修改地址,正常流程是把旧地址整块替换成新地址,而不是把旧地址的某个字段改一下保存回去。
3.2 聚合与聚合根:边界内的一致性问题
聚合是把一组关联紧密的实体和值对象包装在一起的整体,对外只允许通过聚合根进行操作。聚合根是聚合内唯一能被外部引用的对象,所有外部访问必须先经过聚合根,内部对象的修改也必须由聚合根来协调。这个设计的根本目的,是在分布式环境下缩小事务一致性的范围,避免牵一发而动全身。
订单这个聚合里,通常包含订单实体、订单项实体、收获地址值对象、金额值对象。外部系统不需要知道订单项如何与订单关联,也不应该直接去操作订单项的状态,而是通过订单聚合根的方法比如“修改商品数量”“取消订单”来统一触发内部校验和状态流转。这样做的好处是,聚合内部的数据一致性由聚合根来保证,外部不需要理解复杂的内部规则。
聚合设计的粒度是DDD实践中最难拿捏的部分。粒度太大,会把太多业务逻辑塞进一个聚合,导致并发冲突高、数据库锁竞争严重、事务范围过大。粒度太小,又会导致聚合之间大量互相引用,领域模型退化成松散的数据结构。
我自己的经验是:聚合应该尽量小,小到能保证业务上的“固定规则”在单事务内一致即可。什么叫固定规则?比如“订单创建后总额必须等于明细之和”,“订单取消后支付单必须同步取消”,这些就是必须在同一个事务边界内保证的固定规则。至于通知外部系统、生成操作日志、更新统计报表这些,不属于聚合内部的固定规则,完全可以靠领域事件或应用服务来异步完成。
3.3 工厂、仓储与领域服务各自负责什么
工厂负责创建复杂对象。当创建一个聚合需要经过多个步骤、涉及多个内部对象的组装时,直接放在构造函数里很吃力,也容易让调用方知道太多内部细节。用工厂方法把创建逻辑收拢在一起,可以保证生成的对象总是处于合法状态。
仓储负责持久化聚合。仓储的接口定义在领域层,而具体实现放在基础设施层。这样做的好处是领域层完全不依赖数据库、ORM、表结构,仓储接口返回的是聚合根,而不是数据表实体。写代码时要注意,仓储粒度是以聚合为单位,不应该为聚合内部的每个表都提供仓储。很多人把仓储直接做成“泛型CRUD仓库”,这恰恰把DDD带回了传统的数据访问模式。
领域服务与传统的Service不是一回事。传统业务开发里的Service,基本就是把业务逻辑全写在里面,最后变成一个没有任何边界的“上帝类”。领域服务只在领域对象本身无法承载某个业务行为时使用,它的特征是:无状态、行为涉及多个聚合或外部系统、属于领域逻辑但放在实体里又不合适。
举一个例子:计算订单折扣。这个逻辑需要读取订单明细、用户会员等级、优惠券信息,计算结果是订单总金额的一部分。如果放在订单实体里,订单就要依赖用户和优惠券,耦合太重。这时候建立领域服务“订单折扣计算服务”,把规则收敛到一个相对独立的域名服务里,订单实体保持纯粹,逻辑也更清晰。
易混点:应用服务和领域服务的区别。应用服务负责用例编排、事务边界、权限控制;领域服务负责具体的业务规则计算。应用服务不写业务规则,领域服务不涉及网络协议和事务提交。
3.4 领域事件:解耦和最终一致性的桥梁
领域事件表示“领域里已经发生的一件事”,比如“订单已支付”“库存已超卖”。在DDD里,聚合根在状态变化后对外发布领域事件,其他聚合或者限界上下文订阅事件并做出响应。
领域事件的一个经典使用场景是跨聚合的一致性。继续拿订单和库存举例:订单确认支付后,需要扣减库存。如果把扣减库存放在同一个事务里,那就要同时锁订单和库存两张表,在并发量高的情况下会严重影响吞吐。通过领域事件方式,订单聚合在自己的事务里提交,同时发布“支付成功”事件,库存服务订阅事件后在另一个事务里扣减库存。两个操作之间短暂不一致是可接受的,系统通过最终一致性达到同步。
落地领域事件时有几个细节要注意。第一,事件一定要包含业务语义而不是数据库变更语义,事件名应该是“已发生的事”而不是“表更新了”。第二,事件的发布时机通常是在聚合事务提交后,避免事件发出但事务回滚造成的幽灵事件。第三,需要设计事件的幂等处理机制,因为消息中间件本身只能保证至少一次投递,消费端需要依靠业务唯一键做好幂等。
现在工程上常用的事务发件箱模式来解决本地事务和事件发布的原子性问题:在聚合所在的同一个数据库里建一张本地消息表,事务提交时同时写入业务数据和领域事件,后台任务再把事件发布到消息中间件。这样既保证了事件不丢,也不引入分布式事务的复杂度。
4. 分层架构与代码落地的实操方案
战略和战术概念都清楚了,代码层面怎么落地是接下来真正的问题。DDD并没有规定固定的分层架构,但最常用的有经典四层架构和六边形架构。我实际项目里更建议使用六边形架构,也就是端口与适配器风格,因为它与SpringBoot这类框架的契合度好,而且边界清晰。
六边形架构的核心思想是:领域模型位于最中心,不依赖任何外部设施;外部通过端口(接口)与内部交互,而端口对应的适配器则负责具体的技术实现,比如HTTP请求适配器、数据库适配器、消息中间件适配器。领域层只需要面对接口,不需要知道JDBC、MyBatis或Redis的存在。
调用方向如下:
控制器/适配器 -> 应用服务 -> 领域服务/聚合根 -> 仓储接口 -> 仓储实现/第三方端口写代码时建议严格按照这个依赖方向控制,并且强制禁止跨层调用:控制器不能直接访问仓储接口;基础设施层不能反向依赖领域层;领域层严禁出现Spring注解和MyBatis的Model类。
分层落地之后,模块划分也是一个容易纠结的点。我的做法是:按限界上下文划分顶层Module,每个Module内部再按Domain、Application、Infrastructure、Interfaces分层。比如订单服务,就是order-context这个Module独占一个代码仓库或者至少独占一个Maven模块,内部按四层分包。这样团队开发时可以快速定位到某块业务,也能很好地控制代码下沉和上引。
下面是订单聚合领域的代码片段,作为一套简化但完整的示例:
// 领域层:订单聚合根 public class Order { private OrderId id; private List<OrderItem> items; private Money totalAmount; private OrderStatus status; public void addItem(ProductSnapshot product, int quantity) { if (quantity <= 0) { throw new IllegalArgumentException("数量必须大于0"); } if (status != OrderStatus.CREATED) { throw new IllegalStateException("订单已提交,不允许修改明细"); } this.items.add(new OrderItem(product, quantity)); this.totalAmount = calculateTotal(); } public void confirm() { if (status != OrderStatus.CREATED) { throw new IllegalStateException("只有新创建的订单才能确认"); } this.status = OrderStatus.CONFIRMED; registerEvent(new OrderConfirmedEvent(this.id)); } }领域层代码读起来几乎就是业务规则在自然语言层面的直译:只有创建状态的订单才能被确认,数量必须大于零,已提交的订单不能改明细。这些规则不需要借助任何框架和ORM就能清晰表达。
仓储接口也同样简洁:
public interface OrderRepository { Order find(OrderId id); void save(Order order); }实现放在基础设施层:
@Repository public class OrderRepositoryImpl implements OrderRepository { @Autowired private OrderJpaMapper mapper; @Override public Order find(OrderId id) { OrderDO data = mapper.selectById(id.getValue()); return OrderAssembler.convertToEntity(data); } @Override public void save(Order order) { OrderDO data = OrderAssembler.convertToDO(order); mapper.save(data); } }应用服务编排操作时,要保持薄薄的一层:
@Service @Transactional public class OrderAppService { private final OrderRepository orderRepository; private final DiscountService discountService; private final EventPublisher eventPublisher; public Order confirmOrder(OrderId id) { Order order = orderRepository.find(id); order.confirm(); orderRepository.save(order); eventPublisher.publish(order.extractEvents()); return order; } }应用服务做的事情很纯粹:找到聚合、调用聚合方法、保证事务、发布事件。它没有携带业务规则,业务规则都在领域层。这样Controller层就只会做参数校验和返回转换,代码写出来非常稳定。
5. DDD落地的常见问题与排查实录
DDD不是看几篇文章就能顺利用起来的,落地过程会遇到一堆困扰。我把过去踩过的坑和团队里其他人遇到的问题整理成一份速查,希望能帮还在摸索的人省下一些时间。
5.1 聚合根更新时的并发控制和旧状态校验
候选问题:当两个请求同时操作同一个订单,一个要做“取消”,另一个要做“确认支付”,如果没有并发控制,两个操作就可能相互覆盖。
排查思路:核心要理解并发冲突本质上是聚合根状态被并发修改。解决的常见方式有两种。一种是乐观锁,在聚合根中增加version字段,保存时比较并更新版本号。另一种是悲观锁,在仓储实现层通过数据库select for update锁定对应行。分布式环境下,也可以考虑通过分布式锁按订单ID加锁,但要特别小心锁粒度:必须精确到业务实例,不能锁整个表。
实际经验是,订单这类聚合,大多数场景并发写非常集中,且一旦状态流转完成就不允许再被修改,所以乐观锁最简单有效。而对于账户余额这类高频写操作,如果需要绝对的更新顺序,则要用更复杂的锁策略或改为事件溯源方式。
5.2 贫血模型和充血模型之间的平衡
很多团队在践行DDD时写出来的代码依然属于贫血模型:实体里只有getter和setter,业务逻辑全部跑在Service里。这其实说明战术设计还没有真正落地,DDD的优势因此也发挥不出来。
但要注意,贯彻充血模型不等于把大量代码塞进实体。实体内只承载自身一致性所必需的规则,比如状态机、金额计算、结构约束。那些跨聚合的编排和外部系统交互,交给应用服务和领域服务。判断一个方法放不该放在实体内,可以问一个问题:这个方法改了当前聚合的字段状态吗?如果改了,而且需要依赖聚合内的其他对象来保证一致,那就放在聚合根内部。如果不改状态,只是查询或者计算,那可能更适合放领域服务。
过度充血也是坑。我见过有人把搜索引擎索引逻辑都塞进商品实体,导致实体里面既不“纯”也不“轻”,每次加载商品都要连带处理一堆无关职责。实体关注的是业务一致性,不是集成逻辑。
5.3 事务边界和“跨聚合的假事务”
最经典的误区是“因为业务涉及订单和库存,所以干脆让一个事务同时提交两张表”。从关系型数据库本地事务的标准看这个操作是合理的,但放在高并发核心链路上,这会导致严重锁竞争,随着聚合数量增长还会产生分布式事务的协调成本。
更好的处理思路是:先用聚合把“必须同生共死的数据”收拢到一个事务里,把跨聚合的最终一致交给领域事件。比如支付成功后需要同时更新订单和扣减库存,不要用一个大事务去锁全部,而是让订单事务提交成功后发布事件,库存服务异步处理。业务短暂不一致没问题,只要最后收敛一致,并且有对账兜底。
判断事务边界是否合理的简单办法:如果你发现自己在事务里调用远程RPC、等待第三方回调、或者锁了多个聚合的行,那么边界十有八九划错了。
5.4 仓储与数据模型不一致时的适配
DDD里,领域模型与数据库表结构往往不是一一对应的。一个聚合在数据库里可能对应多张表,一个值对象可能以JSON字段存储,一个领域概念可能需要从多个数据源拼装。
所以我一直强调,仓储实现本质上是适配器,它负责领域对象和持久化数据模型之间的双向转换。转换过程可以用Assembler或Converter模式独立成类,避免在仓储实现里堆积永久映射逻辑。当遇到性能瓶颈时,可以在仓储内部做查询优化,比如减少跨表查询、局部延迟加载、合理使用物化视图等,但对外暴露的仓储接口不需要因此变化。
5.5 团队协作:通用语言没对齐,一切白搭
这是最隐蔽但影响最深远的问题。如果团队没有真正建立通用语言并持续维护,战略设计阶段的成果会被推翻,战术设计阶段的模型会变得模糊。
我见证过一个销售域上下文,团队内部开发一直在用“客户单”这个叫法,但业务方不认这个词。业务方说的是“线索”“商机”“客户”,但代码里的类名和产品PRD里的名词完全是两套体系。最终导致每次需求沟通都要先翻译一遍,模型频繁调整,返工率很高。
解决通用语言不一致没有捷径,要不断召开事件风暴或模型评审会,把业务方、产品、研发拉到一起,统一术语词典是必须的。每个限界上下文要有术语表,核心概念用代码名和业务名双向映射。如果有条件,将术语表整理到团队Wiki并定期评审,新成员入职时也要先过一遍。
5.6 什么时候不该用DDD
最后一条要比前面所有内容都更重要:DDD有明确适用场景,不是到处都要套用的。简单CRUD项目、报表类项目、管理后台类项目,强行使用DDD只会增加开发成本,写出“为了分层而分层”的代码。团队的领域专家参与度低,研发和业务无法顺畅沟通时,上DDD也大概率失败。
适用DDD的场景往往有几个特征:业务规则复杂且持续演化;有核心业务需要精细化运营;团队具备领域建模能力和技术基础;开发节奏允许在前期设计和建模上有一定投入。如果你团队现在的业务只是简单增删改查,完全可以先做一套标准的Controller-Service-Mapper架构,等业务复杂度到了临界点再引入DDD也不迟。
6. 关于DDD的几点个人体会
DDD真正难的不是那些术语,也不是聚合和事件这些技术点,而是它要求团队里每一个人都愿意停下来思考“业务是什么”。在长期忙于接需求、赶上线、修Bug的氛围里,这种思考显得特别奢侈。但项目越是复杂、业务越是在演进,这种沉默的成本反而越高。我和团队实践下来,最明显的收获不是某一套代码写得“好看了”,而是业务方和研发之间的沟通方式变了:大家开始用同一个词讨论同一个概念,方案评审时不再争论实现细节,而是在对齐业务规则。
另外,DDD一般不是一步到位的。如果团队第一次接触,我建议不要试着一次性完成全部门或者整个中台的建模,那样风险太大了。最安稳的第1步是找一个最痛的核心模块,例如订单、库存或价格中心,用两三天时间做事件风暴和上下文梳理,再小范围写一部分代码验证模型。当团队积累了信心和方法论后再逐步推开,会顺畅很多。
我还想补充一个细节:DDD和微服务天然契合,但不代表DDD只服务微服务架构。单体应用领域模型同样可以用DDD来组织代码,只是不需要考虑进程间事件通信那一套复杂性。所以不用为了上DDD而先去拆微服务,很多时候业务模型的边界清晰了,未来微服务该怎么拆,反而是水到渠成的事。
如果真的想更深入地感知DDD,光看文章远远不够。建议找一个真实存在的、自己熟悉的业务场景,亲手做一个完整的建模:画出限界上下文、定义聚合、写一段领域代码,然后找一个业务专家认真聊聊模型是否匹配。模型的推敲和被推翻本身就是DDD最有价值的部分之一。很多人问“领先的团队在用DDD吗”,我的感受是,真正领先的团队并不是在追这个名词,而是在用DDD的方法帮助自己持续、高效地处理复杂业务。