ports/adapters 遷移的核心目標是讓 application 與 domain 不依賴外部技術細節。HTTP、WebSocket、callback receiver、database 都是 adapters;usecase 透過 ports 使用它們。

這種重構不需要一次套完整架構。Go 專案更常見的做法是先把過重 handler、外部依賴與狀態寫入切開,再在壓力最大的邊界引入 port。

本章目標

學完本章後,你將能夠:

  1. 用依賴方向理解 ports/adapters
  2. 分辨 inbound adapter 與 outbound adapter
  3. 把 usecase 從 handler、repository、publisher 中切出
  4. 用新功能先走新架構的方式漸進遷移
  5. 驗證 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  ┘        │
45                      ports defined here
67storage/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 handlerHTTP requestcommand
WebSocket routerclient messagecommand
callback receiverexternal callbackdomain event
workertimer 或 queue itemcommand/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.gocmd/.../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,可以按這個順序:

  1. 先找一條最痛的功能路徑,例如新增 notification 或 ingest external event。
  2. 把 handler/router 中的規則抽成 command/usecase。
  3. 在 application 定義 repository、event log、publisher port。
  4. 讓現有 memory store 或 publisher 實作 port。
  5. main 組裝 concrete adapter 與 usecase。
  6. 新功能只走新路徑。
  7. 舊功能被修改時,逐步搬到同樣邊界。
  8. 用 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 教材的關係

這一章承接的是 handler、repository、event 與 composition root 的遷移路線;如果你要先回看語言教材,可以讀:

小結

ports/adapters 遷移的重點是控制依賴方向:adapter 處理外部技術,application 定義 usecase 與 ports,domain 保存核心語意。Go 專案可以漸進式遷移,新功能先走清楚邊界,舊功能在修改時再搬。架構的價值在於測試更直接、替換更容易、核心規則不被外部技術綁住。