7.2 用 interface 隔離外部依賴
interface 邊界重構的核心規則是由使用端定義需要的能力。介面的目的是讓 usecase 不依賴外部技術細節。
本章目標
學完本章後,你將能夠:
- 辨識哪些依賴值得用 interface 隔離
- 讓 interface 由使用端定義
- 設計小而穩定的 port
- 分辨 fake test 與 contract test
- 避免過早抽象與巨大 interface
【觀察】interface 是依賴邊界
interface 重構的核心目標是讓高層邏輯只依賴需要的能力。Go 的 interface 讓呼叫端不必知道具體實作。
過重依賴常見在這些地方:
- usecase 直接依賴
*sql.DB。 - handler 直接依賴 concrete service,測試很難替換。
- background worker 直接呼叫外部 API client。
- processor 直接知道 WebSocket hub。
- 測試為了建一個 usecase,必須初始化真資料庫、真檔案或真網路。
interface 的價值是讓 usecase 可以說:「我只需要儲存 notification」、「我只需要 append event」、「我只需要 publish message」。至於能力怎麼實作,是 adapter 的責任。
【判讀】先辨識外部依賴
外部依賴的核心特徵是慢、不穩、難測或帶有技術細節。這些依賴通常適合被 interface 隔離。
| 依賴 | 隔離原因 | 可能 interface |
|---|---|---|
| clock | 測試需要固定時間 | Clock 或 func() time.Time |
| repository | 儲存技術可替換 | NotificationRepository |
| [event log](/go/backend/knowledge-cards/event-log) | 記錄實作可替換 | EventLog |
| publisher | WebSocket、queue、log 都可能是輸出 | Publisher |
| external client | 網路失敗與測試替身 | NotificationSource |
| command runner | 外部程序慢且不穩 | CommandRunner |
不是所有型別都需要 interface。純資料 struct、簡單 helper、沒有替換需求的內部物件,通常先保持 concrete type 更清楚。
【策略】interface 放在使用端
interface 位置的核心規則是:誰需要這個能力,誰定義 interface。這會讓 interface 保持小,也避免 implementation 暴露太多方法。
usecase 需要儲存通知,就在 usecase 附近定義:
1type NotificationRepository interface {
2 Save(ctx context.Context, notification Notification) error
3 FindByID(ctx context.Context, id string) (Notification, bool, error)
4}in-memory adapter 只要方法集合符合,就自然實作這個 interface:
1type InMemoryNotificationRepository struct {
2 mu sync.RWMutex
3 notifications map[string]Notification
4}
5
6func (r *InMemoryNotificationRepository) Save(ctx context.Context, notification Notification) error {
7 r.mu.Lock()
8 defer r.mu.Unlock()
9 r.notifications[notification.ID] = notification
10 return nil
11}
12
13func (r *InMemoryNotificationRepository) FindByID(ctx context.Context, id string) (Notification, bool, error) {
14 r.mu.RLock()
15 defer r.mu.RUnlock()
16 notification, ok := r.notifications[id]
17 return notification, ok, nil
18}InMemoryNotificationRepository 不需要宣告自己 implements 了什麼。Go 的 implicit interface 讓實作端保持乾淨。
【執行】用小 port 隔離 event log
event log port 的核心語意是「可以記錄已發生的事件」。usecase 或 processor 不需要知道事件記錄到 memory、檔案還是資料庫。
1type EventLog interface {
2 Append(ctx context.Context, event DomainEvent) error
3}processor 依賴這個 port:
1type EventProcessor struct {
2 eventLog EventLog
3}
4
5func NewEventProcessor(eventLog EventLog) *EventProcessor {
6 return &EventProcessor{eventLog: eventLog}
7}
8
9func (p *EventProcessor) Process(ctx context.Context, event DomainEvent) error {
10 if err := event.Validate(); err != nil {
11 return fmt.Errorf("validate event: %w", err)
12 }
13 if err := p.eventLog.Append(ctx, event); err != nil {
14 return fmt.Errorf("append event log: %w", err)
15 }
16 return nil
17}這個 interface 很小,但它已經足夠讓 processor 測試脫離真正儲存實作。
【執行】publisher port 隔離輸出技術
publisher port 的核心語意是「把結果送出去」。即時推送可以用 WebSocket,非同步流程可以用 queue,測試可以用 recording fake。
1type Publisher interface {
2 Publish(ctx context.Context, event DomainEvent) error
3}processor 可以同時依賴 event log 與 publisher:
1type RecordingProcessor struct {
2 eventLog EventLog
3 publisher Publisher
4}
5
6func (p *RecordingProcessor) Process(ctx context.Context, event DomainEvent) error {
7 if err := p.eventLog.Append(ctx, event); err != nil {
8 return fmt.Errorf("append event: %w", err)
9 }
10 if err := p.publisher.Publish(ctx, event); err != nil {
11 return fmt.Errorf("publish event: %w", err)
12 }
13 return nil
14}這裡的 processor 不知道輸出是 WebSocket 還是 message queue。這就是 interface 邊界的目的。
【策略】clock 可以用函式,不一定要 interface
時間依賴的核心問題是測試需要固定現在時間。最小解法通常是注入函式,而不是建立完整 interface。
1type Clock func() time.Time
2
3type NotificationService struct {
4 now Clock
5}
6
7func NewNotificationService(now Clock) *NotificationService {
8 return &NotificationService{now: now}
9}使用時:
1func (s *NotificationService) NewNotification(id string, topic string) Notification {
2 return Notification{
3 ID: id,
4 Topic: topic,
5 CreatedAt: s.now(),
6 }
7}測試時傳固定時間:
1fixedNow := func() time.Time {
2 return time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
3}
4service := NewNotificationService(fixedNow)若只需要「現在時間」,函式比 Clock interface { Now() time.Time } 更簡單。Go 的抽象不一定要用 interface。
【判讀】小 interface 比大 interface 更穩定
小 interface 的核心好處是測試替身容易寫,使用端只知道自己需要的能力。巨大 interface 會把不相關 usecase 綁在一起。
不佳:
1type ApplicationService interface {
2 CreateNotification(ctx context.Context, cmd CreateNotificationCommand) error
3 ListNotifications(ctx context.Context, topic string) ([]Notification, error)
4 AppendEvent(ctx context.Context, event DomainEvent) error
5 Publish(ctx context.Context, event DomainEvent) error
6 RunSync(ctx context.Context) error
7}較佳:
1type NotificationCreator interface {
2 Create(ctx context.Context, cmd CreateNotificationCommand) error
3}
4
5type EventLog interface {
6 Append(ctx context.Context, event DomainEvent) error
7}
8
9type Publisher interface {
10 Publish(ctx context.Context, event DomainEvent) error
11}不同呼叫端依賴不同能力。handler 只依賴 creator,processor 只依賴 event log 與 publisher,worker 只依賴 source 與 processor。
【執行】fake test 驗證使用端行為
fake test 的核心目標是測使用端怎麼使用依賴。fake 可以很小,只實作測試需要的行為。
1type fakeEventLog struct {
2 appended []DomainEvent
3 err error
4}
5
6func (f *fakeEventLog) Append(ctx context.Context, event DomainEvent) error {
7 if f.err != nil {
8 return f.err
9 }
10 f.appended = append(f.appended, event)
11 return nil
12}測 processor:
1func TestEventProcessorAppendsEvent(t *testing.T) {
2 eventLog := &fakeEventLog{}
3 processor := NewEventProcessor(eventLog)
4
5 event := validDomainEvent()
6 if err := processor.Process(context.Background(), event); err != nil {
7 t.Fatalf("process event: %v", err)
8 }
9
10 if len(eventLog.appended) != 1 {
11 t.Fatalf("appended events = %d, want 1", len(eventLog.appended))
12 }
13}這個測試不關心 event log 如何保存資料。它只驗證 processor 在正確情境下呼叫了 port。
【執行】contract test 驗證 adapter 行為
contract test 的核心目標是讓不同 adapter 都符合 port 行為。這類測試測 implementation,不測 usecase。
1func runEventLogContract(t *testing.T, newLog func() EventLogWithList) {
2 t.Helper()
3
4 log := newLog()
5 event := validDomainEvent()
6
7 if err := log.Append(context.Background(), event); err != nil {
8 t.Fatalf("append event: %v", err)
9 }
10
11 events := log.List()
12 if len(events) != 1 {
13 t.Fatalf("events = %d, want 1", len(events))
14 }
15 if events[0].ID != event.ID {
16 t.Fatalf("event ID = %q, want %q", events[0].ID, event.ID)
17 }
18}
19
20type EventLogWithList interface {
21 EventLog
22 List() []DomainEvent
23}List 不一定屬於 production port,它可以是測試用輔助介面。若未來有 SQLite event log,也可以跑同一套 contract test。
【策略】避免過早抽象
避免過早抽象的核心判斷是:沒有替換、測試或技術隔離需求時,先用 concrete type。interface 不是越多越好。
先不要抽 interface 的情境:
- 只有一個 implementation。
- 測試不需要 fake。
- concrete type 很小,直接使用更清楚。
- interface 只是完整複製 concrete type 的所有方法。
- 邊界還不穩,方法很快會變。
可以抽 interface 的情境:
- usecase 不應依賴技術細節。
- 測試需要替換慢或不穩的依賴。
- 同一個能力有多種 implementation。
- 依賴跨越 package 邊界,使用端只需要小部分能力。
重構時可以先寫 concrete type,等第二個使用端或測試壓力出現,再抽出使用端 interface。
重構步驟
把 concrete dependency 改成 interface 時,可以按這個順序:
- 找出使用端真正呼叫的方法。
- 在使用端附近定義小 interface。
- 把 struct 欄位型別從 concrete type 改成 interface。
- 確認現有 concrete type 自然符合 interface。
- 在測試中建立 fake。
- 為 adapter 補 contract test。
- 移除不再需要的直接依賴。
不要先設計一個完美的全域介面。從使用端需要的最小方法開始,介面會更穩。
設計檢查
檢查一:interface 由使用端定義
implementation 定義的 interface 往往暴露太多方法。使用端定義 interface,才能只依賴自己真正需要的能力。
檢查二:有替換需求再建立 interface
Foo 搭配 FooInterface 不是 Go 的慣例。interface 應該來自使用需求,而不是來自型別存在。
檢查三:fake 服務當前測試行為
fake 是測試工具,不是真 adapter。它只需要支援測試情境,不需要重建資料庫、網路或完整狀態機。
檢查四:公開 interface 需要穩定承諾
一旦 interface 被多個 package 依賴,修改成本會提高。邊界還在探索時,保持 unexported 或使用 concrete type 更務實。
本章不處理
本章先處理 interface 如何讓使用端依賴能力;全專案 DI 框架與 mock generator,會在下列章節再往外延伸:
和 Go 教材的關係
這一章承接的是 usecase、adapter 與測試替身邊界;如果你要先回看語言教材,可以讀: