六角架構 (Hexagonal Architecture) 是做什麼的?
六角架構(Ports and Adapters)讓業務邏輯不受框架、傳輸層與持久化細節的影響。核心應用取決於抽象連接埠(Ports),而配接器(Adapters)則在邊緣實作這些連接埠。
使用時機
- 建立需要長期可維護性與可測試性的新功能。
- 重構層次化或框架依賴過重的程式碼,其中領域邏輯與 I/O 關注點混雜在一起。
- 為同一個用例支援多種介面(如 HTTP、CLI、佇列工作者、排程任務)。
- 在無需改寫業務規則的情況下,更換基礎設施(資料庫、外部 API、訊息匯流排)。
當請求涉及邊界、領域驅動設計、重構緊耦合服務,或將應用邏輯與特定函式庫解耦時,請使用此技能。
核心概念
- 領域模型 (Domain model):業務規則與實體/值物件。不匯入任何框架。
- 用例(應用層) (Use cases (application layer)):編排領域行為與工作流程步驟。
- 輸入連接埠 (Inbound ports):描述應用程式能做什麼的合約(命令/查詢/用例介面)。
- 輸出連接埠 (Outbound ports):應用程式所需依賴項的合約(儲存庫、閘道器、事件發布器、時鐘、UUID 等)。
- 配接器 (Adapters):連接埠的基礎設施與傳送實作(HTTP 控制器、資料庫儲存庫、佇列消費者、SDK 包裝器)。
- 組合根 (Composition root):將具體配接器繫結到用例的單一注入位置。
輸出連接埠介面通常位於應用層(或僅當抽象確實屬於領域層級時才放在領域層),而基礎設施配接器則實作它們。
依賴方向始終向內:
- 配接器 -> 應用/領域層
- 應用層 -> 連接埠介面(輸入/輸出合約)
- 領域層 -> 領域層專屬抽象(無框架或基礎設施依賴)
- 領域層 -> 不對外依賴任何東西
運作方式
步驟 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或佇列後設資料中讀取。 - 在未經過領域/應用層映射的情況下,直接從用例回傳資料庫行資料。
- 讓配接器彼此直接呼叫,而不是透過用例連接埠流動。
- 將依賴注入分散到許多檔案中,並使用隱藏的單例全域變數。
遷移手冊
- 選擇一個經常變更且痛苦的垂直切片(單一端點/工作)。
- 使用明確的輸入/輸出的型別提取出一個用例邊界。
- 圍繞現有的基礎設施呼叫導入輸出連接埠。
- 將編排邏輯從控制器/服務移動到用例中。
- 保留舊的配接器,但它們改為委託給新的用例。
- 圍繞新的邊界添加測試(單元測試 + 配接器整合測試)。
- 逐個切片重複此過程;避免全面改寫。
重構既有的系統
- Strangler 方法:保持目前的端點,一次將一個用例透過新的連接埠/配接器路由。
- 避免大規模改寫:按功能切片遷移,並使用特徵化測試保留行為。
- 先建立外觀模式:在更換內部實作之前,先將舊版服務包裝在輸出連接埠之後。
- 凍結組合根:及早集中管理注入,讓新的依賴項不會洩漏到領域/用例層。
- 切片選擇規則:優先處理變更頻繁且影響範圍小的流程。
- 復原路徑:為每個遷移的切片保留可逆的切換開關或路由切換,直到生產行為得到驗證。
測試指南(相同的六角邊界)
- 領域測試:將實體/值物件作為純業務規則進行測試(無模擬、無框架設定)。
- 用例單元測試:使用輸出連接埠的虛擬實作/樁件測試編排邏輯;斷言業務結果與連接埠互動。
- 輸出配接器合約測試:在連接埠層級定義共享的合約套件,並針對每個配接器實作執行。
- 輸入配接器測試:驗證協定映射(HTTP/CLI/佇列承載到用例輸入,以及輸出/錯誤映射回協定)。
- 配接器整合測試:針對真實基礎設施(資料庫/API/佇列)進行序列化、綱要/查詢行為、重試與逾時測試。
- 端到端測試:涵蓋透過輸入配接器 -> 用例 -> 輸出配接器的關鍵使用者旅程。
- 重構安全性:在提取之前添加特徵化測試;在新的邊界行為穩定且等效之前保留它們。
最佳實務檢查清單
- 領域與用例層僅匯入內部型別與連接埠。
- 每個外部依賴項都由一個輸出連接埠表示。
- 驗證發生在邊界處(輸入配接器 + 用例不變量)。
- 使用不可變轉換(回傳新的值/實體,而不是變更共享狀態)。
- 跨邊界翻譯錯誤(基礎設施錯誤 -> 應用/領域錯誤)。
- 組合根是明確且易於審計的。
- 用例可以使用簡單的記憶體內虛擬連接埠進行測試。
- 重構從一個帶有行為保留測試的垂直切片開始。
- 語言/框架特性保留在配接器中,而非領域規則中。