7.5 以 domain 重新整理 package
以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意,而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時,平面檔案會讓邊界越來越難看見。
Go package 是語意邊界,不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念;如果只能命名成 utils、common 或 helpers,通常代表邊界還沒有想清楚。
這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段;只有當檔案切分已經無法表達業務邊界時,才需要把概念搬成更清楚的 domain package。
本章目標
學完本章後,你將能夠:
- 判斷何時該從平面多檔案拆出 package
- 用業務語意命名 package
- 依照純型別、純規則、usecase/repository 的順序搬移
- 避免 import cycle
- 用 type alias 與測試降低搬移風險
【觀察】平面多檔案是自然成長階段
平面 package 的核心價值是初期簡單。服務還小時,main.go、models.go、handlers.go、repository.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 的核心要求是名稱要讓讀者知道這裡負責哪組概念。job、event、account、workflow 比 common、types、utils 更有語意。
一個可能的拆分:
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 名稱只能叫 misc 或 helpers,代表邊界還沒有清楚。
【執行】先搬純型別
搬移 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.Projectionpackage 名稱已經提供語境,所以型別不必再叫 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 修改範圍太大。每次只搬一個語意邊界,測試通過後再搬下一個。
建議順序:
- 搬
domain/job純型別。 - 搬
domain/job純規則。 - 修正使用端 import。
- 搬
domain/event純型別。 - 搬 event validation/normalize helper。
- 搬 application processor。
- 搬 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,可以按這個順序:
- 列出現有檔案中的概念:request、response、domain state、event、repository、worker。
- 找出最穩定的 domain 名稱,例如
job、event、account。 - 先建立一個 domain package,不要一次建立整棵架構。
- 搬純型別與 typed constant。
- 用 type alias 過渡大型呼叫端。
- 搬純規則與測試。
- 修正 import,避免 domain 依賴 adapter。
- 測試通過後,再搬下一個 domain。
設計檢查
檢查一:目錄跟著概念壓力成長
服務還小時,一次建立 domain/application/infrastructure/interfaces 可能只會增加跳轉成本。先拆最痛的語意邊界。
檢查二:package 名稱表達 domain 概念
models、types、helpers 通常不夠好。它們說明了程式碼形狀,沒有說明業務語意。
檢查三:domain package 保持技術無關
domain 應保存業務語意,不應知道傳輸協定。若 domain import adapter,依賴方向已經反了。
檢查四:搬移和行為變更分開
package 重構應先保持行為不變。若同時改規則與搬檔案,測試失敗時很難判斷是搬移錯誤還是行為改動。
本章不處理
本章先處理 package 結構如何反映 domain 語意;更大型的 module 拆分與 monorepo 策略,會在下列章節再往外延伸:
和 Go 教材的關係
這一章承接的是 type、rule 與 package 邊界;如果你要先回看語言教材,可以讀: