新增 repository port 的核心目標是讓 application layer 依賴資料能力,而不是依賴具體儲存技術。先建立 port,才能在 memory、SQLite 或其他資料庫之間替換。

本章目標

學完本章後,你將能夠:

  1. 判斷何時需要 repository port
  2. 由 usecase 定義小而明確的 repository interface
  3. 實作 map + mutex 的 in-memory repository
  4. 保留 context、error 與 database-ready 邊界
  5. 分開撰寫 usecase fake test 與 repository contract test

【觀察】repository port 表達資料能力

repository port 的核心語意是 usecase 需要哪些資料能力。它應描述 application layer 的讀寫需求,而不是照搬資料庫 CRUD 方法或把所有資料操作塞進同一個巨大 Repository

例如通知服務的 usecase 可能需要三種能力:

usecase 需求repository 能力
建立通知儲存一筆 notification
查詢 topic 通知依 topic 列出 notification
避免重複建立依 ID 查詢 notification 是否存在

這些能力可以先用 memory 實作,未來再換成 SQLite、PostgreSQL 或外部服務。usecase 不應知道底層儲存技術。

【判讀】repository 服務共享讀寫邊界

是否需要 repository 的核心判斷是資料是否需要一致的讀寫邊界。暫時變數、單次函式內部結果或不共享的 cache,不一定需要 repository。

適合 repository 的情境:

  • 多個 usecase 需要一致讀寫同一組資料
  • 資料需要被測試替身取代
  • 讀寫規則需要集中
  • 未來可能從 memory 換成資料庫
  • 需要保護 map、slice 或 pointer 不被外部修改

不一定需要 repository 的情境:

  • 只在單一函式內暫存
  • 只是純計算結果
  • 還沒有共享或替換需求
  • 只有一個簡單 struct 可以直接傳遞

repository 是邊界工具,不是成熟專案的儀式。過早建立 repository 會讓小程式變得難讀。

【策略】interface 放在使用端

repository interface 的核心規則是由使用端定義需要的能力。usecase 需要什麼,就在 usecase 所在 package 定義什麼;adapter 或 infrastructure 實作它。

1type NotificationRepository interface {
2    Save(ctx context.Context, notification Notification) error
3    FindByID(ctx context.Context, id string) (Notification, bool, error)
4    ListByTopic(ctx context.Context, topic string) ([]Notification, error)
5}

方法名稱應該表達業務能力,而不是資料庫操作細節。ListByTopicSelectWhereTopicEquals 更適合 usecase。

先定義 domain model:

1type Notification struct {
2    ID        string
3    Topic     string
4    Title     string
5    CreatedAt time.Time
6}

domain model 不需要 JSON tag。對外 JSON 格式應交給 response struct,repository 儲存的是內部資料。

【執行】usecase 依賴 port,不依賴 implementation

usecase 的核心責任是協調資料能力與行為規則。它接收 repository port,而不是具體 memory repository。

 1type CreateNotificationCommand struct {
 2    ID        string
 3    Topic     string
 4    Title     string
 5    CreatedAt time.Time
 6}
 7
 8type NotificationService struct {
 9    repository NotificationRepository
10}
11
12func NewNotificationService(repository NotificationRepository) *NotificationService {
13    return &NotificationService{repository: repository}
14}

建立通知時,usecase 可以檢查重複與必填欄位:

 1func (s *NotificationService) Create(ctx context.Context, cmd CreateNotificationCommand) error {
 2    if strings.TrimSpace(cmd.ID) == "" {
 3        return fmt.Errorf("notification id is required")
 4    }
 5    if strings.TrimSpace(cmd.Topic) == "" {
 6        return fmt.Errorf("topic is required")
 7    }
 8
 9    if _, exists, err := s.repository.FindByID(ctx, cmd.ID); err != nil {
10        return fmt.Errorf("find notification: %w", err)
11    } else if exists {
12        return fmt.Errorf("notification %s already exists", cmd.ID)
13    }
14
15    notification := Notification{
16        ID:        cmd.ID,
17        Topic:     cmd.Topic,
18        Title:     cmd.Title,
19        CreatedAt: cmd.CreatedAt,
20    }
21
22    if err := s.repository.Save(ctx, notification); err != nil {
23        return fmt.Errorf("save notification: %w", err)
24    }
25
26    return nil
27}

這段 usecase 不知道資料存在 map、SQLite 或遠端 API。它只依賴「可以查詢與儲存 notification」這個能力。

【執行】memory implementation 要保護 map

in-memory repository 的核心責任是提供可用的儲存實作,同時保護共享 map。只要 repository 可能被多個 goroutine 使用,就應該用 mutex 保護。

 1type InMemoryNotificationRepository struct {
 2    mu            sync.RWMutex
 3    notifications map[string]Notification
 4}
 5
 6func NewInMemoryNotificationRepository() *InMemoryNotificationRepository {
 7    return &InMemoryNotificationRepository{
 8        notifications: make(map[string]Notification),
 9    }
10}

Save 寫入時要複製資料:

1func (r *InMemoryNotificationRepository) Save(ctx context.Context, notification Notification) error {
2    r.mu.Lock()
3    defer r.mu.Unlock()
4
5    r.notifications[notification.ID] = notification
6    return nil
7}

FindByID 回傳值與 bool:

 1func (r *InMemoryNotificationRepository) FindByID(ctx context.Context, id string) (Notification, bool, error) {
 2    r.mu.RLock()
 3    defer r.mu.RUnlock()
 4
 5    notification, ok := r.notifications[id]
 6    if !ok {
 7        return Notification{}, false, nil
 8    }
 9    return notification, true, nil
10}

ListByTopic 回傳新 slice,避免呼叫端修改內部資料:

 1func (r *InMemoryNotificationRepository) ListByTopic(ctx context.Context, topic string) ([]Notification, error) {
 2    r.mu.RLock()
 3    defer r.mu.RUnlock()
 4
 5    result := make([]Notification, 0)
 6    for _, notification := range r.notifications {
 7        if notification.Topic == topic {
 8            result = append(result, notification)
 9        }
10    }
11    return result, nil
12}

目前 Notification 只有值型別欄位,所以回傳值與新 slice 已足夠。若未來加上 slice、map 或 pointer 欄位,就要補 clone 函式。

【策略】context 和 error 是未來資料庫邊界

repository method 接收 context 的核心原因是未來可能出現 I/O。memory 實作可能不使用 context,但資料庫查詢、遠端 API 或 transaction 會需要取消與逾時。

1Save(ctx context.Context, notification Notification) error

error wrapping 的核心規則是保留失敗位置與原始錯誤:

1if err := s.repository.Save(ctx, notification); err != nil {
2    return fmt.Errorf("save notification: %w", err)
3}

不要過早抽象 transaction。只有當一個 usecase 真的需要多筆寫入同時成功或失敗時,再設計 transaction boundary。否則 repository interface 會提前背負資料庫細節。

【判讀】fake 和 in-memory 用於不同測試

測試替身的核心差異是 fake 服務 usecase 測試,in-memory implementation 服務 repository 行為測試。兩者可以長得像,但用途不同。

usecase 測試可以用簡單 fake:

 1type fakeNotificationRepository struct {
 2    existing map[string]Notification
 3    saved    []Notification
 4    err      error
 5}
 6
 7func (f *fakeNotificationRepository) Save(ctx context.Context, notification Notification) error {
 8    if f.err != nil {
 9        return f.err
10    }
11    f.saved = append(f.saved, notification)
12    return nil
13}
14
15func (f *fakeNotificationRepository) FindByID(ctx context.Context, id string) (Notification, bool, error) {
16    if f.err != nil {
17        return Notification{}, false, f.err
18    }
19    notification, ok := f.existing[id]
20    return notification, ok, nil
21}
22
23func (f *fakeNotificationRepository) ListByTopic(ctx context.Context, topic string) ([]Notification, error) {
24    return nil, nil
25}

這個 fake 只支援測試需要的行為,不必成為完整 repository。

【執行】usecase 測試檢查行為規則

usecase 測試的核心目標是驗證 command 進來後是否呼叫正確資料能力。它不應測 memory map 的 lock 或 copy 行為。

 1func TestNotificationServiceCreate(t *testing.T) {
 2    repo := &fakeNotificationRepository{
 3        existing: make(map[string]Notification),
 4    }
 5    service := NewNotificationService(repo)
 6
 7    err := service.Create(context.Background(), CreateNotificationCommand{
 8        ID:        "ntf_1",
 9        Topic:     "deployments",
10        Title:     "Deploy finished",
11        CreatedAt: time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC),
12    })
13    if err != nil {
14        t.Fatalf("create notification: %v", err)
15    }
16
17    if len(repo.saved) != 1 {
18        t.Fatalf("saved notifications = %d, want 1", len(repo.saved))
19    }
20}

重複 ID 測試可以讓 fake 回傳 existing notification:

 1func TestNotificationServiceCreateDuplicate(t *testing.T) {
 2    repo := &fakeNotificationRepository{
 3        existing: map[string]Notification{
 4            "ntf_1": {ID: "ntf_1"},
 5        },
 6    }
 7    service := NewNotificationService(repo)
 8
 9    err := service.Create(context.Background(), CreateNotificationCommand{
10        ID:        "ntf_1",
11        Topic:     "deployments",
12        Title:     "Deploy finished",
13        CreatedAt: time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC),
14    })
15    if err == nil {
16        t.Fatalf("expected duplicate error")
17    }
18}

這些測試讓 usecase 不依賴具體 repository implementation。

【執行】repository contract test 保護實作行為

repository contract test 的核心目標是讓不同 implementation 遵守同一組行為。memory repository、SQLite repository 或其他實作都可以跑同一套測試。

 1func TestNotificationRepositoryContract(t *testing.T) {
 2    runNotificationRepositoryContract(t, func() NotificationRepository {
 3        return NewInMemoryNotificationRepository()
 4    })
 5}
 6
 7func runNotificationRepositoryContract(t *testing.T, newRepo func() NotificationRepository) {
 8    t.Helper()
 9
10    repo := newRepo()
11    ctx := context.Background()
12    notification := Notification{
13        ID:        "ntf_1",
14        Topic:     "deployments",
15        Title:     "Deploy finished",
16        CreatedAt: time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC),
17    }
18
19    if err := repo.Save(ctx, notification); err != nil {
20        t.Fatalf("save notification: %v", err)
21    }
22
23    got, ok, err := repo.FindByID(ctx, "ntf_1")
24    if err != nil {
25        t.Fatalf("find notification: %v", err)
26    }
27    if !ok {
28        t.Fatalf("notification should exist")
29    }
30    if got.Topic != "deployments" {
31        t.Fatalf("topic = %q, want deployments", got.Topic)
32    }
33
34    list, err := repo.ListByTopic(ctx, "deployments")
35    if err != nil {
36        t.Fatalf("list notifications: %v", err)
37    }
38    if len(list) != 1 {
39        t.Fatalf("notifications = %d, want 1", len(list))
40    }
41}

contract test 不需要知道實作細節。它只驗證 port 承諾的行為。

【策略】小介面比萬用 repository 穩定

repository interface 的核心風險是變成所有資料操作的大型介面。大型介面會讓每個 usecase、fake 與測試都被迫依賴不需要的方法。

不佳:

1type Repository interface {
2    SaveNotification(ctx context.Context, notification Notification) error
3    ListNotifications(ctx context.Context) ([]Notification, error)
4    SaveJob(ctx context.Context, job JobProjection) error
5    SaveAccount(ctx context.Context, account Account) error
6    AppendEvent(ctx context.Context, event DomainEvent) error
7}

較佳:

1type NotificationRepository interface {
2    Save(ctx context.Context, notification Notification) error
3    FindByID(ctx context.Context, id string) (Notification, bool, error)
4    ListByTopic(ctx context.Context, topic string) ([]Notification, error)
5}

不同 usecase 可以定義不同 port。若未來多個 port 由同一個 database adapter 實作,那是 adapter 的事,不必讓 usecase 共享巨大介面。

實作檢查清單

新增 repository port 時,可以依序檢查:

  1. 是否真的需要共享讀寫邊界
  2. interface 是否由 usecase 需要定義
  3. 方法名稱是否表達業務能力
  4. 方法是否接收 context.Context
  5. error 是否被包上操作脈絡
  6. in-memory implementation 是否保護 map
  7. 回傳 slice/map/pointer 時是否有 copy boundary
  8. usecase 測試是否使用 fake
  9. repository implementation 是否跑 contract test
  10. 是否避免把所有資料操作塞進巨大介面

設計檢查

檢查一:repository 來自 usecase 需求

repository 應該來自 usecase 需求。先建一個萬用 Repository,通常會讓介面快速膨脹,測試也變得笨重。

檢查二:interface 放在使用端

若 interface 是由 implementation 定義,usecase 會被迫接受 implementation 想暴露的能力。Go 更常見的做法是讓使用端定義最小需求。

檢查三:memory repository 保護內部資料

回傳內部 slice、map 或 pointer 會讓呼叫端繞過 repository 規則。即使目前只是 memory 實作,也應保護資料擁有權。

檢查四:transaction 和 ORM 等需求出現再抽象

沒有跨多筆寫入一致性需求時,transaction 介面只會增加複雜度。先把 context、error、port 邊界放好,等需求出現再擴展。

本章不處理

本章先處理 repository port 如何表達資料能力;特定資料庫、ORM 與 transaction,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 interface、state 與 application boundary;如果你要先回看語言教材,可以讀: