六角架構 是做什麼的?
六角架構(Ports and Adapters)讓業務邏輯獨立於框架、傳輸及持久化細節。核心應用程式依賴於抽象的埠,而邊緣的適配器則實作這些埠。
使用時機
- 在開發需要長期維護性與可測試性的新功能時。
- 重構層級式或框架重度程式碼,當領域邏輯與 I/O 問題混合在一起時。
- 支援同一用例的多種介面(HTTP、CLI、佇列工作者、cron 任務)。
- 更換基礎設施(資料庫、外部 API、訊息匯流排)時,無需重寫業務規則。
當請求涉及邊界、領域驅動設計、重構緊密耦合服務,或將應用邏輯與特定函式庫解耦時,請使用此技能。
核心概念
- 領域模型:業務規則與實體/值物件。不包含框架匯入。
- 用例(應用層):編排領域行為與工作流程步驟。
- 輸入埠:描述應用程式能做什麼的合約(命令/查詢/用例介面)。
- 輸出埠:應用程式所需依賴的合約(儲存庫、閘道器、事件發佈者、時鐘、UUID 等)。
- 適配器:輸入與輸出埠的基礎設施與交付實作(HTTP 控制器、資料庫儲存庫、佇列消費者、SDK 包裝器)。
- 組合根:將具體適配器繫結到用例的單一注入位置。
輸出埠介面通常位於應用層(或僅當抽象確實是領域層級時才放在領域層),而基礎設施的適配器則實作它們。
依賴方向永遠向內:
- 適配器 -> 應用程式/領域
- 應用程式 -> 埠介面(輸入/輸出合約)
- 領域 -> 僅限領域的抽象(無框架或基礎設施依賴)
- 領域 -> 無外部依賴
運作方式
步驟 1:建模用例邊界
定義單一用例,包含清晰的輸入與輸出 DTO。將傳輸細節(Express req、GraphQL context、任務酬載包裝器)保留在此邊界之外。
步驟 2:先定義輸出埠
將每個副作用識別為一個埠:
- 持久化(
UserRepositoryPort) - 外部呼叫(
BillingGatewayPort) - 橫切關注點(
LoggerPort、ClockPort)
埠應建模能力,而非技術。
步驟 3:以純編排實作用例
用例類別/函式透過建構子/參數接收埠。它驗證應用層級的不變條件、協調領域規則,並回傳純資料結構。
步驟 4:在邊緣建構適配器
- 輸入適配器將通訊協定輸入轉換為用例輸入。
- 輸出適配器將應用合約映射到具體 API/ORM/查詢建構器。
- 映射保持在適配器內,而非用例內部。
步驟 5:在組合根中注入所有依賴
實例化適配器,然後將它們注入用例。保持此注入集中化,以避免隱藏的服務定位器行為。
步驟 6:按邊界測試
- 使用虛擬埠對用例進行單元測試。
- 使用真實基礎設施依賴對適配器進行整合測試。
- 透過輸入適配器對使用者面向流程進行端到端測試。
架構圖
flowchart LR
Client["客戶端 (HTTP/CLI/Worker)"] --> InboundAdapter["輸入適配器"]
InboundAdapter -->|"呼叫"| UseCase["用例 (應用層)"]
UseCase -->|"使用"| OutboundPort["輸出埠 (介面)"]
OutboundAdapter["輸出適配器"] -->|"實作"| OutboundPort
OutboundAdapter --> ExternalSystem["資料庫/API/佇列"]
UseCase --> DomainModel["領域模型"]
建議模組佈局
使用以功能優先的組織方式,並帶有明確邊界:
src/
features/
orders/
domain/
Order.ts
OrderPolicy.ts
application/
ports/
inbound/
CreateOrder.ts
outbound/
OrderRepositoryPort.ts
PaymentGatewayPort.ts
use-cases/
CreateOrderUseCase.ts
adapters/
inbound/
http/
createOrderRoute.ts
outbound/
postgres/
PostgresOrderRepository.ts
stripe/
StripePaymentGateway.ts
composition/
ordersContainer.ts
TypeScript 範例
埠定義
export interface OrderRepositoryPort {
save(order: Order): Promise<void>;
findById(orderId: string): Promise<Order | null>;
}
export interface PaymentGatewayPort {
authorize(input: { orderId: string; amountCents: number }): Promise<{ authorizationId: string }>;
}
用例
type CreateOrderInput = {
orderId: string;
amountCents: number;
};
type CreateOrderOutput = {
orderId: string;
authorizationId: string;
};
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepositoryPort,
private readonly paymentGateway: PaymentGatewayPort
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const order = Order.create({ id: input.orderId, amountCents: input.amountCents });
const auth = await this.paymentGateway.authorize({
orderId: order.id,
amountCents: order.amountCents,
});
// markAuthorized 回傳一個新的 Order 實例;它不會原地修改。
const authorizedOrder = order.markAuthorized(auth.authorizationId);
await this.orderRepository.save(authorizedOrder);
return {
orderId: order.id,
authorizationId: auth.authorizationId,
};
}
}
輸出適配器
export class PostgresOrderRepository implements OrderRepositoryPort {
constructor(private readonly db: SqlClient) {}
async save(order: Order): Promise<void> {
await this.db.query(
"insert into orders (id, amount_cents, status, authorization_id) values ($1, $2, $3, $4)",
[order.id, order.amountCents, order.status, order.authorizationId]
);
}
async findById(orderId: string): Promise<Order | null> {
const row = await this.db.oneOrNone("select * from orders where id = $1", [orderId]);
return row ? Order.rehydrate(row) : null;
}
}
組合根
export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => {
const orderRepository = new PostgresOrderRepository(deps.db);
const paymentGateway = new StripePaymentGateway(deps.stripe);
return new CreateOrderUseCase(orderRepository, paymentGateway);
};
多語言對應
在不同的生態系統中遵循相同的邊界規則;僅語法與注入方式有所不同。
- TypeScript/JavaScript
- 埠:
application/ports/*作為介面/型別。 - 用例:帶有建構子/參數注入的類別/函式。
- 適配器:
adapters/inbound/*、adapters/outbound/*。 - 組合:明確的工廠/容器模組(無隱藏全域變數)。
- 埠:
- Java
- 套件:
domain、application.port.in、application.port.out、application.usecase、adapter.in、adapter.out。 - 埠:
application.port.*中的介面。 - 用例:純類別(Spring
@Service是可選的,非必要)。 - 組合:Spring 配置或手動注入類別;將注入保持在領域/用例類別之外。
- 套件:
- Kotlin
- 模組/套件鏡像 Java 分割(
domain、application.port、application.usecase、adapter)。 - 埠:Kotlin 介面。
- 用例:帶有建構子注入的類別(Koin/Dagger/Spring/手動)。
- 組合:模組定義或專用的組合函式;避免服務定位器模式。
- 模組/套件鏡像 Java 分割(
- Go
- 套件:
internal/<feature>/domain、application、ports、adapters/inbound、adapters/outbound。 - 埠:由消費端應用程式套件擁有的小型介面。
- 用例:帶有介面欄位與明確
New...建構函式的結構體。 - 組合:在
cmd/<app>/main.go(或專用的注入套件)中注入,保持建構函式明確。
- 套件:
應避免的反模式
- 領域實體匯入 ORM 模型、網頁框架型別或 SDK 客戶端。
- 用例直接從
req、res或佇列元資料讀取。 - 用例直接回傳資料庫行,而沒有經過領域/應用程式的映射。
- 讓適配器直接互相呼叫,而不是透過用例的埠。
- 將依賴注入分散到許多檔案中,並使用隱藏的全域單例。
遷移手冊
- 選擇一個垂直切片(單一端點/任務),且經常變更帶來痛苦。
- 提取用例邊界,包含明確的輸入/輸出型別。
- 圍繞現有的基礎設施呼叫引入輸出埠。
- 將控制器/服務中的編排邏輯移到用例中。
- 保留舊的適配器,但讓它們委派給新的用例。
- 在新的邊界周圍添加測試(單元測試 + 適配器整合測試)。
- 逐個切片重複;避免全面重寫。
重構現有系統
- 扼殺者模式:保留當前端點,一次將一個用例路由到新的埠/適配器。
- 無大爆炸重寫:按功能切片遷移,並透過特徵化測試保留行為。
- 先建外觀:在替換內部實作之前,先將舊服務包裝在輸出埠之後。
- 組合凍結:早期集中注入,使新依賴不會洩漏到領域/用例層中。
- 切片選擇規則:優先選擇變更頻繁且影響範圍小的流程。
- 回滾路徑:在生產行為得到驗證之前,為每個遷移的切片保留可逆切換或路由開關。
測試指引(相同的六角邊界)
- 領域測試:將實體/值物件作為純業務規則進行測試(無模擬、無框架設定)。
- 用例單元測試:使用輸出埠的虛擬/樁件測試編排;斷言業務結果與埠互動。
- 輸出適配器合約測試:在埠層級定義共享合約套件,並針對每個適配器實作執行。
- 輸入適配器測試:驗證通訊協定映射(HTTP/CLI/佇列酬載到用例輸入,以及輸出/錯誤回映射到通訊協定)。
- 適配器整合測試:針對真實基礎設施(資料庫/API/佇列)執行,測試序列化、綱要/查詢行為、重試與逾時。
- 端到端測試:涵蓋關鍵的使用者旅程,從輸入適配器 -> 用例 -> 輸出適配器。
- 重構安全性:在提取之前添加特徵化測試;在新建的邊界行為穩定且等效之前,保留這些測試。
最佳實踐檢查清單
- 領域與用例層僅匯入內部型別與埠。
- 每個外部依賴都由一個輸出埠表示。
- 在邊界處進行驗證(輸入適配器 + 用例不變條件)。
- 使用不可變轉換(回傳新的值/實體,而非修改共享狀態)。
- 錯誤在邊界之間進行轉換(基礎設施錯誤 -> 應用程式/領域錯誤)。
- 組合根明確且易於審計。
- 用例可使用簡單的記憶體虛擬埠進行測試。
- 重構從一個垂直切片開始,並保留行為的測試。
- 語言/框架特性僅保留在適配器中,絕不進入領域規則。