擴展狀態投影欄位的核心流程是先確認欄位屬於 domain state、read model 還是 response view。欄位加在哪一層,會決定寫入規則、相容性與測試方式。

本章目標

學完本章後,你將能夠:

  1. 分辨 domain state、read model 與 response view
  2. 判斷新欄位的零值是否有語意
  3. 把狀態轉移集中在 repository 或 state owner
  4. 用 copy boundary 保護內部 slice/map
  5. 測試 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 應保持穩定輸出。idstatus 這類欄位消失會讓 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}

這個測試是測對外承諾的欄位語意。

實作檢查清單

擴展狀態投影欄位時,可以依序檢查:

  1. 欄位屬於 domain state、read model 還是 response view
  2. 零值是否有明確語意
  3. 是否需要 typed constant
  4. 寫入是否集中在 repository 或 state owner
  5. handler、router、worker 是否沒有直接修改內部 map/slice
  6. 查詢是否回傳 copy
  7. response 是否使用正確 JSON tag
  8. omitempty 是否真的代表可選欄位
  9. 測試是否分成 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 的邊界;如果你要先回看語言教材,可以讀: