关于 hexagonal-architecture
面向采用六边形架构项目的编码技能,通过端口和适配器组织应用边界。重点是依赖向内、用例不依赖具体供应商,以及使用内存假对象测试。
主要功能
- 01用端口划分内外边界
- 02通过适配器隔离供应商 SDK
- 03定义驱动与被驱动接口
- 04用手写假对象测试用例
- 05提供迁移与完整示例资源
适用场景
- 01为六边形架构代码库添加功能
- 02将供应商 SDK 与业务逻辑隔离
- 03不使用 SDK 模拟对象测试用例
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill hexagonal-architecture安装后在 Claude Code 中输入/hexagonal-architecture确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
resources/cqrs-lite.mdcross-cutting-concerns.mdgreenfield-sequence.mdincremental-adoption.mdreferences.mdtesting-hex-arch.mdworked-example.md详细介绍
六边形架构(端口与适配器)
此技能仅适用于已选择采用六边形架构的项目。请勿将这些模式用于采用其他架构的项目。有关如何在现有代码库中逐步引入六边形架构,请参阅 resources/incremental-adoption.md。
编写代码之前
请将本节作为指引阅读;技能的其余部分会解释原因。
第三方 SDK 的客户端类型绝不是端口。 OrdersApiClient、SmtpClient、PushClient、Stripe 客户端——任何由供应商包或仓库的 SDK 文件夹(src/lib/、vendor/、node_modules)导出的内容——都在六边形之外;任何导入它的文件也在六边形之外,包括使用 import type 的导入,以及 SDK 的 DTO 类型。字段为 SDK 客户端类型的依赖对象并不包含端口,无论测试中传入多少个伪对象。SDK 客户端与所需接口在结构上兼容,并不能成为跳过适配器的理由。
在已选择采用六边形架构的代码库中添加功能——按以下顺序进行;resources/worked-example.md 展示了完成后的结构:
- 将业务规则(时间窗口、筛选条件、收件人、主题行和消息文本、截断)放入纯内部函数中,以领域类型和
now值作为参数;适配器接收已处理好的值,只将其转换为 SDK 所需的格式。 - 在用例内部、紧邻用例处声明所需的端口,并将其导出为
interface:被驱动端口使用角色名词(OrderRepository、ReceiptSender、PaymentGateway),其方法使用自定义领域类型,绝不使用 SDK 的 DTO;驱动端口使用For<动词>ing<名词>的形式(ForPlacingOrders、ForSendingReceipts),由create<Capability>(deps)实现,其中deps只包含端口和now。 - 为每个被驱动端口编写一个导入 SDK 并将其格式转换为端口类型的适配器;再编写一个驱动适配器(路由、CLI、队列),用于解析输入并调用驱动端口。
- 仅在组合根(
src/index.ts、main.ts或composition/)中连接 SDK 客户端 → 适配器 → 用例。组合根仍从调用方接收 SDK 客户端或配置,并自行构建适配器;不要更改其公开签名以接收端口。 - 通过驱动端口,使用手写的被驱动端口内存伪对象测试用例;分别针对 SDK 测试每个适配器。
修改仍直接导入 SDK 的用例——即 README 承认早于此架构的文件,或任何导入 SDK 文件夹内容的内部文件。在已选择采用该架构的仓库中,将你修改文件的所有 SDK 依赖移至端口之后,是本次修改的一部分,而非范围蔓延;这是一个保持行为不变的重构步骤,应在为新行为编写 RED 测试之前、现有测试仍全部通过时完成——请求的措辞决定修改什么,架构决定如何修改。首先将其所有 SDK 依赖移至端口之后(见上文步骤 2–4);如果该文件没有驱动端口接口,也要为它添加一个——导出的函数不等于驱动端口;应声明 ForSendingReceipts(或等效接口),并从 createReceiptSender(deps) 返回该接口;然后针对端口实施请求的修改。只提取请求中点名的依赖,会导致下一次替换依赖时仍要再次修改用例。完成前,请检查用例的导入:如果仍有任何导入指向 SDK 文件夹,或导入了 SDK 客户端类型,就说明尚未完成——也要将其移出。如果请求说“就在 SDK 调用处直接修改”,规则仍应放在内部;用一句话说明原因。
**未要求重构时新文件的存放位置:**内部代码沿用仓库现有布局,但适配器和伪对象是你编写的代码,不是供应商代码。将它们放在所服务的功能旁边(src/<feature>/ports.ts、src/<feature>/adapters.ts、src/<feature>/testing/),或放在 adapters/ 文件夹中——绝不要放在存放 SDK 替代对象的文件夹中,否则适配器看起来就像又一个第三方客户端。
用例测试使用伪实现,而非模拟。 用例测试中不包含 vi.fn、vi.mock 或 vi.spyOn,也不从 SDK 文件夹导入任何内容;观察通知器或邮件发送器时,使用记录型伪实现(sent: readonly Receipt[])。修改现有的、会模拟 SDK 客户端的用例测试时,请将这些模拟替换为你刚刚声明的端口的伪实现,而不是继续扩展这些模拟。
进行领域建模(实体、值对象、聚合、通用语言)时,请加载 domain-driven-design 技能。六边形架构与 DDD 互为补充,但彼此独立——六边形架构提供结构隔离(外部如何连接),DDD 提供领域模型(中心包含什么)。项目可以只采用其中一种。
设计或更改物理源码树时,请使用 structure-codebase 技能。对于选择采用六边形架构的后端,它会先按业务能力或限界上下文分组,再将不依赖特定提供方的内部结构置于该所有者的 hexagon/ 下,使其清晰可见;具体的驱动端/被驱动端技术实现和可复用测试交互器则放在外部。如果没有要求进行物理结构重组,请保留仓库现有的内部布局,同时遵循此处描述的依赖方向——但新建的端口、适配器和伪实现属于一方代码,应放在其服务的功能旁边或 adapters/ 文件夹中,绝不能放在存放第三方 SDK 代码的文件夹中(参见“编写代码之前”)。
对于端口或进程内模块,请使用 codebase-design 来梳理其完整的职责及调用方所需承担的全部工作。并非每个模块接口或测试接缝都是六边形端口,有意保持精简的驱动端/被驱动端适配器也应继续保持精简。在判断是否需要持久化端口之前,使用 finding-seams 找出刻画紧耦合旧版行为所需的最小启用点。
深入参考资料位于 resources/ 目录中。按需加载:
| 资源 | 加载时机…… |
|---|---|
worked-example.md | 需要通过测试和文件映射追踪一个贯穿所有层的完整功能时 |
testing-hex-arch.md | 编写测试、创建伪实现、设置 createTestDb 或进行可替换性测试时 |
cqrs-lite.md | 读取操作需要跨聚合执行 JOIN,或需要分离读写路径时 |
cross-cutting-concerns.md | 确定身份验证、日志记录、事务或错误格式化的放置位置时 |
incremental-adoption.md | 将六边形架构引入现有代码库时 |
greenfield-sequence.md | 从零开始构建六边形项目,需要安排首批端口、适配器和测试的顺序时 |
references.md | 核对来源依据,尤其是端口/公共接口的命名时 |
权威来源及命名依据请参阅 resources/references.md。
核心概念
业务逻辑属于领域策略;用例和编排属于应用策略。外部系统通过端口(接口)和适配器(实现)连接。依赖指向内侧:领域保持纯粹,应用只能通过自己拥有的抽象了解外部能力,二者都不导入具体适配器。
- 领域代码不依赖任何框架、传输协议、持久化实现、时钟、UUID 生成器、供应商 SDK 或 UI 相关内容。将时间、标识符和外部事实作为有类型的输入提供。
- 驱动/输入适配器会解析不可信的外部输入,并将其转换为有类型的应用命令,然后调用应用用例。
- 应用服务负责协调用例、调用领域行为,并调用所需的任何端口。
- 被驱动/输出适配器实现由其内侧使用者拥有的端口,并在边界处转换特定技术的结果。
Driving (left) Driven (right)
┌──────────────────┐ ┌──────────────────┐
│ Route handlers │ │ Repositories │
│ CLI commands │──────┐┌────│ API clients │
│ Event listeners │ ││ │ Email services │
└──────────────────┘ ││ └──────────────────┘
call into ──────► ┌────┐ ◄────── implement
│ │
│ IN │
│ SI │
│ DE │
│ │
call into ──────► └────┘ ◄────── implement
┌──────────────────┐ ││ ┌──────────────────┐
│ Cron triggers │──────┘└────│ File storage │
│ Message queues │ │ Payment gateway │
└──────────────────┘ └──────────────────┘
驱动适配器(左侧):在应用上发起操作。它们会调用用例。
被驱动适配器(右侧):应用会调用它们。它们会实现端口接口。
这一模式本身是对称的:只有一条规则——内侧与外侧;应用对两侧连接的内容一无所知。左右不对称只体现在实现方式上,也就是谁了解谁:驱动适配器了解应用,并调用应用提供的驱动端口接口;应用只将被驱动适配器视为已注入的参数,这些参数实现了应用定义的端口接口。因此,驱动端口是提供接口,被驱动端口是所需接口。
该模式只定义两个区域——内侧和外侧,并未说明任一区域内部应如何组织。本技能中的领域/用例分层,是我们为保持内侧边界清晰而推荐的做法,并非该模式的一部分。
术语: 参与者是指边界之外任何具有行为的事物——人、数据库、其他程序或测试。驱动(=主要)参与者会主动与应用交互;被驱动(=次要)参与者则是应用会调用的对象。这是唯一适用于参与者、端口和适配器的形容词组合(入站/出站适用于端口、适配器和文件夹,但不适用于参与者)。交互器是直接接触端口的参与者或其适配器:并非每个参与者都需要适配器——测试、同级六边形应用以及程序间调用方,都可以直接使用端口接口。驱动端口构成应用的提供接口(API);被驱动端口构成应用的所需接口(SPI)。
端口 = 公共契约
端口是在内外边界处定义的一种有名称、有明确目的的交互。在 TypeScript 中,当边界具有公共性或架构意义时,应将端口表示为显式的 interface 类型:驱动适配器调用驱动端口接口,被驱动适配器/伪实现则实现被驱动端口接口。
端口由需要该抽象的最内层使用者拥有。驱动端口由应用拥有,因为它们暴露应用用例。面向基础设施的存储库和网关端口通常也由应用拥有,因为用例会使用它们。只有当领域模型本身确实拥有并使用这种交互时,端口才属于领域。在保持依赖反转的同时,不要混淆内侧拥有和领域拥有。
用例实现满足驱动端口。驱动适配器应依赖符合端口职责的接口,而不是依赖特定于框架的控制器、处理器类或具体适配器。
端口和公共接口的命名
从应用程序的视角出发,使用对话中的领域语言为每个端口命名。源模式会按对话意图为所有端口命名,包括驱动端和被驱动端:ForPlacingOrders、ForGettingTaxRates、ForStoringTickets。不过,该模式并未规定任何命名方式。本项目将意图名称用于驱动端口,并有意将角色名词(OrderRepository、PaymentGateway)用于被驱动端口,作为项目内部的选择:两种风格都使用目的名称,而非技术名称。如果代码库的被驱动端已经采用 For... 命名,请遵循源约定,不要更改。
| 边界 | 推荐名称 | 避免使用 | 原因 |
|---|---|---|---|
| 驱动端/公共 API | ForPlacingOrders、ForPledgingToOccasions | PlaceOrderUseCase、PlaceOrderHandler、OrderInputPort | 命名的是行动者的能力,而非模式 |
| 聚合持久化 | OrderRepository、ContributorRepository(或 ForStoringOrders) | OrderDatabase、OrderDao、SqlOrderPort | 命名用例所需的聚合访问能力 |
| 外部服务 | PaymentGateway、ExchangeRateProvider(或 ForPaying、ForObtainingRates) | StripeClient、HttpPaymentPort | 命名应用程序所需的能力,而非供应商/协议 |
| 通知/事件 | OrderEventPublisher、ReceiptSender(或 ForNotifyingContributors) | SnsAdapter、MessageBusPort | 命名业务侧的交互 |
公共接口命名规则:
- 使用
interface表示行为契约;对于更适合用type/schema 建模的数据结构,不要使用interface。 - 不要在接口前加
I,也不要在名称后加Interface;直接命名角色(PaymentGateway,而非IPaymentGateway或PaymentGatewayInterface)。 - 避免在类型名称中使用
Port。像PaymentPort这样的名称表达的是“架构”,而非“对话”。 - 避免在实现名称中使用
Impl。应命名具体技术或策略:createStripePaymentGateway、createFakePaymentGateway、createDrizzleOrderRepository。 - 优先使用角色接口,而非大而全的接口:只包含用例所需的方法,不要包含适配器恰好公开的所有方法。
- 让名称在更换适配器时保持稳定。如果从 Stripe 换到 PayPal、从 SQL 换到 DynamoDB,或从 HTTP 换到队列时必须重命名端口,说明端口名称泄露了基础设施细节。
// Driving port — exposed by the application, called by driving adapters
interface ForPlacingOrders {
readonly placeOrder: (command: PlaceOrderCommand) => Promise<PlaceOrderResult>;
}
// Driven port — application-owned because the use case consumes it
interface UserRepository {
readonly findById: (id: UserId) => Promise<User | undefined>;
readonly save: (user: User) => Promise<void>;
}
type PreparePaymentResult =
| { readonly success: true; readonly paymentId: PaymentId }
| { readonly success: false; readonly reason: PaymentFailure };
type PaymentOutcome =
| { readonly outcome: 'paid'; readonly chargeId: ChargeId }
| { readonly outcome: 'declined'; readonly reason: PaymentFailure }
| { readonly outcome: 'pending' };
// Driven port — application-owned because the use case consumes it
interface PaymentGateway {
readonly preparePayment: (request: {
readonly amount: Money;
readonly reference: OrderId;
readonly idempotencyKey: OrderId;
}) => Promise<PreparePaymentResult>;
readonly completePayment: (request: {
readonly paymentId: PaymentId;
readonly paymentInfo: PaymentInfo;
}) => Promise<PaymentOutcome>;
}
// Driven port — event publishing (outbound to message brokers)
interface OrderEventPublisher {
readonly publish: (event: OrderEvent) => Promise<void>;
}
端口设计原则:
- 按业务目的而非技术命名端口(
UserRepository,而非DatabasePort) - 保持端口职责明确——每个聚合或能力一个端口,而非一个包罗万象的端口
- 端口方法使用领域类型,不使用基础设施类型(不使用
SqlRow、HttpResponse),也不使用供应商 SDK 的 DTO 或消息类型(SDK 中的OrderDto、PushMessage属于基础设施类型——请自行声明Order/Receipt,并在适配器中完成映射) - 创建参数 schema 应与其描述的存储库端口放在一起
适配器 = 实现
适配器为特定技术实现端口。好的适配器应保持简单——在端口的领域类型与技术的原生类型之间进行转换,不包含业务逻辑。
// Driven adapter — implements the repository port using Drizzle/D1
const createDrizzleUserRepository = (db: D1Database): UserRepository => ({
findById: async (id) => {
const row = await db.select().from(users).where(eq(users.id, id)).get();
return row ? toUser(row) : undefined;
},
save: async (user) => {
await db.insert(users).values(toRow(user)).onConflictDoUpdate({ ... });
},
});
// Driven adapter — implements the same port for tests
const createFakeUserRepository = (initial: readonly User[] = []): UserRepository => {
const store = new Map(initial.map(u => [u.id, u]));
return {
findById: async (id) => store.get(id),
save: async (user) => { store.set(user.id, user); },
};
};
**适配器错误处理:**将预期内的比较并保存或唯一性 结果转换为明确的应用端口结果。意外的基础设施故障 (连接丢失、超时、磁盘故障)会传递到顶层 处理器。领域层不会捕获基础设施错误,因为它并不知道基础设施的存在。
type CreateUserResult =
| { readonly success: true }
| { readonly success: false; readonly reason: 'already-exists' };
// Driven adapter: translate an expected storage condition into port vocabulary
const createDrizzleUserCreator = (db: Database) => ({
create: async (user: User): Promise<CreateUserResult> => {
try {
await db.insert(users).values(toRow(user));
return { success: true };
} catch (e) {
if (isUniqueConstraintError(e)) {
return { success: false, reason: 'already-exists' };
}
throw e; // unexpected errors (connection lost, disk full) propagate
}
},
});
用例会穷尽处理 already-exists。意外的基础设施
错误会传递到顶层处理器。
**核心原则:**如果更换适配器需要修改领域代码,说明边界划分有误。
读取与写入(轻量级 CQRS)
并非所有读取都需要通过仓储进行。仓储模式强制遵守聚合边界——这对写入至关重要,但读取通常需要跨聚合执行 JOIN,以便展示数据。
| 操作 | 模式 | 示例 |
|---|---|---|
| 写入 | 仓储(单个聚合) | userRepo.save(user) |
| 读取(单个聚合) | 仓储 | userRepo.findById(id) |
| 读取(跨聚合,用于展示) | 查询函数(可自由使用 JOIN) | getEventDetail(db, eventId) |
查询函数也是驱动适配器——在以能力为先的布局中,它们位于所属方的提供方边界下(例如,packages/reporting/adapters/driven/postgres/queries/),并返回针对读取优化的 DTO。它们有意绕过仓储模式。
// Query function — JOINs across aggregates for display
// Lives at packages/reporting/adapters/driven/postgres/queries/, outside the hexagon
const getParticipantEventView = async (db: Database, eventId: string) => {
return db.select({ ... })
.from(events)
.innerJoin(occasions, ...)
.leftJoin(giftClaims, ...)
.where(eq(events.id, eventId))
.all();
};
函数内部不依赖提供方的代码可以解读查询结果,前提是这种解读体现了真正的业务含义(例如,“当前用户是否已认领此项目?”)。仅用于展示的格式化应留在驱动边界。查询负责获取数据;业务策略负责解读数据。
如需详细了解轻量级 CQRS,请参阅 resources/cqrs-lite.md。如果读写分离彻底实现——以事件作为事实来源,并通过重放重建读取模型——这就是事件溯源;请加载 event-sourcing 技能。
依赖注入与配置器
通过函数参数注入所有依赖。无需 DI 容器。驱动适配器收集非纯依赖,将其传递给用例,并根据结果执行操作——这就是 Seemann 的“非纯/纯/非纯三明治”(impure/pure/impure)。
// WRONG — creates dependencies internally (untestable, tightly coupled)
const createOrder = async (order: NewOrder) => {
const repo = new DrizzleOrderRepo(getDb()); // hardcoded
const gateway = new StripeGateway(process.env.KEY); // hardcoded
// ...
};
// RIGHT — dependencies injected into a use case implementation
type RecordPaymentResult =
| { readonly outcome: 'recorded'; readonly order: Order }
| { readonly outcome: 'conflict' };
type RecordChargeResult =
| { readonly outcome: 'recorded'; readonly order: Order }
| { readonly outcome: 'conflict' };
interface OrderRepository {
readonly findById: (id: OrderId) => Promise<Order | undefined>;
readonly findOrCreatePending: (
order: NewOrder & { readonly id: OrderId },
) => Promise<Order>;
readonly recordPayment: (
id: OrderId,
paymentId: PaymentId,
options: { readonly expectedVersion: number },
) => Promise<RecordPaymentResult>;
readonly recordCharge: (
id: OrderId,
chargeId: ChargeId,
options: { readonly expectedVersion: number },
) => Promise<RecordChargeResult>;
}
interface ForPlacingOrders {
readonly placeOrder: (order: NewOrder & { readonly id: OrderId }) => Promise<OrderResult>;
}
const createOrderPlacement = (
repo: OrderRepository,
gateway: PaymentGateway,
): ForPlacingOrders => ({
placeOrder: async (order) => {
const pending = await repo.findOrCreatePending(order);
if (pending.status === 'paid') return { success: true, order: pending };
let payable = pending;
if (payable.paymentId === undefined) {
const prepared = await gateway.preparePayment({
amount: payable.total,
reference: payable.id,
idempotencyKey: payable.id,
});
if (!prepared.success) return { success: false, reason: prepared.reason };
const paymentRecorded = await repo.recordPayment(payable.id, prepared.paymentId, {
expectedVersion: payable.version,
});
if (paymentRecorded.outcome === 'recorded') {
payable = paymentRecorded.order;
} else {
const current = await repo.findById(payable.id);
if (current?.status === 'paid') return { success: true, order: current };
if (current?.status !== 'pending' || current.paymentId === undefined) {
return { success: false, reason: 'concurrent-change' };
}
payable = current;
}
}
if (payable.paymentId === undefined) return { success: false, reason: 'concurrent-change' };
const payment = await gateway.completePayment({
paymentId: payable.paymentId,
paymentInfo: payable.payment,
});
if (payment.outcome === 'pending') return { success: false, reason: 'payment-pending' };
if (payment.outcome === 'declined') return { success: false, reason: payment.reason };
const recorded = await repo.recordCharge(payable.id, payment.chargeId, {
expectedVersion: payable.version,
});
if (recorded.outcome === 'recorded') return { success: true, order: recorded.order };
const current = await repo.findById(payable.id);
if (current?.status === 'paid' && current.chargeId === payment.chargeId) {
return { success: true, order: current };
}
return { success: false, reason: 'concurrent-change' };
},
});
findOrCreatePending 会在执行外部工作前提交订单,并拒绝使用相同数据以外的内容重用客户端提供的 ID。preparePayment 会创建提供方支付对象,但不会扣款;应用会先记录持久化的 paymentId,然后 completePayment 才能转移资金。崩溃或并发准备可能会留下未扣款的孤立对象,需要按订单引用进行对账或取消,但只有在 recordPayment 中胜出的支付 ID 才会被完成。例如,Stripe 适配器会为订单创建一个 PaymentIntent,之后再检索/确认同一个对象。
准备阶段的幂等键是用于短暂重试的优化,而非无限期的崩溃恢复:Stripe 可以在请求键至少存在 24 小时后将其清除。因此,较晚的重试绝不会为已经记录的支付 ID 创建替代项。completePayment 会检查持久化的提供方对象,仅在适当时确认它,并将非终态或含糊不明的提供方状态报告为 payment-pending,以便后续由 webhook/协调器跟进。如果进程在支付完成后、recordCharge 执行前停止,恢复流程会检查同一个提供方对象并记录同一笔扣款;乐观冲突中的失败方只有在已存储的正是该笔扣款时才会成功。无法提供持久化支付对象/引用的网关,需要采用明确限制重试范围并进行对账/补偿的工作流,而不是直接扣款后再保存。
组合根: 装配发生在应用入口——在此处根据环境/配置创建适配器,并将其注入用例。这是唯一了解具体实现的地方。
// Serverless executable entrypoint = inline composition + driving adapter
// Valid only while this object graph remains trivial and unshared.
export async function POST(request: Request) {
const { env } = getCloudflareContext();
const db = createDb(env.DB);
// Wire adapters
const repo = createDrizzleOrderRepository(db);
const gateway = createStripeGateway(env.STRIPE_KEY);
const orderPlacement: ForPlacingOrders = createOrderPlacement(repo, gateway);
// Translate transport syntax separately from request-schema validation.
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: 'malformed-json' }, { status: 400 });
}
const parsedBody = CreateOrderSchema.safeParse(rawBody);
if (!parsedBody.success) {
return NextResponse.json({ error: 'invalid-body' }, { status: 422 });
}
// Call use case
const result = await orderPlacement.placeOrder(parsedBody.data);
return NextResponse.json(result);
}
该处理程序只有在框架将其设为可执行部署入口点且图结构简单时,才可兼任这两个角色。内联装配代码应与请求转换代码在视觉上清晰区分。在 HTTP 边界上,格式错误的 JSON 属于传输语法错误(400);语法有效但未通过请求体或路径架构校验的输入属于无法处理的请求(422)。JSON 的 try/catch 范围应保持狭窄,避免将应用或基础设施故障转成客户端错误。在共享或非简单的宿主中,应在 main.ts 或 composition/ 中构建一次依赖图,并为路由提供已准备好的 ForPlacingOrders;普通路由模块绝不选择具体实现。这两个角色都不应包含业务逻辑。
**配置者:**了解所有参与者并将其引入系统的代码——此处的组合根——是该模式的第五个要素。构造函数注入(本技能的默认方式)是三种获准形式之一:setter 或 ForConfiguring... 函数允许被驱动适配器在系统运行时进行替换(风险:应用已构建却从未配置);依赖查找则将一个代理交给应用,由应用在调用时向其请求依赖。在测试中,测试用例本身同时充当配置者和驱动参与者;在生产环境中,则由组合根充当配置者。
非 HTTP 可执行入口点遵循相同的边界。作为部署入口点的队列回调可以内联装配简单的依赖图;普通消费者则接收已准备好的驱动端口,只负责解析、委托和转换:
// Queue deployment entrypoint = inline composition + driving adapter
const handlePledgeMessage = async (message: SQSMessage, env: Env) => {
const db = createDb(env.DB);
const occasionRepo = createDrizzleOccasionRepository(db);
const contributorRepo = createDrizzleContributorRepository(db);
const pledging: ForPledgingToOccasions = createPledgingToOccasions(occasionRepo, contributorRepo);
const dto = PledgeSchema.parse(JSON.parse(message.body));
await pledging.pledgeToOccasion({
...dto,
pledgeId: createPledgeId(message.messageId),
});
};
用例并不知道、也不关心它由 HTTP 请求、队列消息、定时任务还是 CLI 命令触发。messageId 在 SQS 重新投递时保持不变;pledge 持久化必须与 pledge 一起以原子方式确保 pledgeId 唯一。如果逻辑上相同的发布可能具有不同的 SQS ID,则应改用消息体中的生产者事件 ID。每个驱动适配器都应保持轻薄,只负责转换;只有可执行入口点例外可以同时选择一个简单的具体依赖图。
**命名:**用例接口和实现应以业务能力命名——ForPlacingOrders、createOrderPlacement、pledgeToOccasion。绝不要使用 CreateOrderUseCase 或 PlaceOrderHandler。模式后缀属于技术术语,不是领域语言。可以通过依赖项区分用例实现与领域函数——用例实现将被驱动端口(存储库、网关)作为参数;领域函数则只接收领域类型。
*术语说明:*在源模式文献中,“用例”是一种需求分析方法——其主要参与者和次要参与者分别对应驱动参与者和被驱动参与者,而端口最初按每个参与者各设一个。本技能将“用例”用于指实现驱动端口的编排对象。二者相关,但并不相同。
文件组织
这些是逻辑职责,并非强制使用的文件夹名称。重构范围涉及物理布局时,由 structure-codebase 技能负责,并建议在外部适配器旁显式设置 hexagon/。
| 职责 | 区域 | 内容 | 测试 |
|---|---|---|---|
| 领域策略 | 内部 | 纯业务规则、类型、状态转换 | 针对行为和属性的测试 |
| 应用策略 | 内部 | 不依赖提供方的编排、驱动端口实现、对被驱动端口的使用 | 使用测试交互器的用例测试 |
| 端口契约 | 内部,通常由应用层拥有 | 由最内层消费者拥有的、有明确目的的提供方/所需方交互 | 从每个端口的两端进行测试 |
| 被驱动适配器 | 外部 | 存储库实现、API 客户端、查询函数 | 集成/契约测试 |
| 驱动适配器 | 外部 | 路由处理程序、事件监听器、CLI/队列入口 | 传输层/E2E 测试 |
| 测试交互器 | 外部 | 测试驱动程序、伪造对象、可复用的行为契约测试套件 | 仅用于测试支持 |
| 组合根 | 可执行宿主 | 具体构建、配置、资源生命周期 | 启动/冒烟/E2E 覆盖 |
关键规则:
- 领域策略不依赖任何框架/基础设施,并且始终保持真正纯粹。
- 应用策略可以调用注入的端口,但不执行具体 I/O,并且可在进程内运行。
- 驱动和被驱动端口契约位于内部,紧邻其最内层消费者;通常,用例、存储库和网关端口由应用层拥有,而领域层仅拥有其真正使用的端口。
- 信任边界/线缆格式架构与负责解析它的适配器放在一起;如果领域层拥有的验证表达的是业务不变量,且不会泄漏提供方的表示形式,则可放在内部。
- 适配器导入内部公共契约,反之则不允许。
- 驱动适配器应保持精简——解析、验证身份、转换、委派、响应。
- 具体组装应靠近可执行入口点。简单的入口点可以内联组合;非简单的宿主则使用显式的组合根。
- 应用
structure-codebase时,使用其包/导入边界指导来强制落实这些职责。
测试策略
六边形架构的主要优势是可测试性。主要测试边界是用例——将被驱动端口替换为内存中的伪造对象(而非模拟对象)后调用它。这可以证明整个功能正常工作。
| 优先级 | 边界 | 验证内容 |
|---|---|---|
| 主要 | 用例(被驱动端口使用伪造对象) | 功能在六边形内部端到端正常工作 |
| 补充 | 领域纯函数 | 独立验证复杂业务规则 |
| 次要 | 被驱动适配器(真实 DB/MSW) | 适配器转换正确 |
| 验证 | E2E(完整技术栈) | 用户体验正常 |
**伪造对象优于模拟对象:**伪造对象实现真实接口并维护状态。模拟对象验证调用顺序,重构时容易失效。具体来说:用例测试不使用 vi.fn、vi.mock 或 vi.spyOn,也不从供应商 SDK 或仓库的 SDK 文件夹导入内容;它会为每个被驱动端口构建 createFake<Port>(),并断言伪造对象记录的状态(notifier.sent、repo.saved)。一旦开始修改现有的模拟 SDK 客户端的用例测试,就应将其转换为使用端口的伪造对象,而不是继续扩展模拟。详细模式请参见 resources/testing-hex-arch.md。
**只有经过测试的端口才是真正的端口。**每个端口都需要一个测试交互器——每个驱动端口都要有一个测试驱动程序,每个被驱动端口都要有一个伪造对象。缺少这些,所谓的“端口”就只是图表上的一条线;没有任何东西能确保它作为边界发挥作用。测试防线也可充当泄漏检测器:业务逻辑偏移到适配器,或技术细节渗入领域层,都会立即导致边界测试失败。
如需查看完整的实践示例,了解一个功能如何贯穿每一层(术语表 → 类型 → 领域 → 用例 → 适配器 → 测试 → 文件位置),请参见 resources/worked-example.md。
横切关注点
| 关注点 | 位置 | 原因 |
|---|---|---|
| 身份认证(你是谁?) | 驱动适配器 | 与协议相关(JWT、会话、API 密钥) |
| 授权(你是否有权限?) | 领域 | 关于权限的业务规则 |
| 技术遥测与关联 | 适配器(两侧) | 基础设施副作用 |
| 领域观察项(支持日志、业务指标) | 驱动端口(探针)或领域事件 | 具有业务意义的事实;像任何端口一样使用伪对象进行测试 |
| 事务 | 适配器 / 组合根 | 基础设施关注点,领域无需感知 |
| 错误格式化 | 驱动适配器 | 将领域结果转换为 HTTP/gRPC |
**领域永远不会导入日志记录器、捕获 HTTP 错误或管理事务。**它只返回结果——如果某个具有业务意义的事实无法传递到边界,就通过显式的驱动端口(领域探针)或领域事件来传达,而不是使用原始日志记录器。四层可观测性模型和详细模式请参阅 resources/cross-cutting-concerns.md;有关遥测内容(宽事件、SLO、告警),请参阅 observability 技能。
反模式
领域依赖基础设施
最常见的六边形架构违规。领域代码从框架、数据库或外部服务导入内容。
// ❌ Domain imports Drizzle
import { eq } from 'drizzle-orm';
export const findActiveUsers = async (db) => db.select()...
// ❌ Same violation, type-only: the use case is shaped by the vendor's client and DTO
import type { OrdersApiClient, OrderDto } from './lib/orders-api';
export const sendReceipts = async (deps: { ordersApi: OrdersApiClient }, customerId: string) => ...
// ✅ Application defines the contract it consumes; adapter implements it
interface UserRepository {
readonly findActive: () => Promise<readonly User[]>;
}
适配器中的业务逻辑
路由处理程序或仓储包含业务规则,而不是将其委托给领域。
// ❌ Business rule in route handler
export async function POST(request: Request) {
const order = await orderRepo.findById(id);
if (order.itemCount > 100) { await requireManagerApproval(order); } // business rule!
...
}
// ✅ Business rule in domain
const placeOrder = (order: Order): PlaceOrderResult => {
if (order.itemCount > 100) return { success: false, reason: 'requires-approval' };
...
};
绕过适配器
路由处理程序直接访问数据库,而不经过端口。
// ❌ Route handler hits DB directly
export async function GET(request: Request) {
const users = await db.select().from(users).where(eq(users.active, true));
...
}
// ✅ Route handler calls use case, which uses a port
const result = await getActiveUsers(userRepo);
端口泛滥
为每个微小抽象都创建端口。端口应代表有意义的边界——每个聚合一个(仓储),或每种外部能力一个(支付、电子邮件、身份验证)。
为领域概念创建端口
驱动端口代表与外部系统的交互——数据库、支付提供商、通知渠道。将内部领域抽象包装在端口接口中只会增加间接层,却没有需要保护的边界。如果六边形外部永远不会有任何组件通过该接口接入,那么它就不是端口。
嵌套六边形
六边形不会嵌套。端口与适配器的边界应位于技术(或团队权限)边缘;在此处维护系统级测试才值得。内部六边形边界会重复这道测试屏障;内部测试会逐渐失效,边界也不再真实存在。应改用模块、有界上下文或普通函数来组织内部结构。
体现技术形态的端口
暴露技术细节的端口方法。端口方法应使用领域语言。
// ❌ Technology leaks into port
interface UserRepository {
readonly findBySqlQuery: (sql: string) => Promise<User[]>;
readonly getFromRedisCache: (key: string) => Promise<User>;
}
// ✅ Business language
interface UserRepository {
readonly findActive: () => Promise<readonly User[]>;
readonly findById: (id: UserId) => Promise<User | undefined>;
}
体现模式形态的公开名称
宣扬架构模式而非业务职责的名称,会让代码更难阅读,也更容易被生搬硬套。
// ❌ Names the pattern or type mechanism
interface IPaymentPort {
readonly charge: (amount: Money, paymentInfo: PaymentInfo) => Promise<ChargeResult>;
}
const createPaymentGatewayImpl = (): IPaymentPort => ...
const placeOrderUseCase = async (...) => ...
// ✅ Names the role and the concrete adapter
interface PaymentGateway {
readonly charge: (amount: Money, paymentInfo: PaymentInfo) => Promise<ChargeResult>;
}
const createStripePaymentGateway = (): PaymentGateway => ...
const placeOrder = async (...) => ...
检查清单
此模式要求:
- 内部文件(用例、领域、端口)不得导入供应商包或仓库的 SDK 目录中的内容——即使是
import type也不行;由 SDK 客户端类型组成的 deps 对象不算一组端口(完成前检查用例的导入) - 所有外部边界都使用端口/公共契约;任何外部部分都不得越过端口访问内部内容
- 领域逻辑不依赖任何框架/基础设施(不得依赖任何 Actor 或适配器)
- 领域逻辑接收作为值传入的时间、生成的标识符和外部事实,而不是读取时钟、UUID 库或 SDK
- 驱动的 Actor 可在运行时配置——应用程序绝不在内部构造它们(此模式不限定配置器的形式;本项目默认采用参数注入)
- 端口只使用业务语言;端口方法绝不暴露技术类型
- 替换任何适配器都不需要更改领域代码
- 每个端口都有一个测试交互器——驱动侧使用测试驱动器,被驱动侧使用伪对象
此技能的补充要求(本项目风格):
- 如果已应用
structure-codebase,则可见的内部/外部包或导入规则已就位且通过检查 - 驱动适配器保持精简——解析、认证、转换、委派、响应;只有平凡的可执行入口例外也会负责组合
- 驱动适配器在调用用例前,将外部输入转换为类型化的应用命令
- 被驱动适配器(仓库)实现端口,且不包含业务逻辑
- 端口与最内层的使用方放在一起;用例、仓库和网关端口通常由应用程序持有,而领域层只持有其真正使用的端口
- 公共接口避免使用
I前缀、Interface后缀、Port后缀和Impl实现名称 - 驱动端口和用例的名称使用业务能力语言,而非
UseCase/Handler模式名称 - 线格式架构仅存在于信任边界;业务不变量验证留在内部,不泄漏提供方信息
- 跨聚合的 JOIN 读取使用查询函数(轻量级 CQRS)
- 每一层都在适当的级别进行行为测试
- 横切关注点(认证、技术遥测、事务)放在适配器中;具有领域意义的观察通过显式的被驱动端口或领域事件进行,绝不直接导入原始日志记录器
- 领域对预期结果返回结果类型,绝不因业务规则而抛出异常