以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意,而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時,平面檔案會讓邊界越來越難看見。

Go package 是語意邊界,不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念;如果只能命名成 utilscommonhelpers,通常代表邊界還沒有想清楚。

這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段;只有當檔案切分已經無法表達業務邊界時,才需要把概念搬成更清楚的 domain package。

本章目標

學完本章後,你將能夠:

  1. 判斷何時該從平面多檔案拆出 package
  2. 用業務語意命名 package
  3. 依照純型別、純規則、usecase/repository 的順序搬移
  4. 避免 import cycle
  5. 用 type alias 與測試降低搬移風險

【觀察】平面多檔案是自然成長階段

平面 package 的核心價值是初期簡單。服務還小時,main.gomodels.gohandlers.gorepository.go 放在同一層,常常比一開始切十幾個資料夾更容易理解。

常見中間階段:

1notify/
2├── go.mod
3├── main.go
4├── models.go
5├── handlers.go
6├── repository.go
7├── events.go
8└── worker.go

這個結構不是問題本身。真正的問題通常出現在概念開始混在一起:HTTP request struct、domain state、event type、repository model 都放在 models.go;handler、worker、processor 都直接引用同一批可變資料。

【判讀】拆 package 的訊號是語意邊界變模糊

拆 package 的核心判斷是讀者是否能從結構看出概念邊界。若只是檔案變多,先拆檔案即可;若業務概念混在一起,才需要拆 package。

適合拆 package 的訊號:

  • models.go 同時包含 request DTO、domain state、response view。
  • 新增功能時不知道型別該放哪個檔案。
  • event、job、account 規則互相 import 或互相修改。
  • 測試一個 domain 規則必須初始化 handler 或 server。
  • package 內 unexported helper 太多,讀者很難判斷哪些屬於哪個概念。

不一定要拆 package 的情境:

  • 檔案只是稍長,但仍圍繞同一個概念。
  • 只有單一 main package 的小工具。
  • 邊界還不穩,拆完很可能馬上搬回來。
  • 只是為了符合某個目錄模板。

【策略】package 名稱要表達業務概念

domain package 的核心要求是名稱要讓讀者知道這裡負責哪組概念。jobeventaccountworkflowcommontypesutils 更有語意。

一個可能的拆分:

 1notify/
 2├── go.mod
 3├── main.go
 4├── domain/
 5│   ├── account/
 6│   ├── job/
 7│   ├── event/
 8│   └── workflow/
 9├── transport/
10│   └── http/
11└── storage/
12    └── memory/

這是示範語意方向的參考結構。小型服務也可以先只拆:

1notify/
2├── main.go
3├── notification/
4└── httpapi/

Go package 不需要層數多才算成熟。好的 package 是讓 import 讀起來自然:

1import "example.com/notify/domain/job"

如果 package 名稱只能叫 mischelpers,代表邊界還沒有清楚。

【執行】先搬純型別

搬移 package 的核心順序是先搬依賴最少的東西。純型別通常最安全,因為它不呼叫外部元件。

重構前:

 1// models.go
 2package main
 3
 4type JobStatus string
 5
 6const (
 7    JobStatusPending   JobStatus = "pending"
 8    JobStatusRunning   JobStatus = "running"
 9    JobStatusSucceeded JobStatus = "succeeded"
10    JobStatusFailed    JobStatus = "failed"
11)
12
13type JobProjection struct {
14    ID        string
15    Status    JobStatus
16    UpdatedAt time.Time
17}

重構後:

 1// domain/job/job.go
 2package job
 3
 4import "time"
 5
 6type Status string
 7
 8const (
 9    StatusPending   Status = "pending"
10    StatusRunning   Status = "running"
11    StatusSucceeded Status = "succeeded"
12    StatusFailed    Status = "failed"
13)
14
15type Projection struct {
16    ID        string
17    Status    Status
18    UpdatedAt time.Time
19}

使用端改成:

1import "example.com/notify/domain/job"
2
3var projection job.Projection

package 名稱已經提供語境,所以型別不必再叫 JobProjection。在 job package 裡叫 Projection 就夠清楚。

【策略】用 type alias 過渡

type alias 的核心用途是降低搬移風險。若一次改完所有 import 太大,可以先在舊位置保留 alias,讓既有程式逐步遷移。

 1// models.go
 2package main
 3
 4import "example.com/notify/domain/job"
 5
 6type JobStatus = job.Status
 7type JobProjection = job.Projection
 8
 9const (
10    JobStatusPending   = job.StatusPending
11    JobStatusRunning   = job.StatusRunning
12    JobStatusSucceeded = job.StatusSucceeded
13    JobStatusFailed    = job.StatusFailed
14)

這是過渡工具。等呼叫端逐步改成直接 import domain/job,再移除 alias。

type alias 適合降低大型搬移風險,但不要讓新舊命名長期並存,否則讀者會不知道哪個才是正式 API。

【執行】再搬純規則

純規則的核心特徵是輸入值、回傳值,不依賴 handler、repository 或外部 I/O。這類函式也適合早期搬入 domain package。

 1// domain/job/transition.go
 2package job
 3
 4import "fmt"
 5
 6func CanTransition(from Status, to Status) bool {
 7    switch from {
 8    case StatusPending:
 9        return to == StatusRunning || to == StatusFailed
10    case StatusRunning:
11        return to == StatusSucceeded || to == StatusFailed
12    default:
13        return false
14    }
15}
16
17func Transition(current Projection, next Status) (Projection, error) {
18    if !CanTransition(current.Status, next) {
19        return Projection{}, fmt.Errorf("invalid job status transition: %s -> %s", current.Status, next)
20    }
21    current.Status = next
22    return current, nil
23}

這些規則不應 import HTTP package,也不應知道 repository。它們是 domain 的穩定核心。

【判讀】domain 不依賴 adapter

避免 import cycle 的核心規則是低層 domain 不依賴高層 adapter。domain 可以被 HTTP、worker、repository 使用;但 domain 不應 import 這些外部層。

不良方向:

1domain/job -> transport/http -> domain/job

良好方向:

1transport/http -> application -> domain/job
2storage/memory -> domain/job

如果 domain/job 需要知道 HTTP request struct,代表 request DTO 沒有停在 transport layer。應把 HTTP request 轉成 command 或 domain value,再交給下層。

【執行】最後搬 repository/usecase

repository 和 usecase 的核心特徵是它們開始協調多個概念,所以搬移時要更謹慎。通常先搬 domain 型別與規則,再處理 application layer。

可能的結構:

 1notify/
 2├── domain/
 3│   ├── job/
 4│   └── event/
 5├── application/
 6│   ├── command.go
 7│   └── processor.go
 8├── transport/
 9│   └── http/
10└── storage/
11    └── memory/

application 可以協調 domain:

 1package application
 2
 3import (
 4    "context"
 5
 6    "example.com/notify/domain/event"
 7    "example.com/notify/domain/job"
 8)
 9
10type JobRepository interface {
11    Apply(ctx context.Context, projection job.Projection) error
12}
13
14type Processor struct {
15    jobs JobRepository
16}
17
18func (p *Processor) Process(ctx context.Context, e event.Event) error {
19    projection := job.Projection{
20        ID:     e.SubjectID,
21        Status: job.StatusRunning,
22    }
23    return p.jobs.Apply(ctx, projection)
24}

application 可以依賴多個 domain package,因為它負責協調 usecase。domain package 之間若互相依賴太多,通常代表邊界切得不對。

【策略】每次只搬一個邊界

package 重構的核心風險是 import 修改範圍太大。每次只搬一個語意邊界,測試通過後再搬下一個。

建議順序:

  1. domain/job 純型別。
  2. domain/job 純規則。
  3. 修正使用端 import。
  4. domain/event 純型別。
  5. 搬 event validation/normalize helper。
  6. 搬 application processor。
  7. 搬 adapter implementation。

不要同時搬 job、event、repository、handler。一次搬太多會讓失敗原因難以定位。

【執行】測試保護搬移

package 搬移的核心驗證是行為不變。搬檔案本身不是功能變更,所以測試應該確認原本行為仍然存在。

domain 規則測試:

1func TestJobCanTransition(t *testing.T) {
2    if !job.CanTransition(job.StatusPending, job.StatusRunning) {
3        t.Fatalf("pending should transition to running")
4    }
5    if job.CanTransition(job.StatusSucceeded, job.StatusRunning) {
6        t.Fatalf("succeeded should not transition to running")
7    }
8}

application 測試:

 1func TestProcessorAppliesJobProjection(t *testing.T) {
 2    repo := &fakeJobRepository{}
 3    processor := application.NewProcessor(repo)
 4
 5    err := processor.Process(context.Background(), event.Event{
 6        SubjectID: "job_1",
 7        Type:      event.JobStarted,
 8    })
 9    if err != nil {
10        t.Fatalf("process event: %v", err)
11    }
12
13    if repo.got.ID != "job_1" {
14        t.Fatalf("job ID = %q, want job_1", repo.got.ID)
15    }
16}

測試不需要關心檔案搬到哪裡,它只確認 package API 與行為仍然正確。

重構步驟

從平面 package 重構成 domain package,可以按這個順序:

  1. 列出現有檔案中的概念:request、response、domain state、event、repository、worker。
  2. 找出最穩定的 domain 名稱,例如 jobeventaccount
  3. 先建立一個 domain package,不要一次建立整棵架構。
  4. 搬純型別與 typed constant。
  5. 用 type alias 過渡大型呼叫端。
  6. 搬純規則與測試。
  7. 修正 import,避免 domain 依賴 adapter。
  8. 測試通過後,再搬下一個 domain。

設計檢查

檢查一:目錄跟著概念壓力成長

服務還小時,一次建立 domain/application/infrastructure/interfaces 可能只會增加跳轉成本。先拆最痛的語意邊界。

檢查二:package 名稱表達 domain 概念

modelstypeshelpers 通常不夠好。它們說明了程式碼形狀,沒有說明業務語意。

檢查三:domain package 保持技術無關

domain 應保存業務語意,不應知道傳輸協定。若 domain import adapter,依賴方向已經反了。

檢查四:搬移和行為變更分開

package 重構應先保持行為不變。若同時改規則與搬檔案,測試失敗時很難判斷是搬移錯誤還是行為改動。

本章不處理

本章先處理 package 結構如何反映 domain 語意;更大型的 module 拆分與 monorepo 策略,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 type、rule 與 package 邊界;如果你要先回看語言教材,可以讀: