六边形架构与整洁架构:端口、适配器与依赖倒置

六边形架构(端口与适配器)与整洁架构的工程落地:依赖倒置如何让领域逻辑摆脱框架与数据库、入站与出站端口的定义方式、整洁架构的同心圆与唯一依赖规则、包组织与组装层注入、内存适配器驱动的分层测试策略,以及与 DDD 分层、洋葱架构的关系和六个常见误区。

业务代码最容易被两样东西绑架:一是框架,二是基础设施。当"下单"这段核心逻辑里混进了 @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)说两件事:

  1. 高层模块不应依赖低层模块,两者都应依赖抽象;
  2. 抽象不应依赖细节,细节应依赖抽象。

翻译到代码里就是:由业务层定义接口(抽象),由基础设施层实现接口(细节)。依赖箭头于是翻转过来:

业务层定义接口 OrderRepository
        ▲
        │ 实现
基础设施层 MySQLOrderRepository

业务层不再知道 MySQL 的存在,只知道"有个东西能存订单"。谁实现它、用什么实现,是外层的事。这就是六边形与整洁架构共同的基石,也是与 领域驱动设计 中"领域层保持纯粹"一致的诉求。


2. 六边形架构:端口与适配器

2.1 端口与适配器模型

六边形架构(Hexagonal Architecture,又称端口与适配器 Ports and Adapters)由 Alistair Cockburn 提出。“六边形"只是示意图的形状——它表示内外两侧有多个对称的交互面,与边的数量无关。

  • 端口(Port):业务核心对外开放的接口。分为两类:
    • 入站端口(Driving Port):外部调用业务的入口,如 PlaceOrderUseCase;
    • 出站端口(Driven Port):业务调用外部的出口,如 OrderRepository、PaymentGateway。
  • 适配器(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 EvansUI/应用/领域/基础设施领域层在中心,基础设施在外

它们共同约束是依赖倒置 + 领域在中心。区别只在术语:六边形强调"对称的交互面",整洁架构强调"同心圆与依赖规则",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 别硬套

一句话记住:六边形与整洁架构都在回答同一个问题——如何让业务逻辑不被技术选择绑架。答案是让依赖箭头翻转:业务只依赖自己定义的抽象,框架、数据库、消息队列统统退居外层,成为可以随时替换的插件。做到这一点,你的核心逻辑就能脱离一切框架被测试、被复用、被长期演进。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「架构」更多文章

  1. 韧性工程与错误预算:从 SLO 到故障演练
  2. 微前端架构:组合、隔离与独立部署
  3. 数据网格(Data Mesh):领域数据产品与去中心化治理