简介:领域驱动设计(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() == ...)这类规则判断?有,说明业务规则漏到了编排层。 - 聚合根之间有没有互相调用内部集合?有,说明聚合边界切错了。
- 跨上下文调用有没有绕过翻译层?有,说明防腐层缺失。
这四个信号我基本百试百灵。带过的项目里,只要其中两个同时亮红灯,两个月后必然出现需求变更牵一发动全身的情况。我自己的习惯是,把这些信号写进代码评审的检查项里,每次评审照着过一遍,比临时讨论“这段该放哪”高效得多。
这套方法不是一个“做完就结束”的任务,而是一种持续校准的节奏。只要业务还在变化,模型就需要跟着迭代。希望帮到你。
本文还有配套的精品资源,点击获取