业务代码最容易被两样东西绑架:一是框架,二是基础设施。当"下单"这段核心逻辑里混进了 @RestController、JdbcTemplate 和 KafkaTemplate,你就再也无法脱离 Spring 去测试它,也无法在不改业务代码的前提下把 MySQL 换成 PostgreSQL。六边形架构与整洁架构给出了同一味解药——把领域逻辑放在中心,让框架与基础设施通过接口挂在外面。本文讲清端口、适配器、依赖规则,以及真正落地时的包结构与测试策略。
1. 问题的根源:依赖方向错了
1.1 传统分层架构的隐性耦合
教科书式的三层架构(Controller → Service → DAO)看起来干净,但依赖方向是从上到下、一路指向基础设施:
Controller ──→ Service ──→ DAO ──→ MySQL
(Web) (业务) (SQL)
问题在于:业务逻辑(Service)直接 import 了 DAO 的具体实现。于是:
- 想给 Service 写单元测试,就必须启动数据库或引入 mock 框架;
- 想换 ORM,得改 Service;
- 框架升级、注解变更,业务代码跟着改。
依赖方向指向了"最不稳定"的一侧——数据库、消息队列、Web 框架恰恰是变化最频繁的部分。
1.2 依赖倒置原则(DIP)
依赖倒置原则(Dependency Inversion Principle)说两件事:
- 高层模块不应依赖低层模块,两者都应依赖抽象;
- 抽象不应依赖细节,细节应依赖抽象。
翻译到代码里就是:由业务层定义接口(抽象),由基础设施层实现接口(细节)。依赖箭头于是翻转过来:
业务层定义接口 OrderRepository
▲
│ 实现
基础设施层 MySQLOrderRepository
业务层不再知道 MySQL 的存在,只知道"有个东西能存订单"。谁实现它、用什么实现,是外层的事。这就是六边形与整洁架构共同的基石,也是与 领域驱动设计 中"领域层保持纯粹"一致的诉求。
2. 六边形架构:端口与适配器
2.1 端口与适配器模型
六边形架构(Hexagonal Architecture,又称端口与适配器 Ports and Adapters)由 Alistair Cockburn 提出。“六边形"只是示意图的形状——它表示内外两侧有多个对称的交互面,与边的数量无关。
- 端口(Port):业务核心对外开放的接口。分为两类:
- 入站端口(Driving Port):外部调用业务的入口,如
PlaceOrderUseCase; - 出站端口(Driven Port):业务调用外部的出口,如
OrderRepository、PaymentGateway。
- 入站端口(Driving Port):外部调用业务的入口,如
- 适配器(Adapter):把某种技术实现接到端口上的转换器。
- 入站适配器:REST Controller、gRPC Handler、CLI 命令、消息消费者;
- 出站适配器:MySQL/JPA 仓储、Kafka 生产者、HTTP 客户端。
┌─────────── 入站适配器 ───────────┐
HTTP ──▶ REST Controller ──▶ [入站端口] ──┐
CLI ──▶ Command Handler ──▶ [入站端口] ──┤
▼
┌───────────┐
│ 领域核心 │
│ (业务逻辑) │
└───────────┘
│
DB ◀── JPA 适配器 ◀── [出站端口] ◀──────┘
MQ ◀── Kafka 适配器 ◀─ [出站端口] ◀──────┘
2.2 用 Go 写一个端口与适配器
先由领域层定义端口(接口):
// domain/order/port.go —— 出站端口,由领域层拥有
package order
type Repository interface {
Save(ctx context.Context, o *Order) error
FindByID(ctx context.Context, id string) (*Order, error)
}
// 入站端口:用例的抽象
type PlaceOrderUseCase interface {
Execute(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
}
领域核心只依赖 Repository 这个抽象:
// domain/order/service.go
type Service struct {
repo Repository // 出站端口
pay PaymentGateway // 另一个出站端口
clock Clock
}
func (s *Service) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error) {
o, err := NewOrder(cmd.CustomerID, cmd.Items, s.clock.Now())
if err != nil {
return "", err
}
if err := s.pay.Authorize(ctx, o.Total()); err != nil {
return "", ErrPaymentDeclined
}
if err := s.repo.Save(ctx, o); err != nil {
return "", err
}
return o.ID(), nil
}
适配器在外层实现端口,把技术细节翻译成领域语言:
// adapter/persistence/mysql_repo.go
package persistence
type MySQLOrderRepo struct{ db *sql.DB }
func (r *MySQLOrderRepo) Save(ctx context.Context, o *order.Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders(id, customer_id, total, status) VALUES(?,?,?,?)",
o.ID(), o.CustomerID(), o.Total(), o.Status())
return err
}
注意方向:adapter 依赖 domain,domain 从不 import adapter。这是整个模式的关键。
3. 整洁架构:同心圆与依赖规则
3.1 四层同心圆
Robert C. Martin 的整洁架构(Clean Architecture)把六边形思想扩展为同心圆,从内到外:
| 层 | 内容 | 依赖 |
|---|---|---|
| Entities(实体) | 企业级业务规则、领域对象 | 无 |
| Use Cases(用例) | 应用级业务流程编排 | 依赖实体 |
| Interface Adapters(接口适配器) | Controller、Presenter、Repository 实现 | 依赖用例 |
| Frameworks & Drivers(框架与驱动) | Web、DB、UI、外部服务 | 最外层 |
3.2 唯一的硬规则:依赖规则
整洁架构只有一条不能违反的规则——源码依赖必须由外向内,内层对"外层有什么"一无所知。
Frameworks & Drivers
┌─────────────────────────────┐
│ Interface Adapters │
│ ┌─────────────────────┐ │
│ │ Use Cases │ │
│ │ ┌─────────────┐ │ │
│ │ │ Entities │ │ │
│ │ └─────────────┘ │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
依赖箭头一律指向圆心
跨层数据怎么传? 用简单的数据传输对象(DTO),不要传 ORM 实体、不要传框架的 Request 对象。内层定义自己的数据结构,外层负责映射。
3.3 跨边界的数据流
// 用例层定义输入/输出模型,不依赖任何框架
public record PlaceOrderRequest(String customerId, List<Item> items) {}
public record PlaceOrderResponse(String orderId, String status) {}
public class PlaceOrderInteractor implements PlaceOrderInputBoundary {
private final OrderRepository repo; // 出站端口
private final OrderPresenter presenter; // 出站端口(输出)
@Override
public void execute(PlaceOrderRequest req) {
Order order = Order.create(req.customerId(), req.items());
repo.save(order);
presenter.present(new PlaceOrderResponse(order.id(), order.status()));
}
}
Controller 只是把 HTTP 请求翻译成 PlaceOrderRequest,Presenter 把 PlaceOrderResponse 翻译成 HTTP 响应。业务逻辑对 HTTP 完全无感。
4. 与 DDD 分层、洋葱架构的关系
这三种说法经常被混用,本质是同一思想的不同表述:
| 架构 | 提出者 | 核心机制 | 与整洁架构关系 |
|---|---|---|---|
| 六边形架构 | Alistair Cockburn | 端口 + 适配器 | 对称内外侧,整洁架构是其"圆环化” |
| 洋葱架构 | Jeffrey Palermo | 同心圆 + 依赖倒置 | 与整洁架构几乎等价 |
| DDD 分层 | Eric Evans | UI/应用/领域/基础设施 | 领域层在中心,基础设施在外 |
它们共同约束是依赖倒置 + 领域在中心。区别只在术语:六边形强调"对称的交互面",整洁架构强调"同心圆与依赖规则",DDD 强调"领域模型的战略设计"。
如果你的项目是单体且需要控制复杂度,可先看 模块化单体 ——它把六边形的边界从"进程"降到"模块",代价更小。
5. 落地:包结构与测试策略
5.1 推荐的包结构
internal/
order/ # 一个限界上下文
domain/ # 领域核心:实体、值对象、领域服务、端口
order.go
port.go # Repository / PaymentGateway 接口
service.go
app/ # 用例编排(应用层)
place_order.go
adapter/
http/ # 入站适配器
order_handler.go
persistence/ # 出站适配器
mysql_repo.go
payment/
stripe_gateway.go
main.go # 组装:把适配器注入领域
组装(wiring)放在最外层,这是唯一知道"哪个适配器接哪个端口"的地方:
func main() {
db := mustOpenDB()
repo := persistence.NewMySQLOrderRepo(db)
gateway := payment.NewStripeGateway(os.Getenv("STRIPE_KEY"))
svc := order.NewService(repo, gateway, system.Clock{})
http.Handle("/orders", adapter.NewOrderHandler(svc))
log.Fatal(http.ListenAndServe(":8080", nil))
}
5.2 测试策略:从内存适配器到端到端
端口与适配器让测试分层变得极其自然:
| 测试层级 | 被测对象 | 替身 | 速度 |
|---|---|---|---|
| 领域单元测试 | Order 实体 | 无(纯内存) | 毫秒 |
| 用例测试 | Service | 内存 Repository | 毫秒 |
| 适配器测试 | MySQLOrderRepo | 真实数据库(Testcontainers) | 秒 |
| 端到端 | 整个六边形 | 全部真实依赖 | 分钟 |
内存适配器是最实用的技巧——它就是一个用 map 实现的 Repository:
// testutil/mem_repo.go
type MemOrderRepo struct {
mu sync.Mutex
data map[string]*order.Order
}
func (r *MemOrderRepo) Save(_ context.Context, o *order.Order) error {
r.mu.Lock()
defer r.mu.Unlock()
r.data[o.ID()] = o
return nil
}
有了它,90% 的业务逻辑测试不需要数据库。这也让测试反过来成为架构的守卫:如果某个用例测试非要连数据库才能跑,说明依赖方向反了。
6. 常见误区
| 误区 | 表现 | 修正 |
|---|---|---|
| 端口里塞技术细节 | 接口签名带 sql.Rows、HttpRequest | 端口用领域语言,纯业务类型 |
| 贫血领域 + 全在 Service | 实体只有 getter/setter | 把行为放回实体与值对象 |
| 为每个 CRUD 都建端口 | 接口爆炸、样板代码成灾 | 只对"会替换/需测试"的依赖建端口 |
| 内外层共享 DTO | 领域对象直接当 API 响应 | 边界处做映射,防止泄露 |
| 忽视组装层 | 领域层里 new(MySQLRepo) | 组装集中在 main/composition root |
| 过度分层 | 一个查询走五层 | 简单读路径可直接查库(CQRS 读模型) |
特别提醒最后一条:六边形是"写路径"的重武器。对复杂业务规则、多外部依赖的写操作,它收益巨大;对一个纯查询接口硬套端口与适配器,只会增加无谓的间接层。判断标准回到 架构设计基础原则 :依赖倒置是为了隔离"会变的东西",不变的东西不需要隔离。
7. 小结
| 要点 | 结论 |
|---|---|
| 核心机制 | 依赖倒置:业务定义接口,基础设施实现接口 |
| 六边形 | 端口(入站/出站)+ 适配器,内外对称 |
| 整洁架构 | 同心圆 + 唯一依赖规则(由外向内) |
| 与 DDD | 同一思想的术语变体,都以领域为中心 |
| 落地关键 | 包结构分层 + 组装层注入 + 内存适配器测试 |
| 适用判断 | 复杂写路径值得;简单 CRUD 别硬套 |
一句话记住:六边形与整洁架构都在回答同一个问题——如何让业务逻辑不被技术选择绑架。答案是让依赖箭头翻转:业务只依赖自己定义的抽象,框架、数据库、消息队列统统退居外层,成为可以随时替换的插件。做到这一点,你的核心逻辑就能脱离一切框架被测试、被复用、被长期演进。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。