端口不是接口清单
很多项目把“端口与适配器”做成目录表演:建一个 ports 包,把所有接口堆进去,再建一个 adapters 包,让领域代码继续直接调用数据库客户端。目录齐了,依赖方向没变。站内 六边形架构
已经讲过六边形架构的依赖倒置原则,这里不重复定义,只看 arch-lab 如何用测试证明这条原则真的成立。
三个端口与错误分类
arch-lab 卷一需要三个端口。OrderRepository 负责订单持久化,InventoryPort 负责库存预留与释放,PaymentPort 负责支付授权。端口定义在 internal/ports,只描述能力和失败分类:
// file: internal/order/order.go
// tier: L1
// verify: go test ./internal/order/
package order
type Status string
const (
StatusCreated Status = "CREATED"
)
type Item struct {
SKU string
Quantity int
UnitPriceCents int64
}
type Order struct {
ID string
CustomerID string
Status Status
Items []Item
Version int
}
// Clone 隔离调用方与仓储内部状态,契约测试会验证这一点。
func (o Order) Clone() Order {
o.Items = append([]Item(nil), o.Items...)
return o
}
领域包没有 import 任何端口或适配器。ports 反过来 import order 来引用领域类型,适配器再实现 ports 的接口。依赖方向是 order <- ports <- adapters,而不是领域包去 import 一个抽象的 ports。
// file: internal/ports/ports.go
// tier: L1
// verify: go test ./internal/ports/
package ports
import (
"context"
"errors"
"archlab/internal/order"
)
// 端口只定义"需要什么能力"和"失败如何分类",不关心实现。
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrUnavailable = errors.New("unavailable")
)
type OrderRepository interface {
Get(ctx context.Context, id string) (order.Order, error)
// Save 的 expectedVersion 为 0 表示创建;非 0 表示乐观锁更新。
Save(ctx context.Context, o order.Order, expectedVersion int) (order.Order, error)
}
type InventoryPort interface {
Reserve(ctx context.Context, orderID string, items []order.Item) (reservationID string, err error)
Release(ctx context.Context, reservationID string) error
}
type PaymentPort interface {
Authorize(ctx context.Context, orderID string, amountCents int64, currency string) (providerRef string, err error)
}
三个错误值不是装饰。ErrNotFound 表示请求的对象不存在,调用方可以返回 404;ErrConflict 表示版本冲突或状态不允许,调用方可以提示重试或拒绝;ErrUnavailable 表示依赖暂时不可用,调用方可以退避重试。适配器可以包装错误,但必须用 %w 保留分类,否则上层只能靠字符串匹配。
内存适配器
内存适配器是契约的第一个实现,也是单元测试的默认依赖。它要处理三件事:并发安全、值拷贝、乐观锁版本:
// file: internal/adapters/memory/order_repo.go
// tier: L1
// verify: go test ./internal/adapters/memory/
package memory
import (
"context"
"fmt"
"sync"
"archlab/internal/order"
"archlab/internal/ports"
)
type OrderRepo struct {
mu sync.RWMutex
orders map[string]order.Order
}
func NewOrderRepo() *OrderRepo {
return &OrderRepo{orders: make(map[string]order.Order)}
}
func (r *OrderRepo) Get(ctx context.Context, id string) (order.Order, error) {
r.mu.RLock()
defer r.mu.RUnlock()
o, ok := r.orders[id]
if !ok {
return order.Order{}, fmt.Errorf("%w: order %s", ports.ErrNotFound, id)
}
return o.Clone(), nil
}
func (r *OrderRepo) Save(ctx context.Context, o order.Order, expectedVersion int) (order.Order, error) {
r.mu.Lock()
defer r.mu.Unlock()
current, exists := r.orders[o.ID]
if !exists {
if expectedVersion != 0 {
return order.Order{}, fmt.Errorf("%w: order %s", ports.ErrNotFound, o.ID)
}
stored := o.Clone()
stored.Version = 1
r.orders[o.ID] = stored
return stored.Clone(), nil
}
if current.Version != expectedVersion {
return order.Order{}, fmt.Errorf("%w: order %s version %d, expected %d",
ports.ErrConflict, o.ID, current.Version, expectedVersion)
}
stored := o.Clone()
stored.Version = current.Version + 1
r.orders[o.ID] = stored
return stored.Clone(), nil
}
Clone 不是可选项。如果 Get 直接把 map 里的切片返回,调用方修改 Items[0].Quantity 就会绕过 Save 改掉“数据库”里的订单;契约测试会专门抓这个泄漏。
故障注入适配器
真实故障不会等到集成环境才出现。故障注入适配器包住任意 OrderRepository,按配置注入延迟或错误,同时保持错误分类:
// file: internal/adapters/faultinject/order_repo.go
// tier: L1
// verify: go test ./internal/adapters/faultinject/
package faultinject
import (
"context"
"fmt"
"time"
"archlab/internal/order"
"archlab/internal/ports"
)
// Config 注入延迟或错误,并保留 ports 的错误分类。
type Config struct {
Latency time.Duration
Err error
}
type OrderRepo struct {
ports.OrderRepository
cfg Config
}
func NewOrderRepo(inner ports.OrderRepository, cfg Config) *OrderRepo {
return &OrderRepo{OrderRepository: inner, cfg: cfg}
}
func (r *OrderRepo) before(ctx context.Context) error {
if r.cfg.Latency > 0 {
timer := time.NewTimer(r.cfg.Latency)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
return fmt.Errorf("%w: %v", ports.ErrUnavailable, ctx.Err())
}
}
if r.cfg.Err != nil {
return fmt.Errorf("faultinject: %w", r.cfg.Err)
}
return nil
}
func (r *OrderRepo) Get(ctx context.Context, id string) (order.Order, error) {
if err := r.before(ctx); err != nil {
return order.Order{}, err
}
return r.OrderRepository.Get(ctx, id)
}
func (r *OrderRepo) Save(ctx context.Context, o order.Order, expectedVersion int) (order.Order, error) {
if err := r.before(ctx); err != nil {
return order.Order{}, err
}
return r.OrderRepository.Save(ctx, o, expectedVersion)
}
延迟用 select 同时监听定时器和 ctx.Done(),超时返回 ErrUnavailable 而不是裸的 context.DeadlineExceeded。这样调用方的重试策略不需要区分“数据库慢”和“网络断”,统一按暂时不可用处理。
契约测试:同一组断言跑两种实现
契约测试放在 internal/contracttest,因为它要同时 import 内存适配器和故障适配器;如果放进 ports 或领域包,依赖方向立刻被破坏。测试对“工厂函数”编程,不关心具体实现:
// file: internal/contracttest/contract_test.go
// tier: L1
// verify: go test -race ./internal/contracttest/ -v
package contracttest
import (
"context"
"errors"
"testing"
"archlab/internal/adapters/faultinject"
"archlab/internal/adapters/memory"
"archlab/internal/order"
"archlab/internal/ports"
)
// runContract 是端口契约,不是某个适配器的单元测试。
// 任何实现都必须通过同一组断言,错误分类才算一致。
func runContract(t *testing.T, newRepo func() ports.OrderRepository) {
t.Helper()
ctx := context.Background()
newOrder := func() order.Order {
return order.Order{
ID: "o-1", CustomerID: "c-1", Status: order.StatusCreated,
Items: []order.Item{{SKU: "sku-1", Quantity: 2, UnitPriceCents: 500}},
}
}
t.Run("GetMissingReturnsNotFound", func(t *testing.T) {
if _, err := newRepo().Get(ctx, "missing"); !errors.Is(err, ports.ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
})
t.Run("SaveCreatesVersionOne", func(t *testing.T) {
repo := newRepo()
saved, err := repo.Save(ctx, newOrder(), 0)
if err != nil || saved.Version != 1 {
t.Fatalf("saved=%+v err=%v", saved, err)
}
})
t.Run("StaleVersionReturnsConflict", func(t *testing.T) {
repo := newRepo()
if _, err := repo.Save(ctx, newOrder(), 0); err != nil {
t.Fatal(err)
}
if _, err := repo.Save(ctx, newOrder(), 99); !errors.Is(err, ports.ErrConflict) {
t.Fatalf("err = %v, want ErrConflict", err)
}
})
}
func TestOrderRepositoryContract(t *testing.T) {
cases := []struct {
name string
newRepo func() ports.OrderRepository
}{
{"memory", func() ports.OrderRepository {
return memory.NewOrderRepo()
}},
{"faultinjectWithoutFaults", func() ports.OrderRepository {
return faultinject.NewOrderRepo(memory.NewOrderRepo(), faultinject.Config{})
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runContract(t, tc.newRepo)
})
}
}
func TestFaultInjectionKeepsErrorClass(t *testing.T) {
repo := faultinject.NewOrderRepo(memory.NewOrderRepo(), faultinject.Config{
Err: ports.ErrUnavailable,
})
if _, err := repo.Get(context.Background(), "o-1"); !errors.Is(err, ports.ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
}
这套契约的价值在于“同一组断言跑两遍”。内存适配器没有故障配置时,语义必须与故障适配器完全一致;一旦某个实现把“不存在”包装成普通 errors.New,errors.Is 立刻失败。InventoryPort 与 PaymentPort 用同样的方式各自维护契约,只是本节篇幅所限不展开。
依赖方向测试
契约测试证明“实现可替换”,还需要证明“领域包没有偷偷依赖适配器”。这里用 go list -deps 看真实的传递闭包,而不是 grep import:
// file: internal/contracttest/deps_test.go
// tier: L1
// verify: go test ./internal/contracttest/ -run TestDomainTransitiveDependencies -v
package contracttest
import (
"os/exec"
"strings"
"testing"
)
// TestDomainTransitiveDependenciesExcludeAdapters 用 go list -deps 看真实的
// 传递闭包。它尊重构建标签、解析真实 import,并会忽略注释与字符串里的假 import;
// 这些正是 grep 会误判或漏判的地方。
func TestDomainTransitiveDependenciesExcludeAdapters(t *testing.T) {
for _, pkg := range []string{
"./internal/order",
"./internal/ports",
} {
cmd := exec.Command("go", "list", "-deps", pkg)
cmd.Dir = "../.."
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go list -deps %s: %v\n%s", pkg, err, out)
}
for _, dep := range strings.Fields(string(out)) {
if dep == "archlab/internal/adapters" ||
strings.HasPrefix(dep, "archlab/internal/adapters/") {
t.Errorf("%s transitively depends on adapter package %s", pkg, dep)
}
}
}
}
为什么不用 grep -r "internal/adapters" internal/order?第一,grep 会把注释、字符串和文档里的示例也算进去,产生假阳性;第二,grep 只能看直接 import,看不到 order -> ports -> adapters 这种间接路径;第三,grep 不认识构建标签,可能检查了永远不会参与编译的文件;第四,包名损坏时 grep 仍然返回结果,而 go list -deps 会直接失败。go list 的代价是要求模块能加载,但这恰恰是 CI 应该知道的事实。
真实运行结果
以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在 .local/checks 的临时模块中执行:
$ go test -race ./... -count=1
? archlab/internal/adapters/faultinject [no test files]
? archlab/internal/adapters/memory [no test files]
ok archlab/internal/contracttest 2.018s
? archlab/internal/order [no test files]
? archlab/internal/ports [no test files]
$ go test -race ./internal/contracttest/ -v -count=1
--- PASS: TestOrderRepositoryContract (0.00s)
--- PASS: TestFaultInjectionKeepsErrorClass (0.00s)
--- PASS: TestDomainTransitiveDependenciesExcludeAdapters (0.72s)
PASS
ok archlab/internal/contracttest 1.995s
-race 在这里不是形式主义:内存适配器用 sync.RWMutex 保护 map,契约测试反复调用适配器时,竞态检测器能抓到漏锁。故障适配器不持有共享状态,因此重点在错误分类和 context 取消。
与 1.3 的 deplint 分工
1.3 的 deplint 检查包前缀的层间方向:order 不能 import adapters,platform 不能 import 领域包。它回答“这一行 import 是否越层”。本篇的依赖测试回答另一个问题:“order 的传递闭包里有没有 adapters”。前者是静态规则,后者是实际构建图;前者能给出精确的文件与 import,后者能抓到间接依赖和替换实现带来的漂移。两者不重复,也不能互相替代:deplint 看不到 order -> ports -> adapters 这种经过接口包的间接路径,go list -deps 不关心规则表里允许了哪些前缀。实践里两个都跑:deplint 快、定位准;依赖测试慢一点,但更接近编译器眼中的真相。
常见错误与边界
第一,把接口定义在领域包里。领域包一旦 import ports 才能使用接口,依赖方向就反了;让 ports import 领域类型,领域包保持零依赖,适配器实现 ports 的接口。
第二,契约测试只跑内存实现。内存实现永远“正确”,因为它就是测试作者写的;必须让故障适配器、数据库适配器跑同一套断言,才能发现错误分类漂移。
小结
端口与适配器的落地物不是目录,而是三样可验证的东西:端口接口与错误分类、至少两个实现共享的契约测试、以及一条断言领域包传递依赖的测试。arch-lab 用 order <- ports <- adapters 固定方向,用 errors.Is 固定错误语义,用 go list -deps 固定传递闭包。下一节处理更靠外的一层:当外部系统的模型与领域模型根本对不上时,如何用防腐层把差异挡在边界外。
阅读导航:上一节:4.1 模块化单体:先拿到边界再谈拆分 · 下一节:4.3 防腐层与外部系统隔离 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。