ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

领域驱动设计落地指南:从限界上下文到聚合建模的实战避坑

领域驱动设计落地指南:从限界上下文到聚合建模的实战避坑

简介:领域驱动设计(DDD)是一套聚焦业务领域的软件建模方法论,其核心在于通过精简业务模型、统一通用语言,以及持续的业务与技术对话,让软件系统准确反映真实业务。这份PDF教程面向希望掌握DDD核心概念的开发者、架构师和技术管理者,既适合零基础入门,也能帮助实践者复盘建模思路。压缩包仅有1个PDF文件,大小约1.27MB,内容完整,在电脑或移动设备上均可顺畅阅读。教程从DDD的核心理念讲起,系统覆盖模型驱动设计、聚合、限界上下文、事件风暴等关键实践,并专门收录了中文版译序中对“如何提炼恰好够用的业务模型”“如何用团队模型匹配业务模型”的深入思考,这些内容对应对复杂业务建模、避免领域模型被技术细节肢解颇有启发。目前已有483人学习下载,是快速建立DDD全景认知的一份轻量级资料。

1. 领域驱动设计到底治什么病:先从一次需求变更说起

一个很常见的翻车场景:“订单提交后要校验收货手机号不能为空。”这个需求听起来不大,真到改代码时你会发现,校验逻辑散落在用户接口层、应用服务、甚至数据库约束里,每个地方写的规则还不完全一样。改一个是救火,全改才是治病。领域驱动设计(DDD)就是冲着这种“业务规则四散”的病来的:它让你先划清业务边界,再把规则收拢到领域模型里,让每个业务动作都有唯一的负责人。这篇教程不是教科书的纸面复述,而是一份按落地顺序整理过的实操笔记,覆盖战略设计、战术设计、分层实现和最常见的坑。

适合谁看?正在把老系统往领域模型上迁的开发者、后端架构师,以及每次需求一变就要全链路排查“规则到底在哪”的负责人。不需要你读过任何DDD原著,只要你手头有一个业务规则开始失控的系统,这篇就能帮你找到下刀的口子。

2. 战略设计先行:用限界上下文切开混乱的业务边界

战略设计是DDD里最值钱的一步,但也是新手最容易跳过的一步。很多人上来就写实体、写值对象,结果发现模型建得挺漂亮,业务方不认、代码也没变清晰。原因很简单:你还没有搞清楚“业务到底分成几块”,就急着给每一块建房子。

限界上下文(Bounded Context)就是第一次分水岭。它把系统按业务职责切成一幅地图,每个上下文有自己独立的概念模型、独立的数据库规则和团队边界。订单模块归订单上下文管,库存模块归库存上下文管,两边用自己的语言说话,互不渗透。做到了这一层,“业务规则散落”的问题至少被解决了一半。

2.1 怎么识别限界上下文:三个最上手的线索

识别上下文不需要高大上的方法论,日常交流里就有线索。我常用的三个判断维度:

线索观察什么怎么用
业务语言分界同一个词在不同业务环节里含义是否一致“客户”在售前叫线索,在售后叫联系人,说明这是两个上下文
团队组织边界哪个小组在维护哪段业务两个团队改同一个库,大概率是上下文没切干净
一致性要求哪些数据必须强一致,哪些可以接受延迟订单和支付能最终一致,就别强行放进一个上下文

实际案例:一个销售系统里,“下单”在订单侧是“提交订单”,在财务侧叫“生成应收单”。同一个动作两个叫法,但两边对订单对象的修改规则完全不同。让它们共用同一个订单实体,就会天天打架。正确做法是把订单上下文和财务上下文切开,财务通过接口拿自己需要的数据,而不是直接改订单表。

2.2 上下文映射关系:决定你代码里要写哪种防腐层

切完上下文,下一步是定义它们之间怎么协作。领域驱动设计里常见的映射关系有防腐层(ACL)、开放主机服务(OHS)、发布语言(PL)等,落到代码层面,最常用也最值得优先掌握的是防腐层。

防腐层解决的是“上游模型很烂,但你又不能不接”的问题。比如外部老系统传给你一个字段含义混乱的DTO,你不能让这个DTO直接穿透到领域模型里,否则脏数据会污染你的核心逻辑。常见做法是写一个翻译层,把外部数据结构转换成内部统一模型:

// 外部库存系统返回的是 DTO,领域模型不认它,翻译一下再进边界 public class WarehouseTranslator { public Inventory toDomain(WarehouseProductDto dto) { return Inventory.of(Sku.of(dto.skuCode()), Quantity.of(dto.availableCount())); } }

逻辑说明:toDomain方法把外部的skuCode翻译成领域里的Sku值对象,把availableCount转成Quantity。这样领域层永远不知道外部DTO长什么样子,外部字段改名、加字段,影响范围被限制在防腐层里。参数说明:构造Sku和Quantity时由值对象自行校验合法性,空值或负数在这里就会被拦下。

2.3 用事件风暴把业务故事变成建模素材

识别上下文最实操的方法,是把一个完整业务流程按“事件”顺着时间线排出来,这个练习叫事件风暴。以订单场景为例,一组人围在白板前,把业务方口中“发生过的事”写成事件卡片放上去:

事件触发命令参与业务对象备注
订单已创建用户选择商品并创建订单订单只记录已发生事实
订单已提交用户点击提交订单订单提交后不可再改商品
已发起支付订单状态机转入待支付订单、支付支付是否独立取决于团队边界
库存预占成功支付结果异步回调库存顺序由业务规则决定

事件风暴的产出不是让你写事件驱动架构,而是帮你找到“哪些事件共享同一套规则、同一批人维护”。如果“库存预占成功”涉及库存团队、库存数据库、库存术语,它就应该拆到库存上下文;如果它只改订单的一个字段,那它还是订单上下文的内聚逻辑。很多团队把事件风暴做成了“消息队列设计稿”,这是理解偏了。它是个分析手段,不是架构图。

3. 战术设计落地:聚合、实体与值对象怎么建才不散

战略设计决定了边界,战术设计决定边界里面长什么样。这个阶段的核心是四个词:实体、值对象、聚合根、领域服务。大多数团队翻车,主要原因不是不知道这四个词的定义,而是不知道“怎么判”。

3.1 实体和值对象的判定:看“换新”还是“改属性”

判定一个对象是实体还是值对象,最简单的标准是:这个对象丢掉“身份”后,还有没有意义?

用户是实体。用户名改了、头像换了、手机号换了,用户还是那个用户,因为有一个userId从头到尾不变,系统靠它识别同一个体。订单也是实体,订单号就是它的身份。

收货地址是值对象。地址的省市区街道全部改了,它还是一个“收货地址”,没有谁能说“这个地址是上一个地址的转世”。金额更是值对象,10元就是10元,不是“某一个10元”的独特个体。

落到Java代码上,值对象有三个硬性要求:不可变、可整体替换、基于属性判等。意思是:所有字段 final,不提供 setter,equals和hashCode基于全部业务字段而不是对象引用。这一点对接下来的建模影响很大,后面避坑章节会专门讲。

3.2 聚合边界=事务边界:别把自己绕进锁冲突

聚合是DDD里最难把握的概念,也是实际开发里最容易出问题的地方。聚合的定义是:一组必须保持业务一致性的对象集合,对外只通过聚合根访问。

通俗一点说:一次业务操作里,必须强一致修改的对象,才放进同一个聚合;能分开处理的对象,别硬塞在一起。

新手常犯的错误是把“查询时需要一起展示”当成“必须放进同一聚合”。订单、订单项、客户、收货地址放在一个大聚合里,结果每次下单都锁一整条数据链,并发一上来就死锁或超时。正确做法是:订单和订单项通常在一个聚合里,因为“订单金额”必须和明细一致;客户是另一个聚合,订单里只存CustomerId;收货地址做成值对象挂到订单上,不独立建表拉关系。

判断聚合边界有一个实用技巧:把“不变量列表”写出来。所谓不变量就是“不管怎么操作都绝对不能违反的规则”,比如“订单总金额=所有明细金额之和”“草稿订单才能加商品”。然后检查:哪个规则一旦违反,必须立刻回滚?这些规则涉及的最小对象集合,就是聚合。

3.3 一个订单聚合的Java骨架:照着改就行

下面是一份订单聚合的最小骨架,可以用它作为你自己建模的起点。首先是订单编号和订单行,这里特意把OrderId和OrderItem都定义成值对象:

// OrderId.java —— 订单编号作为值对象,自带不可变性 public final class OrderId { private final Long value; private OrderId(Long value) { this.value = value; } public static OrderId of(Long value) { if (value == null || value <= 0) { throw new IllegalArgumentException("订单编号不合法"); } return new OrderId(value); } public Long value() { return value; } // 值对象判等基于编号,不基于对象引用 @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof OrderId)) return false; return value.equals(((OrderId) o).value); } @Override public int hashCode() { return value.hashCode(); } }
// OrderItem.java —— 订单行:值对象,替换比修改更有意义 public final class OrderItem { private final ProductId productId; private final int quantity; private final Money unitPrice; public OrderItem(ProductId productId, int quantity, Money unitPrice) { if (productId == null || quantity <= 0 || unitPrice == null) { throw new DomainRuleException("商品项信息不完整"); } this.productId = productId; this.quantity = quantity; this.unitPrice = unitPrice; } public Money subtotal() { return unitPrice.multiply(quantity); } }

逻辑说明:OrderId的构造方法私有,只能通过of创建,保证了编号合法性校验不会遗漏。OrderItem的字段全是 final,创建后不可修改,要改只能“换一个新的订单行”。这是值对象不可变性的直接体现。参数说明:Money同样是值对象,乘法和加法逻辑收在它内部,不要在外面算金额,否则四舍五入规则又散落一地。

然后是聚合根部,所有订单内部操作都通过它来调用:

// Order.java —— 聚合根:外部只能通过它访问订单内部 public class Order { private OrderId id; private OrderStatus status; private List<OrderItem> items; private Address deliveryAddress; private List<DomainEvent> pendingEvents = new ArrayList<>(); Order(OrderId id, Address deliveryAddress) { this.id = id; this.status = OrderStatus.DRAFT; this.items = new ArrayList<>(); this.deliveryAddress = deliveryAddress; } // 只有草稿订单才能追加商品 public void addItem(ProductId productId, int quantity, Money unitPrice) { if (status != OrderStatus.DRAFT) { throw new DomainRuleException("只有草稿订单才能追加商品"); } items.add(new OrderItem(productId, quantity, unitPrice)); } // 提交订单:校验核心不变量后流转状态 public void submit() { if (items.isEmpty()) { throw new DomainRuleException("空订单不能提交"); } if (deliveryAddress == null) { throw new DomainRuleException("提交前必须确认收货地址"); } this.status = OrderStatus.SUBMITTED; pendingEvents.add(new OrderSubmittedEvent(id)); } public List<DomainEvent> pendingEvents() { return Collections.unmodifiableList(pendingEvents); } public void clearEvents() { pendingEvents.clear(); } }

逻辑说明:addItem和submit是两个“业务方法”,而不是简单的 setter。它们把状态校验和业务规则收进聚合根里,应用层不再需要到处写if (order.getStatus() != DRAFT)。pendingEvents是聚合里暂存的领域事件,等事务成功后再统一发布,具体机制在第 4 章讲。参数说明:DomainRuleException是自定义业务异常,不要用RuntimeException裸抛,否则调用方无法区分“业务不允许”和“程序出错了”。

注意:订单聚合根建议提供静态工厂方法Order.create(...),而不是在应用层直接new Order(...),否则创建校验还是会被绕过。工厂方法把“创建订单”定位成领域层自己的职责。

4. 四层架构与代码落地:让领域模型在仓库与接口之间活起来

光有模型不落地等于白建。DDD落地时最常用的是四层架构:用户接口层、应用层、领域层、基础设施层。这一章不展开讲每层的教科书定义,只讲两件事:依赖方向怎么控制,事务边界怎么划。

4.1 分层职责和依赖方向:最容易被改坏的一处

四层架构里最容易改坏的就是依赖方向。正宗的依赖规则是:外层依赖内层,内层不依赖外层。领域层是心脏,不依赖任何框架和数据库;应用层编排用例,依赖领域层;基础设施层实现仓储接口,依赖领域层而不是被领域层依赖。

层级职责允许依赖谁不允许依赖谁
用户接口层接收请求、渲染响应、参数校验应用层领域层、基础设施层
应用层编排用例、开事务、发事件领域层基础设施层、用户接口层
领域层业务规则、实体、值对象、聚合、领域服务只依赖自己任何外部技术
基础设施层实现仓储接口、对接消息中间件领域层应用层、用户接口层

如果代码里出现领域层直接 import 某个 ORM 的注解,或者应用层直接操作数据库 Session,架构就已经坏了一半。很多老代码的问题不是模型不对,而是依赖方向乱了,导致业务规则被数据库实现牵着走。

4.2 应用服务、领域服务与仓库接口的最小协作代码

这个协作关系的核心是:应用层只做编排,不做业务判断;仓储接口定义在领域层,实现放在基础设施层。

