从 CRUD 到 DDD:一个 Java 业务开发的认知突围笔记
这是我转型记录系列的第二篇。上一篇我聊了从外包 Java 转型 Agent 开发的心路历程,这一篇聊聊我入职新公司后接触到的、彻底改变我对"业务代码"理解的 DDD 六边形架构。
写这篇的初衷是:作为一个干了三年 CRUD 的 Java 开发,我第一次见到一个不是"Controller-Service-DAO"的项目时,受到的冲击是巨大的。 这种冲击让我意识到,所谓"高级开发"和"CRUD 开发"的区别,不在于会用多少中间件,而在于怎么组织业务代码。
一、为什么需要 DDD?先看看 CRUD 的痛
我之前做过几年 ERP 和 MES 的 CRUD 开发,写代码的方式基本是:
markdown
Controller 接参数
↓
Service 写业务逻辑(一个方法 200 行起步)
↓
DAO 操作数据库
这种写法表面上"简单直接",但项目大了之后会出现一堆经典问题:
- 业务逻辑散落在到处都是------同一个"订单状态流转"的逻辑,可能在 Controller 里有一段、Service 里有一段、工具类里又有一段,改一处忘一处
- DO(数据对象)属性被多个 Service 随意访问和修改------A 服务改了状态、B 服务改了金额、C 服务改了备注,最后状态对不上
- 业务规则缺乏明确归属------"机票退改签政策"这个逻辑应该放哪?放 Service 里 Service 会膨胀,放 Util 里又找不到归属
- 多表操作通过 Manager 层堆砌------一个方法里 5 个 DAO 调用 + 3 个外部 RPC 调用,改一行就可能引发雪崩
- 改个枚举要改十几个文件------因为同一个状态在 DO、VO、DTO、PO 里到处都是
核心问题:业务逻辑和数据结构没有归属,所有人都能改任何东西,最终代码变成"谁都不敢动"的祖传代码。
DDD 解决的就是这个问题------给业务逻辑找一个明确的家,谁都不能越界。
二、DDD 五个核心概念,一句话讲清楚
| 概念 | 一句话解释 | 类比 |
|---|---|---|
| 通用语言 | 业务说啥代码就叫啥 | 业务说"下单",方法就叫 placeOrder(),不叫 saveOrderData() |
| 限界上下文 | 划地盘,不同地盘里同一个词意思不一样 | 销售系统里的"商品"和物流系统里的"商品"是两个概念 |
| 聚合根 | 一组对象的"老大",对外是唯一入口 | Order类包含 List<OrderItem>,外部只能操作 Order,不能直接动 OrderItem |
| 领域服务 | 不属于任何对象的独立逻辑 | "汇率转换"不属于某个具体订单,单独放领域服务 |
| 仓储 | 假装自己有数据库,只定义接口 | 领域层定义 OrderRepository接口,基础设施层实现它 |
三、聚合根、实体、值对象、领域服务------四大概念的直觉
用一个生活场景建立直觉
想象你去机场办登机手续:
| DDD 概念 | 类比 | 特征 |
|---|---|---|
| 聚合根 | 一张机票订单 | 有唯一编号、是你和航空公司交互的入口、所有操作通过它 |
| 实体 | 订单里的每个乘客 | 有自己的身份证号(有标识),但不能脱离订单独立存在 |
| 值对象 | 乘客的行李额度 | 没有自己的 ID,只描述一个属性,20kg 就是 20kg |
| 领域服务 | 值机柜台的工作人员 | 协调多个对象完成一件事(验票+分配座位+打印登机牌) |
1. 聚合根(Aggregate)------ 团队的队长
Java
// 一个典型的订单聚合根
public class OrderAggregate extends BaseAggregate {
private String orderId; // 全局唯一标识
private CustomerEntity customer; // 客户实体
private List<OrderItemEntity> items; // 商品列表
private List<PaymentEntity> payments; // 支付记录
private DiscountValue discount; // 折扣值对象
}
关键特征:
| 特征 | 说明 | 体现 |
|---|---|---|
| 唯一入口 | 外部不能直接操作 OrderItem,必须通过 OrderAggregate | aggregate.addItem(item)而不是 items.add(item) |
| 事务边界 | 一个聚合根 = 一个事务单元 | repository.save(aggregate)整体保存 |
| 一致性保证 | 聚合根方法内部保证业务规则不被破坏 | "订单总价 = 各商品价格之和"这个规则在聚合根方法里保证 |
新人最容易踩的坑 :把聚合根当 POJO 用,到处 getXxx().setYyy()。这等于绕过了聚合根的所有业务规则,直接破坏一致性。
2. 实体(Entity)------ 有身份的成员
Java
public class OrderItemEntity extends BaseEntity {
private String itemId; // 局部 ID
private String productCode;
private Integer quantity;
private BigDecimal price;
}
和聚合根的区别:
- 聚合根有全局 ID,外部可以通过 ID 直接引用
- 实体有局部 ID,只在聚合根内部有意义
- 想操作
OrderItemEntity?必须先拿到OrderAggregate,再通过聚合根的方法操作
3. 值对象(Value Object)------ 可替换的属性描述
Java
public class DiscountValue extends BaseValue {
private BigDecimal amount; // 折扣金额
private String reason; // 折扣原因
private DiscountType type; // 折扣类型
}
和实体的核心区别:
| 维度 | 实体 | 值对象 |
|---|---|---|
| 有没有 ID | ✅ 有,通过 ID 区分 | ❌ 没有,通过值区分 |
| 能不能改 | ✅ 可以修改属性 | ❌ 理论上不可变 |
| 独立性 | 有自己的生命周期 | 依附于实体或聚合根 |
| 比喻 | 像一个人(即使长得一样也是不同的人) | 像一张 100 元钞票(不关心是哪一张) |
4. 领域服务(Domain Service)------ 协调多方的调度员
什么时候用聚合根方法,什么时候用领域服务?
| 场景 | 放在哪里 | 原因 |
|---|---|---|
| 计算订单总价 | aggregate.calculateTotal() |
只涉及聚合根内部状态 |
| 校验库存是否充足 | domainService.checkInventory() |
需要协调聚合根 + 仓储 + 外部系统 |
| 改变订单状态 | aggregate.confirmOrder() |
只涉及聚合根内部状态变更 |
| 退款流程 | domainService.refund() |
需要协调订单 + 支付 + 通知多个系统 |
判断标准:
- 能放聚合根就放聚合根(优先)
- 只有当操作需要跨对象协调 或调用仓储/适配器时,才放领域服务
四者关系图
PlainText
┌─────────────────────────────────────────────┐
│ OrderDomainService(领域服务) │
│ "我负责协调,但我不持有数据" │
│ refund() → 调用仓储 → 调用聚合根 → 调用支付 │
│ │ │
│ ▼ 操作 │
│ ┌────────────────────────────────────────┐ │
│ │ OrderAggregate(聚合根) │ │
│ │ "我是入口,所有操作通过我" │ │
│ │ confirmOrder() / addItem() / refund() │ │
│ │ │ │
│ │ ┌─────────┐ ┌──────────────────┐ │ │
│ │ │Customer │ │ List<OrderItem> │ │ │
│ │ │Entity │ │ Entity │ │ │
│ │ │(实体) │ │(实体) │ │ │
│ │ └─────────┘ └────┬─────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────▼──────────────┐ │ │
│ │ │ DiscountValue(值对象) │ │ │
│ │ │ 无 ID,描述属性,可替换 │ │ │
│ │ └────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
四、六边形架构:DDD 的标准落地姿势
DDD 不只是几个概念,它有一套完整的代码组织方式------六边形架构(Hexagonal Architecture,也叫端口和适配器架构)。
四层职责划分
PlainText
┌──────────────────────────────────────────────┐
│ Application 层(应用层 / 编排层) │
│ ⚙️ 流程编排,无业务逻辑 │
│ Scenario + BPM 工作流 │
└──────────────────────────────────────────────┘
↓ 调用
┌──────────────────────────────────────────────┐
│ Domain 层(领域层 / 业务核心) │
│ 💡 纯业务逻辑,零框架依赖 │
│ Aggregate + Entity + Value + DomainService │
└──────────────────────────────────────────────┘
↑ 实现(依赖倒置)
┌──────────────────────────────────────────────┐
│ Infrastructure 层(基础设施层 / 技术实现) │
│ 🔧 实现 Domain 层定义的 Repository 接口 │
│ 数据库、缓存、消息队列 │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Adaptor 层(适配器层 / 防腐层) │
│ 🛡️ 隔离外部系统,做数据内外转换 │
│ Input Adaptor + Output Adaptor │
└──────────────────────────────────────────────┘
各层职责详解
| 层级 | 职责 | 关键约束 |
|---|---|---|
| Application | 流程编排(先做 A,再做 B,最后做 C) | ❌ 禁止包含业务逻辑 ❌ 写服务禁止调用读服务 |
| Domain | 业务规则、状态变更、业务校验 | ❌ 禁止依赖任何技术框架 ❌ 领域服务禁止互相调用 |
| Infrastructure | 数据库、缓存、消息队列等技术实现 | 必须实现 Domain 层定义的接口 |
| Adaptor | 接收外部请求 / 调用外部系统 | Input + Output 两个方向 |
核心思想:依赖倒置
所有层都依赖 Domain 层,Domain 层不依赖任何层。这意味着:
- Domain 层定义
OrderRepository接口 - Infrastructure 层实现这个接口(用 MyBatis、JPA 都行)
- Domain 层只调用接口,不知道也不关心底层用了什么技术
好处:
- 可测试性 ------ Domain 层可以单元测试,不需要启动 Spring
- 可替换性 ------ MySQL 换 MongoDB,Domain 层一行代码不用改
- 关注点分离 ------ 业务逻辑和技术实现彻底解耦
五、四种 DDD 开发模式
实战中并不是所有业务都需要"完整 DDD",根据场景的复杂度可以选择不同模式:
| 模式 | 使用场景 | 是否变更状态 | 例子 |
|---|---|---|---|
| 写模式 | 创建订单、状态流转 | ✅ 通过聚合根方法 | 下单、支付确认、取消订单 |
| 读模式 | 查询 | ❌ | 查订单详情、列表查询 |
| 规则+计算模式 | 带聚合根的规则匹配 | ❌(纯计算) | 定价规则匹配 |
| 纯计算模式 | 无聚合根的无状态计算 | ❌ | 汇率换算、税费计算 |
写模式标准流程(最重要!)
PlainText
1. 获取分布式锁(防止并发修改)
↓
2. 通过仓储加载聚合根
↓
3. 调用聚合根方法执行业务逻辑(状态变更)
↓
4. 如需调用外部服务,在状态变更之后
↓
5. 通过仓储持久化(repository.save(aggregate))
↓
6. finally 块中释放锁
为什么要这个流程?
- 加锁 ------ 防止两个请求同时修改同一个订单(经典超卖问题)
- 聚合根方法变更状态 ------ 保证业务规则不被破坏
- 外部调用在变更后 ------ 避免外部成功但本地失败的不一致
- finally 释放锁 ------ 防止异常导致死锁
六、应用层的灵魂:Scenario + BPM 工作流模式
这是我接触新项目时受冲击最大的部分。之前在 ERP 项目里,业务流程都是写在 Service 里的几百行 if-else,而新项目是这样组织的:
传统 Service 写法(反例)
Java
public class OrderService {
public void placeOrder(OrderDTO dto) {
// 第 1 步:参数校验
if (dto == null) throw new Exception();
// 第 2 步:查库存
Inventory inv = inventoryService.query(...);
if (inv.getStock() < dto.getQuantity()) {
throw new Exception("库存不足");
}
// 第 3 步:扣库存
inventoryService.deduct(...);
// 第 4 步:创建订单
Order order = new Order(...);
orderDao.insert(order);
// 第 5 步:发消息
mq.send(...);
// ... 200 行后 ...
}
}
问题:
- 一个方法做了 N 件事,无法局部修改
- 异常处理散落各处
- 想加一个新步骤要改整个方法
- 流程不可视化
Scenario + Activity 写法(DDD 推荐)
每个业务场景拆成一个 Scenario,每个 Scenario 由若干 Activity(步骤)组成,用 BPM 工作流引擎按顺序调度:
Java
// 极其简洁的 Scenario 定义
@Component
public class PlaceOrderScenario extends BaseScenario<PlaceOrderContext> {
@Override
protected String scenarioName() {
return "placeOrderScenario";
}
@Override
protected String standardProcessName() {
return "bpm.PlaceOrderScenario"; // 指向 BPM 流程定义(XML)
}
}
真正的流程在 BPM XML 里定义:
XML
<process name="placeOrderScenario">
<activity name="ValidateActivity"/>
<activity name="CheckInventoryActivity"/>
<activity name="DeductInventoryActivity"/>
<activity name="CreateOrderActivity"/>
<activity name="SendMessageActivity"/>
</process>
每个 Activity 长这样(只有一件事):
Java
@Component
public class CheckInventoryActivity extends BaseActivity<Void, PlaceOrderContext> {
private final InventoryDomainService inventoryDomainService;
@Override
public Void process(PlaceOrderContext context) {
// 1. 从 Context 取数据
OrderAggregate aggregate = context.getOrderAggregate();
// 2. 调用领域服务(核心逻辑就这一行)
inventoryDomainService.checkInventory(aggregate);
// 3. 把结果放回 Context(如果有)
return null;
}
}
这样写的好处
- 流程可视化 ------ BPM XML 一眼看清整个业务流程
- 职责单一 ------ 每个 Activity 只做一件事
- 易于扩展 ------ 加步骤只需新增 Activity + 改 XML
- 复用性强 ------
ValidateActivity可以被多个 Scenario 复用 - 支持分支 ------ BPM 支持条件分支、循环、异常处理
Context:流水线上的"托盘"
Activity 之间通过 Context 传递数据。Context 就像流水线上的托盘:
PlainText
PlaceOrderContext(托盘)
├── OrderDTO ← 入参
├── OrderAggregate ← 中间数据(聚合根,多步共享)
└── OrderResultDTO ← 出参
关键设计:
- 第一个 Activity 把聚合根放进 Context
- 中间的 Activity 直接修改聚合根(Java 引用传递)
- 最后一个 Activity 从 Context 取出聚合根持久化
七、防腐层(Anti-Corruption Layer):隔离外部系统
复杂业务系统往往要对接 5-10 个外部系统(支付、库存、风控、物流......)。每个外部系统都有自己的 DTO 格式、协议、错误码。
如果直接在业务代码里调用,会发生什么?
- 外部系统 DTO 改字段 → 业务代码到处改
- 外部系统宕机 → 业务代码到处加 try-catch
- 业务想换一个供应商 → 业务代码大改
防腐层的作用就是隔离这些外部系统的复杂性,确保业务代码不被"污染"。
两层模式:Proxy + AdaptorImpl
PlainText
业务代码
↓ 调用 Domain 层定义的接口(如 PaymentAdaptor)
↓
AdaptorImpl(适配器实现)
├── Converter:领域对象 ↔ 外部 DTO 转换
└── 调用 Proxy
↓
Proxy(外部 RPC 薄封装)
├── 监控埋点(成功率、RT)
├── 日志记录
└── 真正的 RPC 调用
↓
外部系统
Proxy 层职责 :薄封装、监控、日志
AdaptorImpl 层职责:数据转换、组合多个 Proxy
比喻:你可以把 Adaptor 模块想象成一个**"海关"**------
- Input 是入境大厅,检查进来的旅客(请求)是否合规
- Output 是出境大厅,把内部货物(领域对象)打包成符合国际标准的集装箱(外部 DTO)
- Converter 是翻译中心,确保内外语言互通
八、一次完整的业务调用链路
把上面所有概念串起来,一次"用户下单"的完整流转是这样的:
PlainText
用户点击"立即下单"
↓ HSF/HTTP 调用
┌─────────────────────────────────────────────┐
│ ① Adaptor 层(Input) │
│ 接收外部请求,DTO → Context │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ② Application 层 │
│ PlaceOrderScenario.startScenario() │
│ ├── 获取分布式锁 │
│ ├── 启动 BPM 工作流 │
│ │ ├── ValidateActivity │
│ │ ├── CheckInventoryActivity │
│ │ ├── DeductInventoryActivity │
│ │ ├── CreateOrderActivity │
│ │ └── SendMessageActivity │
│ └── 释放锁 │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ③ Domain 层 │
│ OrderDomainService.placeOrder() │
│ ├── 调用聚合根方法(业务规则) │
│ └── 调用 Repository 接口(持久化) │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ④ Infrastructure 层 │
│ OrderRepositoryImpl.save() │
│ ├── MyBatis 写数据库 │
│ ├── Redis 缓存 │
│ └── 发布领域事件 │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ⑤ Adaptor 层(Output) │
│ PaymentAdaptorImpl.deduct() │
│ ├── Converter 转换领域对象 → 外部 DTO │
│ └── Proxy 调用支付系统 │
└─────────────────────────────────────────────┘
↓
外部支付系统
↓
原路返回
↓
用户看到"下单成功"
数据在每一层换"衣服"
这是新人最容易混淆的点------同一份数据在不同层有不同的格式:
| 数据格式 | 所在层 | 转换器 |
|---|---|---|
| 外部 DTO | Client | - |
| Context | Application | Assembler |
| Aggregate / Entity / Value | Domain | - |
| Param / Result | Domain ↔ Infrastructure | - |
| 外部 Request / Response | Adaptor (Output) | Converter |
| PO(数据库对象) | Infrastructure | Convert |
口诀:每一层只认自己的格式,通过转换器沟通,所以改一层不会影响其他层。
九、命名规范:让代码自己说话
DDD 的命名是强约束,通过后缀就能识别组件类型:
| 类型 | 后缀 | 继承 | 例子 |
|---|---|---|---|
| 聚合根 | 名词 + Aggregate |
BaseAggregate |
OrderAggregate |
| 实体 | 名词 + Entity |
BaseEntity |
OrderItemEntity |
| 值对象 | 名词 + Value |
BaseValue |
DiscountValue |
| 参数对象 | 方法名 + Param |
BaseParam |
PlaceOrderParam |
| 结果对象 | 方法名 + Result |
BaseResult |
PlaceOrderResult |
| 领域服务 | 名词 + DomainService |
- | OrderDomainService |
| 仓储接口 | 名词 + Repository |
- | OrderRepository |
| 仓储实现 | 名词 + RepositoryImpl |
- | OrderRepositoryImpl |
| 适配器 | 名词 + Adaptor |
- | PaymentAdaptor |
| 写应用服务 | 名词 + AppService |
ApplicationCmdService |
OrderAppService |
| 读应用服务 | 名词 + QueryAppService |
ApplicationQueryService |
OrderQueryAppService |
| 组装器 | 功能 + Assembler |
- | OrderAssembler |
聚合根方法命名:必须用业务动词
Java
// ❌ 反例
order.save();
order.update();
order.process();
// ✅ 正例
order.confirmPayment();
order.cancel();
order.addItem();
order.applyDiscount();
为什么?
save/update/process是技术词汇,毫无业务含义- 业务动词让代码自己讲业务故事
- 业务方和开发能用同一套语言沟通(通用语言)
十、DDD 不是银弹:什么场景适合 DDD?
最后必须泼一盆冷水------DDD 不是万能的,它有明显的适用边界:
适合 DDD 的场景
- 业务复杂度高(电商、金融、出行、供应链)
- 业务规则需要长期演进
- 多人协作的大型项目
- 需要和业务方紧密沟通
不适合 DDD 的场景
- 简单 CRUD 项目(后台管理系统、小工具)
- 业务规则极少(数据展示类应用)
- 小团队、短生命周期项目
- 强调极致性能的底层组件
强行上 DDD 的代价:
- 类的数量爆炸(一个简单功能可能涉及 10+ 个类)
- 学习曲线陡峭(新人要 1-3 个月才能上手)
- 简单需求开发慢(写 5 个类来完成一个 CRUD)
所以:是否用 DDD,要看业务复杂度,不要为了 DDD 而 DDD。
十一、给新人的学习建议
如果你和我一样,是从传统 CRUD 开发转 DDD,给你几条踩坑总结:
1. 先读项目,再读理论
DDD 的书(如《领域驱动设计》《实现领域驱动设计》)非常厚,纯看理论会劝退。先扎进一个真实的 DDD 项目,对照代码看理论,理解会快 10 倍。
2. 从聚合根开始读
找到项目里的核心聚合根(通常名字叫 XxxAggregate 且体量最大),把它的所有方法过一遍。聚合根的方法就是业务的全貌。
3. 跟着 Scenario 走一遍流程
找一个核心业务场景(比如下单),从 AppService 入口开始,一路追到 Activity → DomainService → Aggregate → Repository。走通一个完整链路,整个架构就活了。
4. 不要害怕"过度设计"的感觉
你会发现 DDD 项目里很多类只有 5-10 行代码,会觉得"为什么要拆这么细"。这种"细"是为了未来 3 年的可维护性买的保险,初看冗余,长期受益。
5. 学习业务比学习技术更重要
DDD 的核心是"领域",不是"驱动"也不是"设计"。你越懂业务,DDD 用得越好。所以遇到不懂的业务术语,别绕过去,问业务方或老同事。
写在最后
我转型路上一直在思考一个问题:为什么同样是写 Java,外包公司的代码和大厂的代码差距那么大?
接触 DDD 之后我有了答案------差距不在技术栈(都是 Spring + MyBatis),差距在于代码组织能力。
CRUD 写法的代码,所有人都能写,所以也就没有壁垒。而 DDD 写法的代码,需要你既懂业务又懂架构,这就形成了护城河。
如果你也是从 CRUD 转型的开发者,我的建议是:别只学 Redis、RocketMQ、SpringCloud 这些"工具",多花时间学 DDD、整洁架构、领域建模这些"思想" 。前者会被 AI 替代,后者不会。