六边形架构设计
# 02.六边形架构设计
# 目录介绍
# 1. 案例引入
# 1.1 一次换 MQ 引发的全栈瘫痪
先看一段在真实项目里跑了三年的"分层很标准"的代码,三层架构、Service 编排、MyBatis 持久化、RocketMQ 异步——主流的不能再主流。直到运维提出"RocketMQ 换 Kafka,下个季度完成",团队开始评估改造范围,评估结果是 4 人月、波及 87 个文件。一个基础设施替换,居然要动 87 个业务文件。
// OrderService.java —— "标准的"四层 Service
@Service
public class OrderService {
@Autowired private OrderMapper orderMapper;
@Autowired private InventoryMapper inventoryMapper;
@Autowired private DefaultMQProducer mqProducer; // ← RocketMQ 客户端
@Autowired private RedisTemplate<String, String> redis; // ← Redis 客户端
@Autowired private OkHttpClient httpClient; // ← 支付 HTTP 客户端
@Transactional
public Long createOrder(CreateOrderReq req) {
// 1. 业务校验
if (req.getAmount() <= 0) throw new RuntimeException("金额非法");
// 2. 扣库存
for (OrderItem it : req.getItems()) {
int n = inventoryMapper.deduct(it.getSkuId(), it.getQty());
if (n == 0) throw new RuntimeException("库存不足");
}
// 3. 落单
OrderPO po = new OrderPO();
po.setUserId(req.getUserId());
po.setAmount(req.getAmount());
po.setStatus(1);
orderMapper.insert(po);
// 4. 写 Redis 缓存(业务里直接用 RedisTemplate)
redis.opsForValue().set("order:" + po.getId(), JSON.toJSONString(po), 1, HOURS);
// 5. 发 MQ(业务里直接用 RocketMQ Producer)
Message msg = new Message("order_created", JSON.toJSONString(po).getBytes());
try {
mqProducer.send(msg);
} catch (Exception e) {
throw new RuntimeException("MQ 发送失败");
}
// 6. 调支付(业务里直接用 OkHttp)
Request httpReq = new Request.Builder()
.url("https://pay.example.com/prepay")
.post(RequestBody.create(JSON.toJSONString(po), MediaType.parse("application/json")))
.build();
try (Response resp = httpClient.newCall(httpReq).execute()) {
if (!resp.isSuccessful()) throw new RuntimeException("支付预下单失败");
} catch (IOException e) {
throw new RuntimeException(e);
}
return po.getId();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
现象:
- 测试环境(用单元测试 mock OkHttp、mock RocketMQ):全部通过
- 评估"把 RocketMQ 换 Kafka"——发现:
- 业务 Service 里直接
import org.apache.rocketmq.client.producer.*出现在 40+ 个文件 - 异常类型
MQClientException散落在 60+ 个catch里 - 消息序列化方式硬编码在每个 Service 里
- 单元测试要重新写一遍(Mock 对象类型全变)
- 业务 Service 里直接
- 又有人说"Redis 顺便换成 Hazelcast"——直接劝退
- 还有人说"支付改成走内部 SDK,不用 HTTP 了"——直接掀桌
直觉怀疑:是不是工程师水平不行?拉过来一看,每个人写的都"很规范"——@Autowired 注入、@Transactional 事务、有日志、有异常处理。问题不在"代码烂",问题在架构没让"业务"和"技术"独立。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:换 MQ 为什么会动业务代码?——因为业务代码里直接 import 了 MQ 客户端类
- 假设 2:为什么要 import?——因为 Service 直接用
DefaultMQProducer,没有抽象一层"消息发送"接口 - 假设 3:为什么不抽接口?——因为"业务方便",加抽象觉得"啰嗦"
- 假设 4:业务测试为什么那么难?——因为测试要 mock OkHttp、mock RocketMQ、mock RedisTemplate 一大堆框架 API
- 假设 5:业务规则去哪了?——藏在
if (req.getAmount() <= 0)这种零散判断里,没有 Domain 对象,业务知识被淹没在框架调用之间 - 假设 6:能不能不依赖任何框架就单测一遍业务规则?——几乎不可能,因为 Service 离开 Spring/MyBatis/RocketMQ 编译不过
- 假设 7:那这套架构到底"分层"在哪?——只分了"代码目录",没分"依赖方向",业务和技术耦死在同一坨字节码里
看似"标准三层"的代码,毛病不在算法,毛病在没有意识到"业务核心应该独立于任何技术"——这条代码碰到的不是 MQ 的坑,是架构层面"依赖方向倒置" 没有做的坑。
这一段事故里至少藏着 7 个原理点:
① 业务 Service 凭什么能直接 import MQ/Redis/HTTP 客户端? → 第 3 章 端口与适配器
② "技术替换"为什么会引发"业务文件大改"? → 第 4 章 依赖倒置原则
③ 业务规则散落在 if 里,没有 Domain 模型——怎么改? → 第 5 章 领域核心建模
④ 工程目录里哪里放 Domain、哪里放 Adapter、哪里放 Port? → 第 6 章 工程结构分解
⑤ Controller、@KafkaListener、@Scheduled 三个入口都要重写吗? → 第 7 章 适配器实战写法
⑥ 测试为什么要 mock 一堆框架对象? → 第 8 章 可测试性飞跃
⑦ 从现状到六边形怎么平滑迁移? → 第 9 章 演进实战剖析
2
3
4
5
6
7
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出三种迁移路径与各自的代价。
本篇路线:
六边形总图 (第 2 章)
↓
端口与适配器 (第 3 章) ─→ 解开"业务和技术怎么解耦"
↓
依赖倒置 (第 4 章) ─→ 解开"为什么外圈依赖内圈"
↓
领域核心 (第 5 章) ─→ 解开"业务知识住在哪"
↓
工程结构 → 适配器写法 (第 6-7 章) ─→ 落地骨架
↓
可测试性 → 演进 (第 8-9 章) ─→ 红利与迁移
↓
综合案例 (第 10 章) ─→ 案例彻底剖开
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:这是架构系列的核心模式篇。前一篇
01.分层架构设计详解解决"代码怎么切层",本篇升级为"怎么让业务核心彻底独立于任何技术框架"——这是后续 CQRS、事件驱动、DDD 战略设计的共同底座。读完本篇,再看任何一个"架构升级"话题,都能立刻回答:"Domain 还是不依赖框架吗?"
# 2. 架构概览
# 2.1 六边形总图
Alistair Cockburn 在 2005 年提出 Hexagonal Architecture(六边形架构,又叫 Ports & Adapters)——把"业务核心"画在中间六边形,所有"外部世界"通过端口与适配器与之对话:
┌──── HTTP Controller ───┐
│ (入站适配器) │
▼ ▲
┌───────────────────────────────────────┐
│ │
│ 入站端口 (Use Case 接口) │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ Application Service │ │
入──► │ │ (用例编排) │ │ ──► 入
站 │ │ ┌─────────────────────────┐ │ │ 站
适 │ │ │ │ │ │ 适
配 │ │ │ Domain Core │ │ │ 配
器 │ │ │ (实体、值对象、规则) │ │ │ 器
│ │ │ │ │ │
│ │ └─────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ 出站端口 (Repository / Gateway 接口) │
│ │
└───────────────────────────────────────┘
▲ ▼
│ (出站适配器) │
└─── MySQL / Kafka / RPC ─┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
三层同心结构:
| 圈层 | 关键词 | 包含内容 | 依赖方向 |
|---|---|---|---|
| 最内:Domain | 业务知识 | 实体、值对象、领域服务、领域事件 | 不依赖任何外部 |
| 中间:Application | 用例编排 | ApplicationService、Port 接口 | 只依赖 Domain |
| 最外:Adapter | 技术细节 | HTTP/MQ/DB/RPC 框架代码 | 依赖 Application 与 Domain |
关键铁律:依赖箭头永远从外圈指向内圈,内圈不知道外圈的存在。
# 2.2 为什么是六边形
疑惑:为什么画成六边形,不画成圆形或方形?
论证:
- 形状本身没有特别含义——Cockburn 自己也说:"画几边都行,六边只是因为画起来好分边"。
- 六条边象征"一个业务核心要面对多种外部世界"——HTTP / RPC / MQ / 定时器 / CLI / 测试桩。如果画成方形会让人误以为"只有 4 类外部"。
- 不是"层",是"内外"——传统分层是上下叠的,六边形是同心的。这是核心范式区别:分层强调"调用顺序",六边形强调"依赖方向"。
- 内圈是业务,外圈是技术,业务变化频率与技术变化频率天然不同——业务三天一变、技术三年一换,把它们用"边界"隔开是必然选择。
- 反向验证:如果没有六边形,业务直接耦合具体技术会怎样?回看第 1 章案例——换个 MQ 动 87 个业务文件,这就是耦合的代价。
结论:六边形是把"业务"和"基础设施"在编译期就强制分离的架构模式。它不是花架子,是真能让"换 DB 不动业务、换 MQ 不动业务、换协议不动业务"的工程化方案。
# 2.3 与分层架构对比
| 维度 | 经典四层 | 六边形 |
|---|---|---|
| 结构方式 | 上下叠 | 同心圆 |
| 依赖方向 | 自上而下 | 由外向内 |
| Domain 位置 | 中间一层 | 最内核心 |
| Repository | 在 Infrastructure | 接口在 Domain,实现在 Adapter |
| 框架依赖 | 各层都可能依赖 | 只允许外圈依赖 |
| 测试难度 | Service 单测要 Mock 一堆 | Domain 纯 JUnit、Application 只 Mock 端口 |
| 技术替换 | 牵动业务 | 只换 Adapter |
| 学习曲线 | 低 | 中(需理解依赖倒置) |
演进关系:
三层架构 ──► 四层架构(加 Domain) ──► 六边形(端口适配器) ──► 洋葱/整洁架构
↑ ↑ ↑ ↑
直觉 加领域 倒依赖 多同心圈
2
3
分层架构走到极致的下一步就是六边形——把 "Controller / Repository" 看成"入站/出站适配器",把"Service 接口"看成"端口",思路立刻通了。
# 3. 端口与适配器
# 3.1 端口是接口契约
端口(Port)——在六边形里就是一个纯 Java 接口,写在 Domain/Application 模块里,不包含任何框架注解、不引用任何外部类型。
// domain/port/out/OrderRepository.java —— 出站端口
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
List<Order> findByCustomer(CustomerId customerId);
}
// domain/port/out/PaymentGateway.java —— 出站端口
public interface PaymentGateway {
PaymentResult prepay(Order order);
}
// domain/port/out/DomainEventPublisher.java —— 出站端口
public interface DomainEventPublisher {
void publish(DomainEvent event);
}
// application/port/in/PlaceOrderUseCase.java —— 入站端口
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand cmd);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
注意四个端口没有任何:
@Repository/@Component(Spring)Page<T>/Pageable(Spring Data)@Mapper(MyBatis)Message/Topic(RocketMQ/Kafka)
——端口是纯净的业务契约。把它编译成 .class 扔到任何项目都能跑(只需 JDK),这是六边形"业务独立于框架"的硬指标。
# 3.2 入站端口与出站端口
端口分两种,方向相反:
┌───────────────────────────────┐
│ │
│ Application + Domain │
│ │
入站请求 ──►│ 入站端口(UseCase) │ 出站端口(Repository/Gateway) ──► 外部资源
│ 由外部调用 → 实现在内 │ 由内部调用 → 实现在外
│ │
└───────────────────────────────┘
2
3
4
5
6
7
8
| 维度 | 入站端口 (Driving Port) | 出站端口 (Driven Port) |
|---|---|---|
| 命名 | XxxUseCase / XxxAppService | XxxRepository / XxxGateway |
| 实现位置 | Application 模块内(*AppServiceImpl) | 外圈 Adapter 模块(*RepositoryImpl) |
| 调用方向 | Adapter → 调用接口 → 进入业务 | 业务 → 调用接口 → 调用 Adapter |
| 依赖关系 | Adapter 依赖端口 | Adapter 依赖端口 |
| 例子 | PlaceOrderUseCase | OrderRepository、PaymentGateway |
关键认知:两种端口的实现都在外圈,但调用方向相反——所以"出站端口"才是六边形最独特的设计,它让 Domain 反过来定义"我需要外部给我什么能力",由外部去满足——这就是依赖倒置。
# 3.3 适配器是技术实现
适配器(Adapter)——具体的"技术翻译器",把"外部世界的请求/响应"翻译成"端口接口的调用"。
// adapter/in/web/OrderController.java —— 入站适配器:HTTP → UseCase
@RestController
@RequiredArgsConstructor
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase; // ← 依赖入站端口
@PostMapping("/api/v1/orders")
public Response<OrderVO> create(@RequestBody @Valid CreateOrderReq req) {
PlaceOrderCommand cmd = OrderWebMapper.toCommand(req);
OrderId id = placeOrderUseCase.placeOrder(cmd);
return Response.ok(OrderVO.builder().id(id.value()).build());
}
}
// adapter/out/persistence/OrderRepositoryImpl.java —— 出站适配器:业务 → MySQL
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepository { // ← 实现出站端口
private final OrderMapper orderMapper;
private final OrderPoConverter converter;
@Override
public Optional<Order> findById(OrderId id) {
OrderPO po = orderMapper.selectById(id.value());
return Optional.ofNullable(po).map(converter::toDomain);
}
@Override
public void save(Order order) {
OrderPO po = converter.toPO(order);
if (po.getId() == null) orderMapper.insert(po);
else orderMapper.updateById(po);
}
@Override
public List<Order> findByCustomer(CustomerId customerId) {
return orderMapper.selectByUserId(customerId.value())
.stream().map(converter::toDomain).toList();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
适配器特点:
- 充满框架代码:
@RestController、@Repository、@RequestBody、Mapper、Converter——这都是好事,外圈本来就该承担"技术细节" - 薄而无业务:只做"协议解析 + 类型转换 + 调用端口"三件事
- 可替换:今天用 MyBatis,明天换 JPA、Mongo,只要新写一个
OrderRepositoryImpl即可,Domain 一行不改
# 3.4 一个端口多个适配器
疑惑:为什么要把"端口"和"适配器"分开?
论证:因为一个端口可以有多个适配器——这是六边形最大的红利。
┌─ HttpAdapter (REST API)
│
PlaceOrderUseCase ──┼─ GrpcAdapter (内部 RPC)
(入站端口) │
├─ KafkaAdapter (消息触发)
│
├─ ScheduledAdapter (定时跑批)
│
└─ TestAdapter (单元测试驱动)
┌─ MySqlRepositoryImpl (生产)
│
OrderRepository ──┼─ InMemoryRepositoryImpl (单元测试)
(出站端口) │
├─ RedisRepositoryImpl (高性能场景)
│
└─ MongoRepositoryImpl (历史归档)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
实战例子——同一个"下单"业务,要支持四种入口:
// 1. HTTP 入口
@PostMapping("/orders")
public Response create(@RequestBody CreateOrderReq req) {
return Response.ok(placeOrderUseCase.placeOrder(toCommand(req)));
}
// 2. 内部 RPC 入口(Dubbo / gRPC)
@DubboService
public class OrderRpcService implements OrderRpcApi {
public Long placeOrder(OrderRpcReq req) {
return placeOrderUseCase.placeOrder(toCommand(req)).value();
}
}
// 3. MQ 入口(异步下单场景)
@KafkaListener(topics = "place_order_cmd")
public void onMessage(String json) {
PlaceOrderCommand cmd = JSON.parseObject(json, PlaceOrderCommand.class);
placeOrderUseCase.placeOrder(cmd);
}
// 4. 定时任务入口(补单)
@Scheduled(cron = "0 0 * * * *")
public void retryFailed() {
failedOrderRepository.findAll().forEach(o ->
placeOrderUseCase.placeOrder(o.toCommand()));
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
四个适配器复用同一个 PlaceOrderUseCase,业务规则只写一遍。这就是"端口与适配器分离"换来的复用——分层架构很难做到这么干净(典型做法是 4 个 Controller 各调一遍 Service,业务规则反复散落)。
# 4. 依赖倒置原则
# 4.1 依赖方向从外指向内
核心铁律:
依赖方向:永远从 外圈 → 内圈
代码可见性:内圈 看不见 外圈
Adapter (外) ──依赖──► Application (中) ──依赖──► Domain (内)
↑ ↑
│ │
可见外圈 可见 Application + Domain
2
3
4
5
6
7
具体到代码:
| 模块 | 可以 import | 禁止 import |
|---|---|---|
domain | 只能 import JDK / 三方纯工具(Guava 等) | Spring、MyBatis、RocketMQ、HTTP 客户端 |
application | domain 的所有 + JDK | Spring 业务注解(少量 @Service 可妥协)、MyBatis、HTTP |
adapter-web | application 入站端口 + Spring MVC + DTO 转换 | 直接 import domain 内部实体的字段 |
adapter-persistence | domain 出站端口 + MyBatis/JPA + Converter | 业务规则 |
# 4.2 Domain 不依赖任何框架
这是检验"六边形落地是否合格"的金标准——把 domain 模块单独抽出来,只引 JDK 就能编译通过。
<!-- domain/pom.xml -->
<project>
<artifactId>order-domain</artifactId>
<dependencies>
<!-- 只允许 JDK + 极少纯净工具 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- ❌ 严禁 spring-boot-starter-* -->
<!-- ❌ 严禁 mybatis-* -->
<!-- ❌ 严禁 rocketmq-* / kafka-* -->
<!-- ❌ 严禁 okhttp / feign -->
</dependencies>
</project>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
它能带来什么?
- 业务规则可以脱离 Spring 跑测试——纯 JUnit,毫秒级
- 业务规则可以被多个 application 复用——同一份 Domain 在"Web 应用"、"批处理"、"CLI 工具"里都跑
- 业务规则可以被静态分析工具扫描——SonarQube 复杂度报告里"业务复杂度"和"技术复杂度"自然分开
- 业务规则演进不受技术演进牵制——Spring 6 升级、JDK 21 适配、MyBatis 替换 JPA,Domain 一行不改
# 4.3 反例:直接耦合的代价
回到第 1 章那个 OrderService.createOrder,把它放到六边形视角下看一下"病灶分布":
@Service
public class OrderService {
@Autowired private OrderMapper orderMapper; // ⚠️ 业务直接耦合 MyBatis
@Autowired private DefaultMQProducer mqProducer; // ⚠️ 业务直接耦合 RocketMQ
@Autowired private RedisTemplate<...> redis; // ⚠️ 业务直接耦合 Redis
@Autowired private OkHttpClient httpClient; // ⚠️ 业务直接耦合 OkHttp
@Transactional
public Long createOrder(CreateOrderReq req) {
if (req.getAmount() <= 0) throw ...; // ⚠️ 业务规则散落
inventoryMapper.deduct(...); // ⚠️ 直接调 Mapper
orderMapper.insert(po); // ⚠️ 直接操作 PO
redis.opsForValue().set(...); // ⚠️ 业务里写缓存
mqProducer.send(msg); // ⚠️ 业务里发 MQ
httpClient.newCall(httpReq).execute(); // ⚠️ 业务里发 HTTP
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
6 处违反依赖倒置 = 6 把锁,每把都把"业务"和"某个具体技术"焊死,任何技术替换都要全文搜索改业务。
六边形修复:
// 1. 端口(在 application 模块)
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand cmd);
}
public interface OrderRepository { ... }
public interface InventoryRepository { ... }
public interface PaymentGateway { ... }
public interface OrderCache { ... }
public interface DomainEventPublisher { ... }
// 2. 用例实现(在 application 模块,只依赖端口)
@RequiredArgsConstructor
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orderRepository;
private final InventoryRepository inventoryRepository;
private final PaymentGateway paymentGateway;
private final OrderCache orderCache;
private final DomainEventPublisher publisher;
@Override
@Transactional
public OrderId placeOrder(PlaceOrderCommand cmd) {
Order order = Order.create(cmd.customerId(), cmd.items()); // ← 领域工厂
inventoryRepository.deductAll(order.items());
orderRepository.save(order);
orderCache.put(order);
paymentGateway.prepay(order);
publisher.publish(new OrderPlaced(order.id()));
return order.id();
}
}
// 3. 适配器(在外圈不同模块)
// adapter-persistence-mysql: OrderRepositoryImpl + InventoryRepositoryImpl
// adapter-cache-redis: OrderCacheImpl
// adapter-mq-rocketmq: DomainEventPublisherImpl(基于 RocketMQ)
// adapter-payment-http: PaymentGatewayImpl(基于 OkHttp)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
换 MQ 的成本:只改 adapter-mq-rocketmq 这一个模块,业务零改动。从 87 个文件 → 1 个模块,这就是六边形换来的"基础设施替换价格"。
# 4.4 控制反转的工程落地
依赖倒置在工程上靠 IoC 容器(Spring)实现:
// Spring 配置:在 adapter 模块声明 Bean
@Configuration
public class PersistenceConfig {
@Bean
public OrderRepository orderRepository(OrderMapper mapper, OrderPoConverter conv) {
return new OrderRepositoryImpl(mapper, conv);
}
}
@Configuration
public class MessagingConfig {
@Bean
public DomainEventPublisher domainEventPublisher(KafkaTemplate<String, String> kafka) {
return new KafkaEventPublisher(kafka);
}
}
// Application 用例里只声明依赖端口,不关心实现
@Service
@RequiredArgsConstructor
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orderRepository; // ← Spring 注入 OrderRepositoryImpl
private final DomainEventPublisher publisher; // ← Spring 注入 KafkaEventPublisher
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
关键点:
- 端口(接口)和实现(Impl)在不同 Maven 模块——这是物理强约束
- Spring 帮我们把"接口 → 实现"在运行时装配起来——这是"控制反转(IoC)"的字面含义
- 运行时换实现只需改
@Configuration一处——这就是六边形"可插拔"的核心机制
💡 常见疑问:六边形要不要 Spring?——不强制。Domain/Application 不依赖 Spring,纯手工
new XxxServiceImpl(...)也能装起来;只是 Spring 让"装配"变得方便。六边形是设计模式,Spring 是工程工具,二者正交。
# 5. 领域核心建模
# 5.1 实体与值对象
进入最内圈 domain,开始建模业务知识。两类核心对象:
| 类型 | 标识 | 可变性 | 例子 |
|---|---|---|---|
| Entity 实体 | 有唯一标识 ID | 可变(生命周期内属性变化) | Order、Customer |
| ValueObject 值对象 | 无 ID,等值即等同 | 不可变(任何修改 = 新对象) | Money、Address、OrderId |
// 值对象:Money(典型示例)
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("精度超过币种规定");
}
}
public Money add(Money other) {
ensureSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int times) {
return new Money(this.amount.multiply(BigDecimal.valueOf(times)), this.currency);
}
private void ensureSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("不同币种不能直接运算");
}
}
}
// 值对象:OrderId(强类型 ID,杜绝 Long 满天飞)
public record OrderId(long value) {
public OrderId {
if (value <= 0) throw new IllegalArgumentException("OrderId 必须 > 0");
}
}
// 实体:Order
public class Order {
private final OrderId id; // 唯一标识
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status; // 可变
private Money totalAmount; // 可变
// ... 业务方法
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
值对象的两大红利:
- 类型表达业务——
Money add(Money m)比BigDecimal add(BigDecimal)多了"币种校验"的语义 - 不可变 = 线程安全 = 无副作用——传值给任何函数都不用担心被改坏
# 5.2 聚合根与不变量
聚合(Aggregate)——一组关系紧密、必须一起一致变化的对象的集合。聚合根(Aggregate Root)是这个集合的唯一入口,所有外部访问只能通过它。
public class Order { // ← Aggregate Root
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items; // ← 内部实体,不允许外部直接拿
private OrderStatus status;
private Money totalAmount;
private final List<DomainEvent> events = new ArrayList<>();
// ─── 工厂方法:所有创建路径都走这里 ───
public static Order create(CustomerId customerId, List<OrderItemCommand> items) {
if (items == null || items.isEmpty()) {
throw new BizException(ErrorCode.ORDER_ITEMS_EMPTY);
}
Order order = new Order(OrderId.next(), customerId);
items.forEach(it -> order.addItem(it.skuId(), it.qty(), it.unitPrice()));
order.events.add(new OrderCreated(order.id));
return order;
}
// ─── 业务行为:守护不变量 ───
public void cancel(String reason) {
if (status != OrderStatus.CREATED && status != OrderStatus.PAID) {
throw new BizException(ErrorCode.ORDER_CANNOT_CANCEL, status);
}
if (StringUtils.isBlank(reason)) {
throw new BizException(ErrorCode.CANCEL_REASON_REQUIRED);
}
this.status = OrderStatus.CANCELLED;
events.add(new OrderCancelled(id, reason));
}
public void pay(Money paid) {
if (status != OrderStatus.CREATED) {
throw new BizException(ErrorCode.ORDER_STATUS_INVALID);
}
if (!paid.equals(totalAmount)) {
throw new BizException(ErrorCode.PAY_AMOUNT_MISMATCH);
}
this.status = OrderStatus.PAID;
events.add(new OrderPaid(id, paid));
}
// ─── 外部只读视图 ───
public List<OrderItem> items() { return List.copyOf(items); }
public List<DomainEvent> pullEvents() {
List<DomainEvent> copy = List.copyOf(events);
events.clear();
return copy;
}
// ─── 私有 add:杜绝外部跳过校验加 item ───
private void addItem(SkuId skuId, int qty, Money unitPrice) {
if (qty <= 0) throw new BizException(ErrorCode.QTY_INVALID);
items.add(new OrderItem(skuId, qty, unitPrice));
this.totalAmount = items.stream()
.map(OrderItem::subtotal)
.reduce(Money.ZERO_CNY, Money::add);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
关键约束:
| 规则 | 原因 |
|---|---|
外部不能 order.getItems().add(...) | 直接改集合绕开了 totalAmount 重算 |
外部不能 order.setStatus(...) | 状态转换必须走业务方法,遵守状态机 |
| 状态机由方法守护 | cancel()、pay()、ship() 各自检查前置状态 |
| 不变量集中在 Aggregate Root | 一处规则,所有调用方都不可能绕开 |
这就是充血模型——业务规则、状态机、不变量全部封装进领域对象,外部不可能写出违反业务的代码。
# 5.3 领域服务的边界
不是所有业务规则都能塞进实体——跨多个实体的协作就要用 DomainService:
// 领域服务:跨 Order 和 Stock 的转账/扣减规则
public class InventoryDomainService {
private final StockRepository stockRepository; // ← 依赖出站端口
public void deductFor(Order order) {
for (OrderItem item : order.items()) {
Stock stock = stockRepository.lockBySkuId(item.skuId())
.orElseThrow(() -> new BizException(ErrorCode.SKU_NOT_FOUND));
stock.deduct(item.qty()); // ← 领域行为
stockRepository.save(stock);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
ApplicationService vs DomainService 区分:
| 角色 | 关键词 | 例子 | 是否有事务注解 |
|---|---|---|---|
| ApplicationService | "怎么做"的编排 | 取数据→调领域→落库→发事件 | ✅(事务边界) |
| DomainService | "是什么"的规则 | 跨实体的业务策略、复杂计算 | ❌ |
红线:DomainService 不能加 @Transactional——它是领域规则,不该知道"事务"这种技术概念,事务边界由 ApplicationService 控制。
# 5.4 领域事件向外冒泡
领域事件(Domain Event)——业务上发生的、值得记录、可能触发其他动作的事实:
// 领域事件基类(在 domain 模块)
public abstract class DomainEvent {
private final Instant occurredAt = Instant.now();
public Instant occurredAt() { return occurredAt; }
}
public record OrderPlaced(OrderId orderId, CustomerId customerId, Money amount) implements DomainEvent {}
public record OrderPaid(OrderId orderId, Money paid) implements DomainEvent {}
public record OrderCancelled(OrderId orderId, String reason) implements DomainEvent {}
2
3
4
5
6
7
8
9
事件由聚合根产生,Application 拉取并发布:
@Transactional
public OrderId placeOrder(PlaceOrderCommand cmd) {
Order order = Order.create(cmd.customerId(), cmd.items());
inventoryDomainService.deductFor(order);
orderRepository.save(order);
// ── 把领域事件交给外圈 ──
order.pullEvents().forEach(publisher::publish);
return order.id();
}
2
3
4
5
6
7
8
9
10
DomainEventPublisher 是出站端口,它的具体实现(基于 Kafka / RocketMQ / Spring Event)在外圈适配器——Domain 只知道"我产生了事件",不知道事件怎么传出去。
这就是事件驱动架构的种子,下一篇 04.事件驱动架构设计 会详细展开。
💡 关键认知:六边形里的 Domain 只是纯净的业务模型,所有跨边界的协作(持久化、发消息、调外部)都通过端口让外圈完成。Domain 把"事件"当作业务语义抛出来,外圈决定怎么传播——关注点的彻底分离。
# 6. 工程结构分解
# 6.1 模块拆分原则
六边形最大的好处是编译期强制依赖方向——这一点必须借助多模块构建才能锁死。一个典型的 Maven 多模块结构:
order-service/ ← 父 pom
│
├── order-domain/ ← 最内圈:实体、值对象、领域服务、领域事件
│ pom 依赖:JDK + commons-lang3
│
├── order-application/ ← 中间圈:用例编排、入站端口、出站端口接口
│ pom 依赖:order-domain
│ + 极少 spring-tx(事务注解)
│
├── order-adapter-web/ ← 外圈:HTTP 适配器
│ pom 依赖:order-application + spring-boot-starter-web
│
├── order-adapter-persistence/ ← 外圈:MySQL 适配器
│ pom 依赖:order-application + mybatis-plus
│
├── order-adapter-messaging/ ← 外圈:Kafka 适配器
│ pom 依赖:order-application + spring-kafka
│
├── order-adapter-cache/ ← 外圈:Redis 适配器
│ pom 依赖:order-application + spring-data-redis
│
├── order-adapter-payment/ ← 外圈:支付三方适配器
│ pom 依赖:order-application + okhttp
│
└── order-bootstrap/ ← 启动模块:聚合所有 adapter + Spring Boot 入口
pom 依赖:所有 adapter 模块
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
核心收益:
| 收益 | 如何起作用 |
|---|---|
domain 不依赖框架 | Maven 编译期强制:domain 模块里 import Spring 直接编译失败 |
| 适配器可独立替换 | 删除 order-adapter-messaging/,换成新模块,业务零改动 |
| 单元测试快 | 测 domain 不启动 Spring,1000 个测试 < 5 秒 |
| 不同适配器并行开发 | 后端 A 写持久化、B 写消息、C 写 HTTP,互不阻塞 |
# 6.2 Maven/Gradle 多模块布局
order-domain/pom.xml:
<project>
<parent><artifactId>order-service</artifactId></parent>
<artifactId>order-domain</artifactId>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
order-application/pom.xml:
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>order-domain</artifactId>
</dependency>
<!-- 唯一允许的 Spring 依赖:声明式事务 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
</dependencies>
2
3
4
5
6
7
8
9
10
11
order-adapter-persistence/pom.xml:
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>order-application</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
</dependencies>
2
3
4
5
6
7
8
9
10
order-bootstrap/pom.xml:
<dependencies>
<!-- 聚合所有 adapter -->
<dependency><artifactId>order-adapter-web</artifactId></dependency>
<dependency><artifactId>order-adapter-persistence</artifactId></dependency>
<dependency><artifactId>order-adapter-messaging</artifactId></dependency>
<dependency><artifactId>order-adapter-cache</artifactId></dependency>
<dependency><artifactId>order-adapter-payment</artifactId></dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
2
3
4
5
6
7
8
9
10
11
12
Gradle 写法类似,用 implementation project(':order-domain')。
# 6.3 包级别守护
模块拆分是宏观约束,包内还要进一步守护——用 ArchUnit 在 CI 里强制依赖方向:
@AnalyzeClasses(packages = "com.example.order")
class HexagonalArchTest {
@ArchTest
static final ArchRule domain_should_not_depend_on_framework =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"org.apache.ibatis..",
"org.mybatis..",
"okhttp3..",
"org.apache.rocketmq..",
"org.apache.kafka.."
);
@ArchTest
static final ArchRule application_should_only_depend_on_domain =
noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAnyPackage("..adapter..");
@ArchTest
static final ArchRule adapters_should_not_depend_on_other_adapters =
slices().matching("..adapter.(*)..")
.should().notDependOnEachOther();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
收益:CI 跑测就拦截"分层破坏",新人想偷懒(在 Domain 里 @Autowired)直接编译失败——纪律从约定变成机制。
# 6.4 反例:分了模块但还是耦合
常见翻车:模块拆了,包名也分了,但 Domain 里偷偷写了 Spring 注解:
// ❌ Domain 里出现 @Component
package com.example.order.domain.service;
@Component // ← 红线:Domain 依赖了 Spring
@RequiredArgsConstructor
public class OrderDomainService {
@Autowired private OrderRepository orderRepository; // ← 双红线:构造注入和字段注入混用
}
2
3
4
5
6
7
8
问题:
- Domain 模块的 pom.xml 必须加 spring-context,金标准已破
- 单元测试需要起 Spring 容器,速度大幅下滑
- Domain 已经无法被"非 Spring 项目"复用
正确:
// ✅ 纯净 Domain
package com.example.order.domain.service;
@RequiredArgsConstructor // 仅 Lombok,编译期注解,运行时无依赖
public class OrderDomainService {
private final OrderRepository orderRepository; // 构造注入,纯 POJO
}
// 装配交给 application 模块
@Configuration
public class DomainConfig {
@Bean
public OrderDomainService orderDomainService(OrderRepository repo) {
return new OrderDomainService(repo);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
反例反思:拆模块只是物理约束,真正的依赖纪律靠 ArchUnit + Code Review 双保险。光拆不守,前一天的努力第二天就被破坏。
# 7. 适配器实战写法
# 7.1 入站 HTTP 适配器
// adapter-web/OrderController.java
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase;
private final QueryOrderUseCase queryOrderUseCase;
@PostMapping
public Response<OrderVO> create(@RequestBody @Valid CreateOrderReq req) {
PlaceOrderCommand cmd = orderWebMapper.toCommand(req);
OrderId id = placeOrderUseCase.placeOrder(cmd);
return Response.ok(OrderVO.builder().id(id.value()).build());
}
@GetMapping("/{id}")
public Response<OrderDetailVO> get(@PathVariable long id) {
OrderDetailDTO dto = queryOrderUseCase.queryDetail(new OrderId(id));
return Response.ok(orderWebMapper.toVO(dto));
}
}
// adapter-web/mapper/OrderWebMapper.java(MapStruct 转换)
@Mapper(componentModel = "spring")
public interface OrderWebMapper {
PlaceOrderCommand toCommand(CreateOrderReq req);
OrderDetailVO toVO(OrderDetailDTO dto);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
适配器职责:
| 该做 | 不该做 |
|---|---|
| HTTP 协议解析、参数校验、统一响应 | 业务规则 |
| Req/VO ↔ Command/DTO 转换 | 直接持有 Domain 实体 |
| 异常翻译(Biz → HTTP 4xx/5xx) | 写事务、写 SQL |
# 7.2 入站消息适配器
// adapter-messaging-in/PlaceOrderCommandListener.java
@Component
@RequiredArgsConstructor
public class PlaceOrderCommandListener {
private final PlaceOrderUseCase placeOrderUseCase;
private final ObjectMapper objectMapper;
@KafkaListener(topics = "place_order_cmd", groupId = "order-svc")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
try {
PlaceOrderCommand cmd = objectMapper.readValue(record.value(), PlaceOrderCommand.class);
placeOrderUseCase.placeOrder(cmd);
ack.acknowledge();
} catch (BizException biz) {
log.warn("业务异常,丢弃: {}", biz.getMessage());
ack.acknowledge(); // 业务错误不应反复重试
} catch (Exception e) {
log.error("系统异常,等重试", e);
// 不 ack → Kafka 重投
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
与 HTTP 适配器对比:
- 复用同一个
PlaceOrderUseCase—— 业务规则零拷贝 - 失败语义不同:HTTP 失败抛给前端,MQ 失败要重试
- 协议不同(Kafka vs HTTP),翻译规则不同,但端口契约不变
这就是"端口与适配器分离"带来的复用——分层架构很难做到这么干净。
# 7.3 出站持久化适配器
// adapter-persistence/OrderRepositoryImpl.java
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepository {
private final OrderMapper orderMapper; // MyBatis Mapper
private final OrderItemMapper itemMapper;
private final OrderPoConverter converter;
@Override
public Optional<Order> findById(OrderId id) {
OrderPO po = orderMapper.selectById(id.value());
if (po == null) return Optional.empty();
List<OrderItemPO> itemPos = itemMapper.selectByOrderId(po.getId());
return Optional.of(converter.toDomain(po, itemPos));
}
@Override
public void save(Order order) {
OrderPO po = converter.toPO(order);
List<OrderItemPO> itemPos = converter.toItemPOs(order);
if (po.getId() == null) {
orderMapper.insert(po);
itemPos.forEach(it -> it.setOrderId(po.getId()));
itemMapper.insertBatch(itemPos);
} else {
orderMapper.updateById(po);
itemMapper.deleteByOrderId(po.getId());
itemMapper.insertBatch(itemPos);
}
}
}
// adapter-persistence/converter/OrderPoConverter.java
@Mapper(componentModel = "spring")
public interface OrderPoConverter {
@Mapping(target = "id", expression = "java(new OrderId(po.getId()))")
@Mapping(target = "customerId", expression = "java(new CustomerId(po.getUserId()))")
Order toDomain(OrderPO po, List<OrderItemPO> items);
OrderPO toPO(Order order);
List<OrderItemPO> toItemPOs(Order order);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
关键:OrderPO 是数据库映射对象,只存在于这个 adapter 模块——Domain 模块完全不知道 PO 的存在。
# 7.4 出站三方调用适配器
// adapter-payment-http/HttpPaymentGateway.java
@Component
@RequiredArgsConstructor
public class HttpPaymentGateway implements PaymentGateway {
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
@Value("${payment.url}")
private String paymentUrl;
@Override
public PaymentResult prepay(Order order) {
try {
String body = objectMapper.writeValueAsString(toRequest(order));
Request req = new Request.Builder()
.url(paymentUrl + "/prepay")
.post(RequestBody.create(body, MediaType.parse("application/json")))
.build();
try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful()) {
throw new BizException(ErrorCode.PAYMENT_PREPAY_FAIL);
}
PrepayResp pr = objectMapper.readValue(resp.body().string(), PrepayResp.class);
return new PaymentResult(pr.getPrepayId(), pr.getQrCode());
}
} catch (IOException e) {
throw new BizException(ErrorCode.PAYMENT_NETWORK_ERROR, e);
}
}
private PrepayReq toRequest(Order order) { /* ... */ }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
对比第 1 章那段"业务里直接 OkHttp":
- 业务代码看到的是
paymentGateway.prepay(order)—— 纯净一行 - 重试、超时、熔断、序列化全部留在 Adapter
- 换支付服务商(HTTP → 内部 RPC)只换这个 Adapter 实现,业务零改动
- 单元测试中传一个
MockPaymentGateway即可,不再 Mock OkHttp
# 8. 可测试性飞跃
# 8.1 领域纯函数级单测
Domain 不依赖任何框架 → 测试就是纯 JUnit,毫秒级:
class OrderTest {
@Test
void cancel_should_throw_when_already_shipped() {
Order order = aShippedOrder();
assertThatThrownBy(() -> order.cancel("用户取消"))
.isInstanceOf(BizException.class)
.hasFieldOrPropertyWithValue("code", ErrorCode.ORDER_CANNOT_CANCEL);
}
@Test
void pay_should_emit_OrderPaid_event() {
Order order = aCreatedOrder(Money.cny(100));
order.pay(Money.cny(100));
assertThat(order.pullEvents()).hasSize(1)
.first().isInstanceOf(OrderPaid.class);
}
@Test
void pay_should_throw_when_amount_mismatch() {
Order order = aCreatedOrder(Money.cny(100));
assertThatThrownBy(() -> order.pay(Money.cny(99)))
.hasFieldOrPropertyWithValue("code", ErrorCode.PAY_AMOUNT_MISMATCH);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
特点:零框架、零 Mock、毫秒级。CI 跑 1000 个测试 < 5 秒。这是分层架构永远比不上的优势。
# 8.2 用例级 Mock 适配器
ApplicationService 测试只需 Mock 端口接口:
@ExtendWith(MockitoExtension.class)
class PlaceOrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private InventoryRepository inventoryRepository;
@Mock private PaymentGateway paymentGateway;
@Mock private DomainEventPublisher publisher;
@InjectMocks private PlaceOrderService service;
@Test
void should_rollback_when_payment_fail() {
// given
PlaceOrderCommand cmd = aValidCmd();
doThrow(new BizException(ErrorCode.PAYMENT_PREPAY_FAIL))
.when(paymentGateway).prepay(any());
// when & then
assertThatThrownBy(() -> service.placeOrder(cmd))
.isInstanceOf(BizException.class);
verify(orderRepository, never()).save(any()); // 没保存
verify(publisher, never()).publish(any()); // 没发事件
}
@Test
void should_publish_events_on_success() {
// given
PlaceOrderCommand cmd = aValidCmd();
// when
OrderId id = service.placeOrder(cmd);
// then
assertThat(id).isNotNull();
verify(orderRepository).save(any());
verify(publisher, atLeastOnce()).publish(any(OrderPlaced.class));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
注意全部 Mock 都是 Domain/Application 自己定义的端口接口——不需要 Mock 任何 Spring/MyBatis/Kafka 的类。这正是六边形测试快、稳的根本原因。
# 8.3 适配器集成测试
适配器测真实技术——所以该重的就重:
@DataJpaTest // 或 @MybatisTest
@AutoConfigureTestDatabase(replace = NONE) // 用 Testcontainers MySQL
@Import(OrderRepositoryImpl.class)
class OrderRepositoryImplIT {
@Autowired private OrderRepository repository;
@Test
void save_and_findById() {
Order order = anOrder();
repository.save(order);
Order found = repository.findById(order.id()).orElseThrow();
assertThat(found.totalAmount()).isEqualTo(order.totalAmount());
}
}
// Kafka 适配器集成测试
@SpringBootTest
@EmbeddedKafka(topics = "order_events")
class KafkaEventPublisherIT {
@Autowired DomainEventPublisher publisher;
@Autowired KafkaConsumer<String, String> consumer;
@Test
void publish_should_send_to_kafka() {
publisher.publish(new OrderPlaced(new OrderId(1), new CustomerId(1), Money.cny(100)));
ConsumerRecord<String, String> record =
KafkaTestUtils.getSingleRecord(consumer, "order_events");
assertThat(record.value()).contains("\"orderId\":1");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
适配器测试慢但可控——只关心"协议+实现",与业务规则解耦。
# 8.4 测试金字塔实际收益
▲
╱ E2E ╲ 少量端到端
╱───────╲
╱ Adapter ╲ 集成测试(连真实 DB/MQ)
╱──────────╲
╱ Application ╲ 用例测试(Mock 端口)
╱──────────────╲
╱ Domain ╲ 纯 JUnit(无框架)
────────────────────
2
3
4
5
6
7
8
9
实际数据(某中型项目,~10 万行):
| 层 | 测试数 | 平均耗时 | 是否启动 Spring |
|---|---|---|---|
| Domain 单测 | 800 | 0.5 ms | 否 |
| Application 单测 | 200 | 2 ms | 否 |
| Adapter 集成 | 80 | 200 ms | 是(局部) |
| 全链路 E2E | 20 | 5 秒 | 是 |
总计 < 60 秒跑完全部测试——分层架构在同等规模下经常需要 5-10 分钟。这就是六边形换来的"持续集成红利"。
# 9. 演进实战剖析
# 9.1 从四层平滑迁移
从经典四层迁移到六边形不需要重写——可以渐进:
阶段一:抽出口 Port(最低成本,立刻有收益)
- Service 里所有 import RocketMQ/OkHttp/RedisTemplate 提取成接口
- 接口放 application/port/out
- 现有 Service 注入接口,Impl 写新的 adapter 类
✓ 即可换 MQ/换 Redis
阶段二:抽 Domain 实体(中等成本)
- 把 PO 上的业务方法搬到 Domain Entity
- 在 Repository 出入口做 PO ↔ Domain 转换
- Service 改成调 Domain.xxxBehavior() 而不是直接读写字段
✓ 业务规则集中、状态机受控
阶段三:拆 Maven 多模块(高成本)
- 把 domain/application/adapter 拆成独立模块
- 加 ArchUnit 守护规则
- bootstrap 模块聚合启动
✓ 编译期强制依赖方向
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键:按业务模块逐个迁移,不要一次性大爆炸——先迁低风险的"用户模块"试水,跑稳了再推全员。
# 9.2 替换持久层的代价
回到第 1 章那个"换 MQ"难题,看六边形下的代价对比:
| 改造项 | 分层架构成本 | 六边形成本 |
|---|---|---|
| RocketMQ → Kafka | 改 40+ 业务文件 | 新增 1 个 KafkaEventPublisher 实现,改 @Configuration 切换 Bean |
| MySQL → PostgreSQL | 改所有 Mapper SQL + 业务相关 | 只动 adapter-persistence-mysql 改成 adapter-persistence-pg |
| OkHttp → 内部 RPC | 改所有 Service 里的 HTTP 调用 | 新增 RpcPaymentGateway implements PaymentGateway,业务零改动 |
| Redis → Hazelcast | 业务里的 redis.opsForValue() 全改 | 新增 HazelcastOrderCache implements OrderCache,业务零改动 |
| 加一个 GraphQL 入口 | 重新写一遍业务编排 | 新增 OrderGraphQLResolver 调用同一个 PlaceOrderUseCase |
质变就在这里:分层架构的成本是"线性 with 业务规模",六边形的成本是"常数 with 适配器"——业务越大、迁移越多,六边形的复利越大。
# 9.3 通往洋葱架构与整洁架构
六边形是一个起点,往下还能继续演进:
六边形(Hexagonal)─┐
│
洋葱架构(Onion)── ┤── 三者本质相同:依赖方向由外向内
│
整洁架构(Clean)── ┘
2
3
4
5
| 架构 | 提出者 | 核心圈层 | 特色 |
|---|---|---|---|
| 六边形 | Cockburn (2005) | Domain / Application / Adapter | 端口与适配器命名清晰 |
| 洋葱架构 | Palermo (2008) | Domain Model → Domain Service → Application → Infra | 强调多层同心圆 |
| 整洁架构 | Uncle Bob (2012) | Entity → UseCase → Adapter → Framework | 强调"框架是细节" |
三者本质相同:把"业务核心"画在内圈,"技术细节"画在外圈,依赖永远从外指向内。
💡 演进规律:从三层 → 四层 → 六边形 → 洋葱/整洁 → DDD 战略设计,架构永远在做一件事——让"业务"越来越独立于"技术"。每一步都不是"换框架",而是"加一道边界"。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 OrderService.createOrder,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 业务凭什么直接 import MQ/Redis/HTTP? | 第 3.1 / 4.3:没有抽端口,业务把"做什么"和"怎么做"焊死 |
| ② 技术替换为什么动业务文件? | 第 4.1:依赖方向不对,业务依赖了具体实现而不是抽象 |
| ③ 业务规则散落怎么破? | 第 5.1-5.2:用充血模型,规则封装进 Entity/Aggregate Root |
| ④ Domain/Application/Adapter 怎么放? | 第 6.2:Maven 多模块,编译期强约束 |
| ⑤ 多个入口要重写业务吗? | 第 3.4 / 7.1-7.2:复用同一个 UseCase,HTTP/MQ/RPC 各写一个 Adapter |
| ⑥ 测试为什么 Mock 一堆? | 第 8:六边形下 Domain 纯 JUnit、Application 只 Mock 端口接口 |
| ⑦ 怎么平滑迁移? | 第 9.1:三阶段渐进,先抽端口、再抽 Domain、最后拆模块 |
修复方案(按代价从小到大):
方案 A:先抽端口(一周可上线)
// application/port/out/
public interface DomainEventPublisher { void publish(DomainEvent e); }
public interface OrderCache { void put(Order o); }
public interface PaymentGateway { PaymentResult prepay(Order o); }
// adapter 包:实现端口
@Component
public class RocketMqEventPublisher implements DomainEventPublisher { ... }
@Component
public class RedisOrderCache implements OrderCache { ... }
@Component
public class HttpPaymentGateway implements PaymentGateway { ... }
// 业务 Service:只依赖端口
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryRepository inventoryRepository;
private final DomainEventPublisher publisher;
private final OrderCache cache;
private final PaymentGateway payment;
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
收益:业务代码立刻不再 import 任何具体技术类,换 MQ 工作量从 87 文件降到 1 类。
方案 B:把规则塞进 Domain(一个月内分模块完成)
public class Order {
public static Order create(...) { ... }
public void cancel(String reason) { ... }
public void pay(Money paid) { ... }
// 状态机集中、不变量集中、事件集中
}
2
3
4
5
6
业务 Service 退化为"编排者"——拿数据、调 Domain 行为、落库、发事件,业务规则不再裸写在 if 里。
方案 C:拆 Maven 多模块 + ArchUnit(季度级别完成)
order-domain/ JDK only
order-application/ + spring-tx
order-adapter-*/ 各种框架
order-bootstrap/ 聚合
2
3
4
加 ArchUnit 守护规则 → CI 强制 → 退化无法再发生。
生产建议:方案 A 立刻做(无风险、收益大),方案 B 按业务模块逐个推(核心模块优先),方案 C 在新业务先试点(老业务慢慢迁)。重构永远是渐进的,不是革命。
# 10.2 一个请求的一生
把 POST /api/v1/orders 这一行的全过程在六边形下串成知识树:
HTTP POST /api/v1/orders {...}
│
├─ 外圈:入站 HTTP 适配器
│ ├─ Tomcat 解析 HTTP → HttpServletRequest
│ ├─ Spring MVC 路由到 OrderController.create
│ ├─ Jackson 反序列化 body → CreateOrderReq
│ ├─ JSR-303 @Valid 校验参数
│ ├─ TraceFilter 注入 MDC traceId
│ └─ OrderWebMapper.toCommand(req) → PlaceOrderCommand ─── 第 7.1 节
│
├─ 中圈:Application Service(入站端口实现)
│ ├─ @Transactional 开启事务 ─── 第 7.1 节
│ ├─ inventoryRepository.lockBySkus(...) ← 出站端口
│ ├─ Order.create(customerId, items) ← 领域工厂 ─── 第 5.2 节
│ ├─ inventoryDomainService.deductFor(order) ← 领域服务 ─── 第 5.3 节
│ ├─ orderRepository.save(order) ← 出站端口
│ ├─ paymentGateway.prepay(order) ← 出站端口
│ ├─ order.pullEvents().forEach(publisher::publish) ← 出站端口
│ ├─ 事务提交 / 异常回滚
│ └─ 返回 OrderId
│
├─ 最内:Domain(业务规则纯净执行)
│ ├─ Order 工厂检查 items 非空、totalAmount > 0
│ ├─ Stock.deduct(qty) 检查库存充足
│ ├─ 产生 OrderCreated / OrderPlaced 领域事件
│ └─ 不依赖任何框架,可被独立单测
│
├─ 外圈:出站持久化适配器
│ ├─ OrderPoConverter.toPO(order) → OrderPO
│ ├─ MyBatis 执行 INSERT
│ ├─ 回写自增 ID 到 Order
│ └─ 子表批量插入
│
├─ 外圈:出站消息适配器
│ ├─ DomainEventPublisher(Kafka 实现)
│ ├─ 序列化 DomainEvent → JSON
│ └─ kafkaTemplate.send(topic, json)
│
├─ 出口
│ ├─ Service 返回 OrderId
│ ├─ Controller 调 toVO 转换
│ ├─ Response.ok 统一包装
│ └─ HTTP 200 响应
│
└─ 异常路径
├─ BizException → GlobalHandler → 业务码
├─ ValidationException → 400 PARAM_INVALID
└─ Throwable → 500 SYSTEM_ERROR (兜底)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
理解一个请求在六边形里的一生,就是理解**"业务"和"技术"在每个时刻分别住在哪一圈**。这是整套架构系列的核心心智模型。
# 10.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:抽象即解耦——端口是"业务"对"技术"的最低期望
业务说"我需要把订单存起来"——这就是 OrderRepository.save(order),不关心是 MySQL 还是 MongoDB。技术说"我能存"——这就是 OrderRepositoryImpl,由它去实现承诺。端口把"业务期望"显式化成接口,让两边可以独立演化。这是面向对象 SOLID 中 D(依赖倒置)原则在架构层的放大。
哲学 2:方向即纪律——依赖永远从外指向内
业务变化频率远高于技术,所以业务应当独立于技术——这意味着代码上"业务不能 import 技术"。这条纪律必须编译期强制(多模块 + ArchUnit),否则人工守不住。架构纪律靠机制,不靠提醒——这是工程化的底线。
哲学 3:核心即业务——Domain 是知识的纯净沉淀
把所有业务规则塞进 Domain Entity/Aggregate,让 Service 退化为"编排者"——这是充血模型的本质。Domain 不依赖框架,是检验六边形落地是否合格的金标准。能脱离 Spring/MyBatis 独立编译跑测,才叫真的"业务独立"。
哲学 4:替换即收益——可插拔是架构演进的复利
六边形最大的承诺:任何外部技术都能替换,业务零改动。换 MQ、换 DB、换 RPC、加新入口——成本是常数级(一个 Adapter 类),不是线性级(满项目改)。这种"替换成本恒定"的特性,是大型系统持续演进的关键复利。短期成本(多写接口、多拆模块)远低于长期收益(每次基础设施替换省下几十人月)。
# 10.4 六边形速查清单
一张表保存以备查:
| 圈层 | 包含 | 允许依赖 | 测试策略 |
|---|---|---|---|
| Domain | 实体、值对象、领域服务、领域事件 | JDK + commons-lang3 | 纯 JUnit |
| Application | UseCase 接口、UseCase 实现、Port 接口 | Domain + spring-tx | Mock 端口 |
| Adapter-in-web | Controller、Req/VO、WebMapper | Application + Spring MVC | @WebMvcTest |
| Adapter-in-mq | MQ 监听、消息→Command 转换 | Application + Kafka/Rocket | EmbeddedKafka |
| Adapter-out-persistence | Repository 实现、PO、Converter | Application + MyBatis | Testcontainers |
| Adapter-out-messaging | EventPublisher 实现 | Application + Kafka/Rocket | EmbeddedKafka |
| Adapter-out-http | Gateway 实现 | Application + OkHttp/Feign | MockWebServer |
| Bootstrap | Spring Boot 入口 + 装配配置 | 所有 Adapter | E2E |
60 秒诊断清单:
# 看 Domain 是否依赖框架(必须为空)
grep -rE "import (org\.springframework|org\.mybatis|org\.apache\.(rocketmq|kafka)|okhttp3)" \
order-domain/src/main/java/
# 看 Application 是否依赖具体 Adapter
grep -rE "import .*\.adapter\." order-application/src/main/java/
# 看 Adapter 之间是否互相依赖
grep -rE "import com\.example\.order\.adapter\." \
order-adapter-persistence/src/main/java/ | grep -v adapter.persistence
# 跑 ArchUnit
mvn test -Dtest=HexagonalArchTest
# 看 @Transactional 是否只在 Application 层
grep -rn "@Transactional" order-application/ order-domain/ order-adapter-*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
端口设计速记:
入站端口 XxxUseCase / XxxAppService (外面调进来)
出站端口 XxxRepository / XxxGateway (里面调出去)
接口在 application 模块,实现在 adapter-* 模块
Spring 在运行时把接口装配到实现
2
3
4
5
与分层架构的对应:
分层架构 六边形架构
───────────── ─────────────
Controller Adapter-in-web
Service Application (UseCase)
Domain Entity Domain (扩展为聚合根)
Repository 实现 Adapter-out-persistence
(PO/VO 直接) 端口隔离 + Converter 转换
2
3
4
5
6
7
演进路径速记:
三层 → 四层(加 Domain) → 六边形(端口与适配器) → 洋葱/整洁 → DDD 战略设计 → 微服务
↑
本篇定位
2
3
每一步都不是"换框架",而是"加一道边界、让依赖更明确"。
下一篇:我们已经知道了"业务可以独立于技术",下一步进入 03.命令查询职责分离——把"业务"自身再切一刀,写入路径(Command)与读取路径(Query)分别建模,让复杂查询不再拖累领域模型。