6.3 如何擴展狀態投影欄位
擴展狀態投影欄位的核心流程是先確認欄位屬於 domain state、read model 還是 response view。欄位加在哪一層,會決定寫入規則、相容性與測試方式。
本章目標
學完本章後,你將能夠:
- 分辨 domain state、read model 與 response view
- 判斷新欄位的零值是否有語意
- 把狀態轉移集中在 repository 或 state owner
- 用 copy boundary 保護內部 slice/map
- 測試 state transition、repository copy 與 JSON response
【觀察】先判斷欄位屬於哪一層
狀態欄位的核心問題是「這個欄位代表哪一種資料責任」。同一個欄位放在不同層,代表不同寫入規則與相容性承諾。
| 層次 | 意義 | 範例 |
|---|---|---|
| domain state | 影響業務規則與狀態轉移 | job 是否 running、failed、completed |
| projection/read model | 方便查詢、列表或即時顯示 | 最近更新時間、目前進度百分比 |
| response view | 只影響對外輸出格式 | 顯示文字、前端用 badge 顏色 |
例如「job 狀態」是 domain state,因為它會影響是否能重試、取消或完成。相反地,「狀態顯示文字」通常是 response view,因為它只是把內部狀態轉成 client 更容易顯示的文字。
本章使用一個簡化的 job 狀態投影作為範例。事件進入系統後,repository 會把事件套用成目前 projection,再由 response layer 輸出給 client。
【判讀】domain state 要用明確型別
domain state 的核心規則是用型別表達可用狀態,而不是讓任意字串在系統裡流動。當欄位會影響規則時,應該優先考慮 typed constant。
1type JobStatus string
2
3const (
4 JobStatusPending JobStatus = "pending"
5 JobStatusRunning JobStatus = "running"
6 JobStatusSucceeded JobStatus = "succeeded"
7 JobStatusFailed JobStatus = "failed"
8)接著定義狀態投影:
1type JobProjection struct {
2 ID string
3 Status JobStatus
4 Progress int
5 UpdatedAt time.Time
6 FinishedAt time.Time
7}Status 是 domain state。Progress 可以是 read model 欄位,代表目前顯示用進度。FinishedAt 需要進一步判斷:如果完成時間會影響重試、保留時間或排序,它就不只是 response 欄位。
零值也要有語意。FinishedAt 的零值可以代表「尚未完成」,但這個語意必須被程式明確處理;若零值會造成混淆,可以改用 pointer:
1type JobProjection struct {
2 ID string
3 Status JobStatus
4 Progress int
5 UpdatedAt time.Time
6 FinishedAt *time.Time
7}pointer 在這裡的用途是區分「沒有值」和「有一個零值」。時間、數字與 bool 欄位最常遇到這個問題。
【策略】狀態轉移要集中在同一個入口
狀態轉移的核心規則是所有寫入都經過同一組方法。handler、worker 或 WebSocket router 應把狀態變更交給 repository 或 state owner,而不是自行修改 map 或 projection 欄位。
先定義內部 event:
1type JobEvent struct {
2 JobID string
3 Type string
4 Progress int
5 OccurredAt time.Time
6}repository 可以集中套用事件:
1type JobRepository struct {
2 mu sync.RWMutex
3 jobs map[string]JobProjection
4}
5
6func NewJobRepository() *JobRepository {
7 return &JobRepository{
8 jobs: make(map[string]JobProjection),
9 }
10}Apply 是狀態寫入入口:
1func (r *JobRepository) Apply(event JobEvent) error {
2 r.mu.Lock()
3 defer r.mu.Unlock()
4
5 job := r.jobs[event.JobID]
6 job.ID = event.JobID
7 job.UpdatedAt = event.OccurredAt
8
9 switch event.Type {
10 case "job.started":
11 job.Status = JobStatusRunning
12 job.Progress = 0
13 case "job.progressed":
14 job.Progress = event.Progress
15 case "job.succeeded":
16 job.Status = JobStatusSucceeded
17 job.Progress = 100
18 finishedAt := event.OccurredAt
19 job.FinishedAt = &finishedAt
20 case "job.failed":
21 job.Status = JobStatusFailed
22 finishedAt := event.OccurredAt
23 job.FinishedAt = &finishedAt
24 default:
25 return fmt.Errorf("unsupported job event type %q", event.Type)
26 }
27
28 r.jobs[event.JobID] = job
29 return nil
30}這段程式讓狀態轉移規則集中在 repository。未來如果要禁止 failed job 重新變成 running,或要求 progress 不可倒退,可以在同一個入口加規則與測試。
【判讀】read model 可以為查詢服務
read model 的核心用途是讓查詢與顯示更直接。它不一定等同完整 domain state,而是為某種讀取需求整理出的投影。
例如列表頁可能只需要 summary:
1type JobSummary struct {
2 ID string
3 Status JobStatus
4 Progress int
5 UpdatedAt time.Time
6}repository 可以提供查詢方法:
1func (r *JobRepository) ListSummaries() []JobSummary {
2 r.mu.RLock()
3 defer r.mu.RUnlock()
4
5 result := make([]JobSummary, 0, len(r.jobs))
6 for _, job := range r.jobs {
7 result = append(result, JobSummary{
8 ID: job.ID,
9 Status: job.Status,
10 Progress: job.Progress,
11 UpdatedAt: job.UpdatedAt,
12 })
13 }
14 return result
15}這個方法回傳新 slice,而不是暴露 repository 內部資料。若 read model 包含 slice、map 或 pointer,也要確認呼叫端不能修改內部狀態。
【執行】讀取方法要保護 copy boundary
copy boundary 的核心目標是避免外部呼叫者修改 repository 內部資料。Go 的 slice、map、pointer 都可能讓內部狀態外洩。
單筆查詢可以回傳值與 bool:
1func (r *JobRepository) Get(id string) (JobProjection, bool) {
2 r.mu.RLock()
3 defer r.mu.RUnlock()
4
5 job, ok := r.jobs[id]
6 if !ok {
7 return JobProjection{}, false
8 }
9 return cloneJobProjection(job), true
10}若 struct 內含 pointer,clone 函式要複製 pointer 指向的值:
1func cloneJobProjection(job JobProjection) JobProjection {
2 cloned := job
3 if job.FinishedAt != nil {
4 finishedAt := *job.FinishedAt
5 cloned.FinishedAt = &finishedAt
6 }
7 return cloned
8}這個 clone 看似瑣碎,但它保護了 repository 的擁有權。呼叫端拿到資料後,即使修改 FinishedAt 指向的時間,也不會影響 repository 內部狀態。
【策略】response view 負責對外格式
response view 的核心責任是把內部狀態轉成外部 contract。JSON tag、omitempty、顯示文字與相容性都應該在 response struct 中處理,讓 domain model 保持在業務狀態語意上。
1type JobResponse struct {
2 ID string `json:"id"`
3 Status JobStatus `json:"status"`
4 Progress int `json:"progress"`
5 UpdatedAt time.Time `json:"updatedAt"`
6 FinishedAt *time.Time `json:"finishedAt,omitempty"`
7 DisplayText string `json:"displayText,omitempty"`
8}轉換函式可以集中 response 規則:
1func NewJobResponse(job JobProjection) JobResponse {
2 return JobResponse{
3 ID: job.ID,
4 Status: job.Status,
5 Progress: job.Progress,
6 UpdatedAt: job.UpdatedAt,
7 FinishedAt: job.FinishedAt,
8 DisplayText: displayText(job.Status),
9 }
10}
11
12func displayText(status JobStatus) string {
13 switch status {
14 case JobStatusPending:
15 return "Waiting"
16 case JobStatusRunning:
17 return "Running"
18 case JobStatusSucceeded:
19 return "Completed"
20 case JobStatusFailed:
21 return "Failed"
22 default:
23 return "Unknown"
24 }
25}DisplayText 是 response view,負責呈現用文字。若未來前端改文案,response 轉換函式可以調整,repository 狀態轉移規則應保持穩定。
【判讀】omitempty 是相容性語意
omitempty 的核心語意是欄位在某些情境中可以不存在。它在對外 contract 中表示「這個欄位可能不存在」,而不是只為了縮短 JSON。
例如 FinishedAt 只有 job 完成或失敗後才有值:
1FinishedAt *time.Time `json:"finishedAt,omitempty"`舊 client 如果不知道 finishedAt,通常會忽略新欄位。新 client 如果需要這個欄位,也必須處理它不存在的情況。
必填欄位的 JSON contract 應保持穩定輸出。id 或 status 這類欄位消失會讓 client 無法理解資料,因此它們屬於必要欄位,應維持固定輸出。
【執行】state transition 測試要鎖定規則
state transition 測試的核心目標是確認事件會產生正確狀態。這類測試不需要 HTTP,也不需要 WebSocket。
1func TestJobRepositoryApplySucceeded(t *testing.T) {
2 repo := NewJobRepository()
3 finishedAt := time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
4
5 err := repo.Apply(JobEvent{
6 JobID: "job_1",
7 Type: "job.succeeded",
8 OccurredAt: finishedAt,
9 })
10 if err != nil {
11 t.Fatalf("apply event: %v", err)
12 }
13
14 job, ok := repo.Get("job_1")
15 if !ok {
16 t.Fatalf("job should exist")
17 }
18 if job.Status != JobStatusSucceeded {
19 t.Fatalf("status = %q, want %q", job.Status, JobStatusSucceeded)
20 }
21 if job.Progress != 100 {
22 t.Fatalf("progress = %d, want 100", job.Progress)
23 }
24 if job.FinishedAt == nil || !job.FinishedAt.Equal(finishedAt) {
25 t.Fatalf("finished at = %v, want %v", job.FinishedAt, finishedAt)
26 }
27}這個測試保護的是狀態規則,而不是輸出 JSON 格式。
【執行】copy boundary 測試要嘗試修改回傳值
copy boundary 測試的核心目標是證明呼叫端不能透過回傳資料改到 repository 內部狀態。
1func TestJobRepositoryGetReturnsCopy(t *testing.T) {
2 repo := NewJobRepository()
3 finishedAt := time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
4
5 _ = repo.Apply(JobEvent{
6 JobID: "job_1",
7 Type: "job.succeeded",
8 OccurredAt: finishedAt,
9 })
10
11 job, ok := repo.Get("job_1")
12 if !ok {
13 t.Fatalf("job should exist")
14 }
15
16 changed := finishedAt.Add(time.Hour)
17 job.FinishedAt = &changed
18
19 again, _ := repo.Get("job_1")
20 if again.FinishedAt == nil || !again.FinishedAt.Equal(finishedAt) {
21 t.Fatalf("repository state was modified through returned value")
22 }
23}這種測試對 map、slice、pointer 特別重要。值型別欄位通常不需要額外 clone,但一旦 struct 包含可變參照,就要測邊界。
【執行】response JSON 測試要檢查 contract
response 測試的核心目標是確認對外 JSON 欄位符合 contract。測試應該解析 JSON 或檢查欄位存在性,而不是只比對整段字串。
1func TestJobResponseOmitsFinishedAtWhenNil(t *testing.T) {
2 response := NewJobResponse(JobProjection{
3 ID: "job_1",
4 Status: JobStatusRunning,
5 Progress: 40,
6 UpdatedAt: time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC),
7 })
8
9 data, err := json.Marshal(response)
10 if err != nil {
11 t.Fatalf("marshal response: %v", err)
12 }
13
14 var body map[string]any
15 if err := json.Unmarshal(data, &body); err != nil {
16 t.Fatalf("unmarshal response: %v", err)
17 }
18
19 if _, ok := body["finishedAt"]; ok {
20 t.Fatalf("finishedAt should be omitted when nil")
21 }
22}這個測試是測對外承諾的欄位語意。
實作檢查清單
擴展狀態投影欄位時,可以依序檢查:
- 欄位屬於 domain state、read model 還是 response view
- 零值是否有明確語意
- 是否需要 typed constant
- 寫入是否集中在 repository 或 state owner
- handler、router、worker 是否沒有直接修改內部 map/slice
- 查詢是否回傳 copy
- response 是否使用正確 JSON tag
omitempty是否真的代表可選欄位- 測試是否分成 state transition、copy boundary、response JSON
設計檢查
檢查一:顯示欄位放在 response view
顯示文字、顏色或前端 badge 通常是 response view。只有影響業務規則的欄位,才需要進入 domain state。
檢查二:handler 透過狀態入口修改 projection
handler 透過 repository 或 state owner 修改 projection,可以讓狀態規則集中。handler 直接改 projection 時,新增第二個入口容易漏掉同一套規則。
檢查三:回傳資料保護 copy boundary
只要呼叫端能修改 repository 內部資料,狀態邊界就失效。回傳值時要檢查是否需要 clone。
檢查四:omitempty 對應可選欄位
必填欄位加上 omitempty 會讓 response contract 變模糊。欄位是否可省略,應由資料語意決定,而不是由欄位零值方便性決定。
本章不處理
本章先處理狀態欄位如何影響 response contract;資料庫 migration 與前端相容性策略,會在下列章節再往外延伸:
和 Go 教材的關係
這一章承接的是 repository、event 與 response view 的邊界;如果你要先回看語言教材,可以讀: