编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

  • 常见设计原则

  • 巧学设计模式

  • 系统架构设计

    • README
    • 分层架构设计详解
    • 六边形架构设计
      • 1. 案例引入
        • 1.1 一次换 MQ 引发的全栈瘫痪
        • 1.2 顺藤摸到根因
        • 1.3 我们要回答什么
      • 2. 架构概览
        • 2.1 六边形总图
        • 2.2 为什么是六边形
        • 2.3 与分层架构对比
      • 3. 端口与适配器
        • 3.1 端口是接口契约
        • 3.2 入站端口与出站端口
        • 3.3 适配器是技术实现
        • 3.4 一个端口多个适配器
      • 4. 依赖倒置原则
        • 4.1 依赖方向从外指向内
        • 4.2 Domain 不依赖任何框架
        • 4.3 反例:直接耦合的代价
        • 4.4 控制反转的工程落地
      • 5. 领域核心建模
        • 5.1 实体与值对象
        • 5.2 聚合根与不变量
        • 5.3 领域服务的边界
        • 5.4 领域事件向外冒泡
      • 6. 工程结构分解
        • 6.1 模块拆分原则
        • 6.2 Maven/Gradle 多模块布局
        • 6.3 包级别守护
        • 6.4 反例:分了模块但还是耦合
      • 7. 适配器实战写法
        • 7.1 入站 HTTP 适配器
        • 7.2 入站消息适配器
        • 7.3 出站持久化适配器
        • 7.4 出站三方调用适配器
      • 8. 可测试性飞跃
        • 8.1 领域纯函数级单测
        • 8.2 用例级 Mock 适配器
        • 8.3 适配器集成测试
        • 8.4 测试金字塔实际收益
      • 9. 演进实战剖析
        • 9.1 从四层平滑迁移
        • 9.2 替换持久层的代价
        • 9.3 通往洋葱架构与整洁架构
      • 10. 综合案例串讲
        • 10.1 案例真相揭晓
        • 10.2 一个请求的一生
        • 10.3 设计哲学回扣
        • 10.4 六边形速查清单
    • 命令查询职责分离
    • 事件驱动架构设计
    • 微服务拆分策略
    • 领域驱动战略设计
    • 架构评审方法论
    • 架构演进实战指南
  • 编程
  • 系统架构设计
杨充
2019-08-18
目录

六边形架构设计

# 02.六边形架构设计

# 目录介绍

  • 1. 案例引入
    • 1.1 一次换 MQ 引发的全栈瘫痪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 六边形总图
    • 2.2 为什么是六边形
    • 2.3 与分层架构对比
  • 3. 端口与适配器
    • 3.1 端口是接口契约
    • 3.2 入站端口与出站端口
    • 3.3 适配器是技术实现
    • 3.4 一个端口多个适配器
  • 4. 依赖倒置原则
    • 4.1 依赖方向从外指向内
    • 4.2 Domain 不依赖任何框架
    • 4.3 反例:直接耦合的代价
    • 4.4 控制反转的工程落地
  • 5. 领域核心建模
    • 5.1 实体与值对象
    • 5.2 聚合根与不变量
    • 5.3 领域服务的边界
    • 5.4 领域事件向外冒泡
  • 6. 工程结构分解
    • 6.1 模块拆分原则
    • 6.2 Maven/Gradle 多模块布局
    • 6.3 包级别守护
    • 6.4 反例:分了模块但还是耦合
  • 7. 适配器实战写法
    • 7.1 入站 HTTP 适配器
    • 7.2 入站消息适配器
    • 7.3 出站持久化适配器
    • 7.4 出站三方调用适配器
  • 8. 可测试性飞跃
    • 8.1 领域纯函数级单测
    • 8.2 用例级 Mock 适配器
    • 8.3 适配器集成测试
    • 8.4 测试金字塔实际收益
  • 9. 演进实战剖析
    • 9.1 从四层平滑迁移
    • 9.2 替换持久层的代价
    • 9.3 通往洋葱架构与整洁架构
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个请求的一生
    • 10.3 设计哲学回扣
    • 10.4 六边形速查清单

# 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();
    }
}
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
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 对象类型全变)
  • 又有人说"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 章 演进实战剖析
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出三种迁移路径与各自的代价。

本篇路线:

六边形总图 (第 2 章)
   ↓
端口与适配器 (第 3 章) ─→ 解开"业务和技术怎么解耦"
   ↓
依赖倒置 (第 4 章) ─→ 解开"为什么外圈依赖内圈"
   ↓
领域核心 (第 5 章) ─→ 解开"业务知识住在哪"
   ↓
工程结构 → 适配器写法 (第 6-7 章) ─→ 落地骨架
   ↓
可测试性 → 演进 (第 8-9 章) ─→ 红利与迁移
   ↓
综合案例 (第 10 章) ─→ 案例彻底剖开
1
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 ─┘
1
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 为什么是六边形

疑惑:为什么画成六边形,不画成圆形或方形?

论证:

  1. 形状本身没有特别含义——Cockburn 自己也说:"画几边都行,六边只是因为画起来好分边"。
  2. 六条边象征"一个业务核心要面对多种外部世界"——HTTP / RPC / MQ / 定时器 / CLI / 测试桩。如果画成方形会让人误以为"只有 4 类外部"。
  3. 不是"层",是"内外"——传统分层是上下叠的,六边形是同心的。这是核心范式区别:分层强调"调用顺序",六边形强调"依赖方向"。
  4. 内圈是业务,外圈是技术,业务变化频率与技术变化频率天然不同——业务三天一变、技术三年一换,把它们用"边界"隔开是必然选择。
  5. 反向验证:如果没有六边形,业务直接耦合具体技术会怎样?回看第 1 章案例——换个 MQ 动 87 个业务文件,这就是耦合的代价。

结论:六边形是把"业务"和"基础设施"在编译期就强制分离的架构模式。它不是花架子,是真能让"换 DB 不动业务、换 MQ 不动业务、换协议不动业务"的工程化方案。

# 2.3 与分层架构对比

维度 经典四层 六边形
结构方式 上下叠 同心圆
依赖方向 自上而下 由外向内
Domain 位置 中间一层 最内核心
Repository 在 Infrastructure 接口在 Domain,实现在 Adapter
框架依赖 各层都可能依赖 只允许外圈依赖
测试难度 Service 单测要 Mock 一堆 Domain 纯 JUnit、Application 只 Mock 端口
技术替换 牵动业务 只换 Adapter
学习曲线 低 中(需理解依赖倒置)

演进关系:

三层架构  ──►  四层架构(加 Domain)  ──►  六边形(端口适配器)  ──►  洋葱/整洁架构
   ↑                ↑                       ↑                       ↑
 直觉              加领域                 倒依赖                   多同心圈
1
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);
}
1
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) ──► 外部资源
            │ 由外部调用 → 实现在内              │ 由内部调用 → 实现在外
            │                               │
            └───────────────────────────────┘
1
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();
    }
}
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
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 (历史归档)
1
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()));
}
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

四个适配器复用同一个 PlaceOrderUseCase,业务规则只写一遍。这就是"端口与适配器分离"换来的复用——分层架构很难做到这么干净(典型做法是 4 个 Controller 各调一遍 Service,业务规则反复散落)。

# 4. 依赖倒置原则

# 4.1 依赖方向从外指向内

核心铁律:

依赖方向:永远从 外圈 → 内圈
代码可见性:内圈 看不见 外圈

    Adapter (外)  ──依赖──►  Application (中)  ──依赖──►  Domain (内)
       ↑                         ↑
       │                         │
    可见外圈              可见 Application + Domain
1
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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

它能带来什么?

  1. 业务规则可以脱离 Spring 跑测试——纯 JUnit,毫秒级
  2. 业务规则可以被多个 application 复用——同一份 Domain 在"Web 应用"、"批处理"、"CLI 工具"里都跑
  3. 业务规则可以被静态分析工具扫描——SonarQube 复杂度报告里"业务复杂度"和"技术复杂度"自然分开
  4. 业务规则演进不受技术演进牵制——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
    }
}
1
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)
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
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
}
1
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;                   // 可变

    // ... 业务方法
}
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
33
34
35
36
37
38
39
40
41
42
43

值对象的两大红利:

  1. 类型表达业务——Money add(Money m) 比 BigDecimal add(BigDecimal) 多了"币种校验"的语义
  2. 不可变 = 线程安全 = 无副作用——传值给任何函数都不用担心被改坏

# 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);
    }
}
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
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);
        }
    }
}
1
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 {}
1
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();
}
1
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 模块
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

核心收益:

收益 如何起作用
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>
1
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>
1
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>
1
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>
1
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();
}
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

收益: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;       // ← 双红线:构造注入和字段注入混用
}
1
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);
    }
}
1
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);
}
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

适配器职责:

该做 不该做
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 重投
        }
    }
}
1
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);
}
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
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) { /* ... */ }
}
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

对比第 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);
    }
}
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

特点:零框架、零 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));
    }
}
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
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");
    }
}
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(无框架)
      ────────────────────
1
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 模块聚合启动
   ✓ 编译期强制依赖方向
1
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)── ┘
1
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;
    // ...
}
1
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) { ... }
    // 状态机集中、不变量集中、事件集中
}
1
2
3
4
5
6

业务 Service 退化为"编排者"——拿数据、调 Domain 行为、落库、发事件,业务规则不再裸写在 if 里。

方案 C:拆 Maven 多模块 + ArchUnit(季度级别完成)

order-domain/         JDK only
order-application/    + spring-tx
order-adapter-*/      各种框架
order-bootstrap/      聚合
1
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 (兜底)
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
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-*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

端口设计速记:

入站端口  XxxUseCase / XxxAppService    (外面调进来)
出站端口  XxxRepository / XxxGateway   (里面调出去)

接口在 application 模块,实现在 adapter-* 模块
Spring 在运行时把接口装配到实现
1
2
3
4
5

与分层架构的对应:

分层架构             六边形架构
─────────────       ─────────────
Controller          Adapter-in-web
Service             Application (UseCase)
Domain Entity       Domain (扩展为聚合根)
Repository 实现      Adapter-out-persistence
(PO/VO 直接)       端口隔离 + Converter 转换
1
2
3
4
5
6
7

演进路径速记:

三层 → 四层(加 Domain) → 六边形(端口与适配器) → 洋葱/整洁 → DDD 战略设计 → 微服务
                                        ↑
                                   本篇定位
1
2
3

每一步都不是"换框架",而是"加一道边界、让依赖更明确"。


下一篇:我们已经知道了"业务可以独立于技术",下一步进入 03.命令查询职责分离——把"业务"自身再切一刀,写入路径(Command)与读取路径(Query)分别建模,让复杂查询不再拖累领域模型。

#架构#六边形#端口适配器
上次更新: 2026/06/17, 11:43:57
分层架构设计详解
命令查询职责分离

← 分层架构设计详解 命令查询职责分离→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式