7.6 逐步遷移到 ports/adapters 架構
ports/adapters 遷移的核心目標是讓 application 與 domain 不依賴外部技術細節。HTTP、WebSocket、callback receiver、database 都是 adapters;usecase 透過 ports 使用它們。
這種重構不需要一次套完整架構。Go 專案更常見的做法是先把過重 handler、外部依賴與狀態寫入切開,再在壓力最大的邊界引入 port。
本章目標
學完本章後,你將能夠:
- 用依賴方向理解 ports/adapters
- 分辨 inbound adapter 與 outbound adapter
- 把 usecase 從 handler、repository、publisher 中切出
- 用新功能先走新架構的方式漸進遷移
- 驗證 import direction、usecase test 與 adapter integration test
【觀察】ports/adapters 是依賴方向
ports/adapters 的核心規則是外部技術依賴 application,而不是 application 依賴外部技術。HTTP、WebSocket、database、queue 都是可替換的邊界;usecase 應該依賴自己定義的 port。
目標方向:
1transport/http ┐
2transport/websocket ├─> application ──> domain
3transport/callback ┘ │
4 ▼
5 ports defined here
6 ▲
7storage/memory ┐ │
8eventlog/memory ├───────┘
9publisher/websocket ┘資料夾名稱可以不同。真正重要的是 import direction:domain 不 import HTTP,application 不 import database implementation,adapter import application 並實作 application 需要的 port。
【判讀】inbound adapter 把外部輸入轉成 command
inbound adapter 的核心責任是接收外部訊號,轉成 application command 或 domain event。它不應直接修改 state,也不應保存業務規則。
常見 inbound adapter:
| adapter | 輸入 | 轉換結果 |
|---|---|---|
| HTTP handler | HTTP request | command |
| WebSocket router | client message | command |
| callback receiver | external callback | domain event |
| worker | timer 或 queue item | command/event |
例如 HTTP adapter:
1type HTTPNotificationHandler struct {
2 usecase *application.CreateNotificationUsecase
3 now func() time.Time
4}
5
6func (h HTTPNotificationHandler) Create(w http.ResponseWriter, r *http.Request) {
7 var req createNotificationRequest
8 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
9 writeError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON")
10 return
11 }
12
13 cmd := application.CreateNotificationCommand{
14 ID: req.ID,
15 Topic: req.Topic,
16 Title: req.Title,
17 CreatedAt: h.now(),
18 }
19
20 if err := h.usecase.Execute(r.Context(), cmd); err != nil {
21 writeUsecaseError(w, err)
22 return
23 }
24
25 w.WriteHeader(http.StatusCreated)
26}HTTP adapter 知道 JSON、status code、request body。usecase 不知道這些協定細節。
【判讀】outbound adapter 實作 application port
outbound adapter 的核心責任是實作 application 定義的 port。application 說「我需要儲存 notification」,adapter 決定用 memory、SQLite 或其他技術完成。
application 定義 port:
1package application
2
3type NotificationRepository interface {
4 Save(ctx context.Context, notification domain.Notification) error
5 FindByID(ctx context.Context, id string) (domain.Notification, bool, error)
6}memory adapter 實作:
1package memory
2
3type NotificationRepository struct {
4 mu sync.RWMutex
5 notifications map[string]domain.Notification
6}
7
8func (r *NotificationRepository) Save(ctx context.Context, notification domain.Notification) error {
9 r.mu.Lock()
10 defer r.mu.Unlock()
11 r.notifications[notification.ID] = notification
12 return nil
13}
14
15func (r *NotificationRepository) FindByID(ctx context.Context, id string) (domain.Notification, bool, error) {
16 r.mu.RLock()
17 defer r.mu.RUnlock()
18 notification, ok := r.notifications[id]
19 return notification, ok, nil
20}adapter import application 不一定必要,因為 Go implicit interface 不要求顯式宣告。只要 method set 符合,組裝時就能傳給 usecase。
【策略】usecase 是遷移中心
usecase 的核心角色是協調 domain 規則與 ports。它不處理 HTTP,也不操作具體資料庫。
1package application
2
3type CreateNotificationUsecase struct {
4 repository NotificationRepository
5 eventLog EventLog
6}
7
8func NewCreateNotificationUsecase(repository NotificationRepository, eventLog EventLog) *CreateNotificationUsecase {
9 return &CreateNotificationUsecase{
10 repository: repository,
11 eventLog: eventLog,
12 }
13}
14
15func (u *CreateNotificationUsecase) Execute(ctx context.Context, cmd CreateNotificationCommand) error {
16 notification := domain.Notification{
17 ID: cmd.ID,
18 Topic: cmd.Topic,
19 Title: cmd.Title,
20 CreatedAt: cmd.CreatedAt,
21 }
22
23 if err := u.repository.Save(ctx, notification); err != nil {
24 return fmt.Errorf("save notification: %w", err)
25 }
26
27 event := domain.NewNotificationCreated(notification)
28 if err := u.eventLog.Append(ctx, event); err != nil {
29 return fmt.Errorf("append event: %w", err)
30 }
31
32 return nil
33}這個 usecase 只依賴 domain 與 ports。HTTP handler、WebSocket router、memory repository、[event log](/go/backend/knowledge-cards/event-log) implementation 都在外面。
【執行】組裝放在 main 或 composition root
composition root 的核心責任是建立 concrete implementation,並把它們接到 usecase 與 adapter。Go 專案常把這件事放在 main.go 或 cmd/.../main.go。
1func main() {
2 notificationRepo := memory.NewNotificationRepository()
3 eventLog := memory.NewEventLog()
4
5 createNotification := application.NewCreateNotificationUsecase(
6 notificationRepo,
7 eventLog,
8 )
9
10 handler := httpadapter.NewNotificationHandler(createNotification, time.Now)
11
12 mux := http.NewServeMux()
13 mux.HandleFunc("POST /notifications", handler.Create)
14
15 server := &http.Server{
16 Addr: ":8080",
17 Handler: mux,
18 }
19
20 if err := server.ListenAndServe(); err != nil {
21 log.Fatal(err)
22 }
23}main 可以知道所有具體實作,因為它負責組裝。這不違反依賴方向;問題是 application 或 domain 不能反過來 import main、HTTP adapter 或 memory adapter。
【策略】新功能先走新架構
漸進式遷移的核心策略是新功能先走新邊界,舊功能在被修改時再搬。一次性大重構風險高,容易同時改壞行為與結構。
建議做法:
- 新 endpoint 直接建立 command/usecase。
- 新 repository 先定義小 port。
- 新 event flow 先走
DomainEvent與 processor。 - 舊 handler 只有在新增需求或修 bug 時才拆。
- 保留舊路徑測試,搬移完成再刪掉。
這樣可以讓新架構逐步長出來,而不是一次強迫整個專案符合模板。
【執行】從 callback ingestion 開始切
以外部 callback 進入事件系統為例,application usecase 可以叫 IngestExternalEvent:
1package application
2
3type EventLog interface {
4 Append(ctx context.Context, event domain.Event) error
5}
6
7type EventProcessor interface {
8 Process(ctx context.Context, event domain.Event) error
9}
10
11type IngestExternalEvent struct {
12 eventLog EventLog
13 processor EventProcessor
14}
15
16func (u *IngestExternalEvent) Execute(ctx context.Context, event domain.Event) error {
17 if err := event.Validate(); err != nil {
18 return fmt.Errorf("validate event: %w", err)
19 }
20 if err := u.eventLog.Append(ctx, event); err != nil {
21 return fmt.Errorf("append event: %w", err)
22 }
23 if err := u.processor.Process(ctx, event); err != nil {
24 return fmt.Errorf("process event: %w", err)
25 }
26 return nil
27}callback adapter 只負責 raw input 轉 domain event:
1func (h CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2 var raw RawNotificationCallback
3 if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
4 writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON")
5 return
6 }
7
8 event, err := NormalizeNotificationCallback(raw, time.Now())
9 if err != nil {
10 writeError(w, http.StatusBadRequest, "invalid_payload", "invalid callback payload")
11 return
12 }
13
14 if err := h.ingest.Execute(r.Context(), event); err != nil {
15 writeError(w, http.StatusInternalServerError, "ingest_failed", "event ingest failed")
16 return
17 }
18
19 w.WriteHeader(http.StatusAccepted)
20}這個切法讓 callback 格式停在 adapter,event log 與 processor 行為停在 application。
【判讀】WebSocket adapter 也是 inbound adapter
WebSocket adapter 的核心責任是把 client message 轉成 command。它不應直接知道 repository 或 event log implementation。
1type WebSocketAdapter struct {
2 router *MessageRouter
3}
4
5type MessageRouter struct {
6 subscriptions application.SubscriptionUsecase
7}router 可以呼叫 application usecase:
1func (r *MessageRouter) handleSubscribeTopic(ctx context.Context, clientID string, req SubscribeTopicRequest) ServerMessage {
2 cmd := application.SubscribeTopicCommand{
3 ClientID: clientID,
4 Topic: req.Topic,
5 IncludeHistory: req.IncludeHistory,
6 }
7
8 if err := r.subscriptions.SubscribeTopic(ctx, cmd); err != nil {
9 return ErrorMessage("", "subscribe_failed", "topic subscription failed")
10 }
11
12 return OKMessage("", map[string]string{"topic": req.Topic})
13}這和 HTTP handler 的方向相同:adapter 處理協定,application 處理行為。
【執行】驗證 import direction
架構邊界的核心驗證是 import direction。即使沒有工具,也可以用簡單規則檢查:
1domain 不 import application、transport、storage
2application 可以 import domain,不 import transport/storage implementation
3transport 可以 import application/domain
4storage 可以 import application/domain
5cmd/main 可以 import 所有 adapter 與 application 做組裝可以用 go list 觀察 package 依賴:
1go list -deps ./...也可以在 review 時檢查:如果 domain/job import 了 net/http,幾乎一定是邊界錯了;如果 application import 了 storage/memory,則 usecase 已經依賴 implementation。
【執行】usecase test 與 adapter integration test 分工
測試分工的核心原則是 usecase 測規則,adapter 測協定轉換與組裝。不要只靠端到端測試保護所有行為。
usecase test:
1func TestIngestExternalEventAppendsAndProcesses(t *testing.T) {
2 eventLog := &fakeEventLog{}
3 processor := &fakeEventProcessor{}
4 usecase := &application.IngestExternalEvent{
5 eventLog: eventLog,
6 processor: processor,
7 }
8
9 event := validDomainEvent()
10 if err := usecase.Execute(context.Background(), event); err != nil {
11 t.Fatalf("ingest event: %v", err)
12 }
13
14 if len(eventLog.appended) != 1 {
15 t.Fatalf("appended events = %d, want 1", len(eventLog.appended))
16 }
17 if len(processor.processed) != 1 {
18 t.Fatalf("processed events = %d, want 1", len(processor.processed))
19 }
20}adapter integration test 則可以用 httptest 驗證 request/response 與 usecase fake 是否被呼叫。兩種測試分工清楚,失敗時才知道是規則錯還是協定轉換錯。
重構步驟
逐步遷移到 ports/adapters,可以按這個順序:
- 先找一條最痛的功能路徑,例如新增 notification 或 ingest external event。
- 把 handler/router 中的規則抽成 command/usecase。
- 在 application 定義 repository、event log、publisher port。
- 讓現有 memory store 或 publisher 實作 port。
- main 組裝 concrete adapter 與 usecase。
- 新功能只走新路徑。
- 舊功能被修改時,逐步搬到同樣邊界。
- 用 import direction review 防止反向依賴。
設計檢查
檢查一:遷移從單一邊界開始
ports/adapters 的價值是依賴方向。若沒有先拆 usecase 與 port,只是搬資料夾,複雜度會上升但邊界不會變清楚。
檢查二:application 依賴 port
application 應依賴 port,不依賴 memory、SQLite 或 database adapter。若 application import storage,依賴方向已經反了。
檢查三:業務規則留在 application 或 domain
adapter 可以驗證輸入格式,但業務規則應該在 usecase 或 domain。否則 HTTP 與 WebSocket 會各自複製規則。
檢查四:port 跟著使用端分散
port 應靠近使用端。把所有 interface 集中到一個大型 package,常會讓依賴重新糾纏在一起。
本章不處理
本章先處理 ports/adapters 的依賴方向;分散式系統、資料庫與平台 wiring,會在下列章節再往外延伸:
- Go 進階:跨節點與平台整合
- Go 進階:資料庫 transaction 與 schema migration
- Go 進階:Durable queue、outbox 與 idempotency
- Go 進階:Kubernetes、systemd 與 load balancer 合約
和 Go 教材的關係
這一章承接的是 handler、repository、event 與 composition root 的遷移路線;如果你要先回看語言教材,可以讀:
小結
ports/adapters 遷移的重點是控制依賴方向:adapter 處理外部技術,application 定義 usecase 與 ports,domain 保存核心語意。Go 專案可以漸進式遷移,新功能先走清楚邊界,舊功能在修改時再搬。架構的價值在於測試更直接、替換更容易、核心規則不被外部技術綁住。