《后端架构设计·架构基础与边界》4.2 端口与适配器在 arch-lab 的落地:依赖测试与包边界

端口与适配器最容易退化成目录表演。本篇在 arch-lab 定义 OrderRepository、InventoryPort、PaymentPort 三个端口,给出内存适配器与可注入延迟/错误的故障适配器,用同一套契约测试验证 ErrNotFound、ErrConflict、ErrUnavailable 语义一致,并用 go list -deps 断言领域包的传递依赖不出现 adapters。

端口不是接口清单

很多项目把“端口与适配器”做成目录表演:建一个 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 防腐层与外部系统隔离 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查