Source of truth 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化,但不能各自保存一份會被當成真相的資料。

本章目標

學完本章後,你將能夠:

  1. 判斷狀態真相應該由哪個元件擁有
  2. 把狀態轉移集中在 repository 或 state owner
  3. 同步更新 current state 與 history
  4. 用 copy boundary 保護 slice、map、pointer
  5. 分辨 internal state、projection 與 response view

【觀察】狀態分散會讓系統失去真相

狀態分散的核心風險是每個元件都以為自己看到的是最新資料。handler 可能有 map,worker 可能有 cache,publisher 可能有最後推送狀態;當三者不一致時,系統很難回答「現在到底是什麼狀態」。

反模式示意:

1var handlerStates = map[string]string{}
2var workerStates = map[string]string{}
3var publisherLastSent = map[string]string{}

這三份資料可能都叫做 state,但只有一份應該是 source of truth。其他資料如果存在,應該明確標示為 cache、projection 或 delivery record,不能被當成狀態判斷依據。

【判讀】source of truth 是寫入權責

source of truth 的核心不是「資料存在哪裡」,而是「誰有權決定狀態如何轉移」。memory map、SQLite、PostgreSQL、Redis 都可以承擔儲存;真正的邊界是所有寫入都經過同一組規則。

 1type AccountStatus string
 2
 3const (
 4    AccountPending AccountStatus = "pending"
 5    AccountActive  AccountStatus = "active"
 6    AccountBlocked AccountStatus = "blocked"
 7)
 8
 9type AccountState struct {
10    ID        string
11    Status    AccountStatus
12    UpdatedAt time.Time
13}

AccountState 是 domain 狀態,不是 HTTP response。它應該表達系統內部真正需要維護的資料,而不是直接迎合某個 API 的輸出格式。

【策略】用明確方法集中狀態轉移

狀態轉移的核心規則是呼叫端不能直接改欄位。外部元件應該送進事件或 command,由 state owner 決定是否合法、如何更新、是否記錄 history。

 1type StateRepository struct {
 2    mu      sync.RWMutex
 3    records map[string]AccountRecord
 4}
 5
 6type AccountRecord struct {
 7    Current AccountState
 8    History []AccountState
 9}
10
11func NewStateRepository() *StateRepository {
12    return &StateRepository{
13        records: make(map[string]AccountRecord),
14    }
15}

repository 擁有 records map。其他元件不應取得這個 map 的 reference,也不應繞過 repository 修改狀態。

【執行】Apply 把事件轉成狀態變化

Apply 的核心責任是把 domain event 套用到 state。它是事件系統與狀態系統的交界。

 1func (r *StateRepository) Apply(ctx context.Context, event DomainEvent) error {
 2    r.mu.Lock()
 3    defer r.mu.Unlock()
 4
 5    record := r.records[event.SubjectID]
 6    next, err := transition(record.Current, event)
 7    if err != nil {
 8        return err
 9    }
10
11    record.Current = next
12    record.History = append(record.History, next)
13    r.records[event.SubjectID] = record
14
15    return nil
16}

這段程式在同一個 lock 內更新 current 與 history。讀者可以相信目前狀態與歷史紀錄來自同一筆事件,不會出現 current 已更新但 history 漏記的情境。

transition 可以是純函式:

 1func transition(current AccountState, event DomainEvent) (AccountState, error) {
 2    switch event.Type {
 3    case EventAccountActivated:
 4        return AccountState{
 5            ID:        event.SubjectID,
 6            Status:    AccountActive,
 7            UpdatedAt: event.OccurredAt,
 8        }, nil
 9    default:
10        return AccountState{}, fmt.Errorf("unsupported event type: %s", event.Type)
11    }
12}

純函式讓狀態規則更容易測試。repository 負責 concurrency 與保存,transition 負責 domain 規則。

【判讀】current、history、projection 是不同資料

狀態資料的核心分類是 internal state、history 與 projection。三者用途不同,不應混成同一個 struct 到處傳。

類型角色範例
internal state系統判斷真相的資料AccountState
history狀態變化紀錄[]AccountState
projection查詢或 UI 需要的讀取模型AccountSummary
response view特定 API 的輸出格式accountResponse

projection 可以從 state 與 history 組出來,但 projection 不應反過來成為狀態真相。API 需要新增欄位時,優先新增 response view 或 projection,不要直接污染 internal state。

【執行】查詢要回傳 copy

copy boundary 的核心目標是防止呼叫端修改 repository 內部資料。Go 的 slice、map、pointer 都可能讓內部狀態外洩。

 1func (r *StateRepository) Current(ctx context.Context, id string) (AccountState, bool, error) {
 2    r.mu.RLock()
 3    defer r.mu.RUnlock()
 4
 5    record, ok := r.records[id]
 6    if !ok {
 7        return AccountState{}, false, nil
 8    }
 9
10    return record.Current, true, nil
11}

AccountState 目前只有值型別欄位,直接回傳值即可。history 是 slice,必須複製:

1func (r *StateRepository) History(ctx context.Context, id string) ([]AccountState, error) {
2    r.mu.RLock()
3    defer r.mu.RUnlock()
4
5    history := r.records[id].History
6    result := make([]AccountState, len(history))
7    copy(result, history)
8    return result, nil
9}

若 state 內含 map、slice 或 pointer,還需要 deep copy。copy 有成本,但它是狀態邊界的保護;資料量大時應用分頁或 projection,不應直接暴露內部 slice。

【策略】projection 讓查詢需求不污染狀態

projection 的核心用途是服務讀取需求。列表頁、儀表板、即時推送可能需要不同欄位,這些需求不應全部塞進 domain state。

 1type AccountSummary struct {
 2    ID              string
 3    Status          AccountStatus
 4    LastChangedAt   time.Time
 5    HistoryCount    int
 6}
 7
 8func (r *StateRepository) Summary(ctx context.Context, id string) (AccountSummary, bool, error) {
 9    r.mu.RLock()
10    defer r.mu.RUnlock()
11
12    record, ok := r.records[id]
13    if !ok {
14        return AccountSummary{}, false, nil
15    }
16
17    return AccountSummary{
18        ID:            record.Current.ID,
19        Status:        record.Current.Status,
20        LastChangedAt: record.Current.UpdatedAt,
21        HistoryCount:  len(record.History),
22    }, true, nil
23}

projection 可以由 repository 即時計算,也可以由背景 worker 預先維護。選擇哪一種取決於讀取量、資料量與一致性要求;小型服務先即時計算通常更容易理解。

【判讀】mutex 與單一 goroutine 都能成為 state owner

狀態擁有權的核心要求是同一時間只有受控路徑能修改資料。mutex 是常見選擇,單一 goroutine 擁有 state 也是 Go 常見模式。

mutex 版本適合直接方法呼叫:

1repository.Apply(ctx, event)

單一 goroutine 版本適合事件流:

1type stateCommand struct {
2    event DomainEvent
3    reply chan error
4}

兩者都可以正確。選擇 mutex 時要小心 copy boundary;選擇 goroutine owner 時要設計 shutdown、reply channel 與 backpressure。不要為了使用 channel 而使用 channel,狀態模型簡單時 mutex 通常更直接。

【測試】狀態測試要覆蓋轉移與外洩

狀態邊界的測試目標是確認轉移一致、history 同步、呼叫端不能修改內部資料。

 1func TestHistoryReturnsCopy(t *testing.T) {
 2    repo := NewStateRepository()
 3    event := DomainEvent{
 4        ID:         "evt_1",
 5        Type:       EventAccountActivated,
 6        SubjectID:  "acct_1",
 7        OccurredAt: time.Now(),
 8        ReceivedAt: time.Now(),
 9    }
10
11    if err := repo.Apply(context.Background(), event); err != nil {
12        t.Fatalf("apply event: %v", err)
13    }
14
15    history, err := repo.History(context.Background(), "acct_1")
16    if err != nil {
17        t.Fatalf("history: %v", err)
18    }
19    history[0].Status = AccountBlocked
20
21    again, err := repo.History(context.Background(), "acct_1")
22    if err != nil {
23        t.Fatalf("history again: %v", err)
24    }
25    if again[0].Status != AccountActive {
26        t.Fatalf("repository state was modified through returned history")
27    }
28}

這個測試檢查的是邊界,不只是結果值。對 Go 服務來說,防止 slice/map 外洩是狀態設計的重要一環。

本章不處理

本章先處理單一服務內誰有寫入權責;資料庫 migration 與 CQRS,會在下列章節再往外延伸:

和 Go 教材的關係

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

小結

Source of truth 是寫入權責,不是某個特定資料庫。狀態轉移應集中在 repository 或 state owner,current 與 history 要在同一邊界更新,查詢要回傳 copy 或 projection。當狀態真相清楚時,handler、worker、publisher 都能保持簡單,系統也能更容易加入資料庫或新的讀取模型。