// OrderRepository.java —— 仓储接口定义在领域层,实现对在基础设施层 public interface OrderRepository { Order findById(OrderId id); void save(Order order); }
// OrderApplicationService.java —— 应用服务:编排用例,不承载业务规则 @Service public class OrderApplicationService { private final OrderRepository orderRepository; public OrderApplicationService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Transactional public void submitOrder(Long orderId) { Order order = orderRepository.findById(OrderId.of(orderId)); order.submit(); // 业务规则在聚合根里 orderRepository.save(order); // 仓储负责持久化 } }

逻辑说明:submitOrder的流程只有三步——查出聚合、调用业务方法、保存。没有if (status == xxx)的判断,没有更新字段的 setter 调用,这些都在order.submit()内部完成。参数说明:OrderId.of(orderId)在入口做一次编号校验;save方法的名字刻意叫 save 而不是 update,因为调用方不关心这次是新增还是更新,仓储会根据编号判断。

领域服务和应用服务的区别也需要说清:应用服务解决“这个用例要调哪些聚合”,领域服务解决“跨聚合的业务规则归属”。比如“计算订单优惠后的实付金额”,涉及订单和优惠券两个聚合,放进任何一个聚合都不合适,这时建一个OrderPricingService放在领域层,才算找对归属。

4.3 事务边界与最终一致性:领域事件怎么用才不算过度设计

很多团队一开始用领域事件,就把每个操作都发一条消息,结果系统变成一张消息网,没人理得清。判断是否真的需要领域事件,标准很简单:改动发生后,有没有别的主体需要做后续动作,且这个动作可以延迟?订单提交后要发短信通知,这是典型的延迟动作。

但这里有个经典坑:在事务里直接发消息,事务回滚后消息却已经发出去了。我现在的习惯是,用事务同步器把事件延后到事务提交后再发布,或用发件箱表保证投递可靠性:

@Transactional public void submitOrder(Long orderId) { Order order = orderRepository.findById(OrderId.of(orderId)); order.submit(); orderRepository.save(order); List<DomainEvent> events = new ArrayList<>(order.pendingEvents()); order.clearEvents(); // 事务提交后再发,避免“业务回滚了,消息却发出去了” TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { events.forEach(eventPublisher::publish); } }); }

逻辑说明:afterCommit只在事务成功提交后触发;事务回滚时这个方法不会被调用,事件就不会发出。如果消息中间件本身不稳定,或你的团队希望做可靠投递,更稳妥的方案是“发件箱表”:把事件先写进数据库的outbox_event表,与业务数据同库同事务,再由后台任务扫描投递:

CREATE TABLE outbox_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_type VARCHAR(128) NOT NULL, payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

逻辑说明:这张表和订单表在同一个数据库、同一个事务里写入。事务提交后,事件已在表里;后台定时任务把未投递的事件发出去,成功后再标记删除。这样即使应用在发消息前崩溃,重启后事件也不会丢。注意:领域事件只关心“发生了什么”,比如OrderSubmittedEvent,别把“怎么处理”塞进事件里——那是监听方的事。

5. DDD落地避坑:六个让团队翻车的常见问题与排查

再好的理论,落到真实代码里都有坎。下面这六个问题我基本在每个DDD项目里都见过,按“现象-原因-解决”的方式记录下来,你可以直接拿来当代码评审的检查清单。

5.1 值对象被做成可变DTO

现象:Address类的字段非 final,还有一堆 setter。两个相同的地址用equals比较却返回 false;一处修改地址,别处读到的数据跟着变,查了半天才定位到是引用共享。

原因:把值对象混成了可变 DTO。值对象的本质是“它是它自己的身份”,必须不可变、可替换、按值判等。DTO 是传输载体,本来就可以塞进各种杂数据,两者职责完全不同。

解决:给值对象上三道锁——类声明为 final,所有字段加 final,不写任何 setter;equals和hashCode基于全部业务字段生成。排查时全局搜一下值对象的setXxx方法,出现一个改一个。

5.2 聚合根在应用层到处被 new

现象:十个 Service 里都在new Order(),每个地方对“新订单必须有收货地址”的校验写得都不一样;想改创建规则,得把所有调用点翻一遍。

原因:聚合的创建没有收敛。聚合根是模型的大门,大门开着,规则自然守不住。

解决:把构造函数改为包级私有或私有,对外只暴露静态工厂方法Order.create(...),创建相关的校验全部收进工厂里。从评审角度,代码搜索里出现new Order(的调用点,就应该直接标红。

5.3 用 ORM 的级联删除实现聚合删除

现象:删除一个商品类目,结果历史订单明细被级联清空,线上数据丢得悄无声息。

原因:数据库外键配了ON DELETE CASCADE,或 ORM 里加了CascadeType.REMOVE。数据库层的级联操作根本不懂业务规则。

解决:删除也走业务方法,比如聚合根里的delete()先判断“这不是历史订单关联的商品”,再标记状态或执行软删除。数据库层级联只保留在外键约束层面兜底,不依赖它实现业务删除。

5.4 领域事件“死”在事务里

现象:业务代码里调了消息发送,日志显示发送成功,但数据库事务后来回滚;下游系统收到通知开始处理,上游订单却压根没生成。

原因:发送动作和事务提交状态没挂钩,属于典型的“事务内发消息”。

解决:按 4.3 的方式,用事务同步器把发布延后到afterCommit,或者用发件箱表保证最终一致性。同时,为每类领域事件加上幂等消费开关,否则哪怕你只发一次,下游的重试机制也可能造成重复处理。

5.5 限界上下文拆得比业务还碎

现象:为了 DDD 而 DDD,一个简单订单模块拆出“订单上下文”“结算上下文”“配送上下文”,结果一个列表页要拼四个服务的数据,联调成本翻倍,性能还差。

原因:只看到“概念上可分”,没看到“运行上是否独立”。限界上下文的划分依据是团队边界、独立部署和独立数据,不是概念分类号。

解决:拆上下文之前先自问“这个上下文能不能独立部署?有没有独立的数据表?有没有独立的业务负责人?”三个问题答不上来,就不拆。宁可先粗后细,别一上来碎一地。

5.6 没有统一语言,模型和业务方各说各话

现象:代码枚举叫“已提交”,业务方管它叫“已下单”;测试用例里写的和开发实现里用的状态名对不上,需求评审时吵了半天才发现是同一个东西。

原因:缺少一个“通用语言”清单。DDD 里有个重要概念叫通用语言(Ubiquitous Language),指的是业务和技术共同使用同一套词汇。很多团队跳过了这一步,导致模型只是开发自嗨的产物。

解决:每个限界上下文维护一份词汇表,把业务术语、代码类名、方法名、状态枚举值放在一张表里对照,不一致的直接改代码或找业务方确认,而不是互相迁就。

6. 立刻见效的技巧:用统一词汇表把模型钉在业务上

前五章讲的是骨架,这一章的技巧能让骨架真正长出肌肉:把“通用语言”落到团队日常协作里。不用搞复杂工具,一张共享文档就够了。

6.1 统一词汇表三步走:把业务术语钉进代码

第一步,把最近需求评审时业务方高频使用的术语原样记下来,不要做任何加工。第二步,把这些术语映射到代码里的类名、方法名、枚举值,写出它们对应的限界上下文。第三步,发现不一致的地方,当场定夺是改代码还是改术语。

业务术语代码模型所在上下文备注
下单submit()/OrderStatus.SUBMITTED订单上下文与运营侧“提交”同意
客户Customer聚合客户上下文售前叫线索,明确不一致
应收单ReceivableDocument财务上下文与订单解耦

这个表我一般放在项目的 README 或者 docs 目录下,每次迭代评审拿出来走查一遍。别觉得这是形式主义:绝大多数“状态对应错了”的线上事故,根源都是术语没有统一。

6.2 十分钟快速自测:四个信号判断模型有没有做坏

每次迭代结束前,花十分钟用四个信号自查一遍团队代码:

  • 值对象里有没有出现 setter?有,说明不可变性破了。
  • 应用服务里有没有if (order.getStatus() == ...)这类规则判断?有,说明业务规则漏到了编排层。
  • 聚合根之间有没有互相调用内部集合?有,说明聚合边界切错了。
  • 跨上下文调用有没有绕过翻译层?有,说明防腐层缺失。

这四个信号我基本百试百灵。带过的项目里,只要其中两个同时亮红灯,两个月后必然出现需求变更牵一发动全身的情况。我自己的习惯是,把这些信号写进代码评审的检查项里,每次评审照着过一遍,比临时讨论“这段该放哪”高效得多。

这套方法不是一个“做完就结束”的任务,而是一种持续校准的节奏。只要业务还在变化,模型就需要跟着迭代。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进