6.6 如何新增 repository port
新增 repository port 的核心目標是讓 application layer 依賴資料能力,而不是依賴具體儲存技術。先建立 port,才能在 memory、SQLite 或其他資料庫之間替換。
本章目標
學完本章後,你將能夠:
- 判斷何時需要 repository port
- 由 usecase 定義小而明確的 repository interface
- 實作 map + mutex 的 in-memory repository
- 保留 context、error 與 database-ready 邊界
- 分開撰寫 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}方法名稱應該表達業務能力,而不是資料庫操作細節。ListByTopic 比 SelectWhereTopicEquals 更適合 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) errorerror 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 時,可以依序檢查:
- 是否真的需要共享讀寫邊界
- interface 是否由 usecase 需要定義
- 方法名稱是否表達業務能力
- 方法是否接收
context.Context - error 是否被包上操作脈絡
- in-memory implementation 是否保護 map
- 回傳 slice/map/pointer 時是否有 copy boundary
- usecase 測試是否使用 fake
- repository implementation 是否跑 contract test
- 是否避免把所有資料操作塞進巨大介面
設計檢查
檢查一: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;如果你要先回看語言教材,可以讀: