4.3 Source of Truth:狀態邊界
Source of truth 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化,但不能各自保存一份會被當成真相的資料。
本章目標
學完本章後,你將能夠:
- 判斷狀態真相應該由哪個元件擁有
- 把狀態轉移集中在 repository 或 state owner
- 同步更新 current state 與 history
- 用 copy boundary 保護 slice、map、pointer
- 分辨 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 都能保持簡單,系統也能更容易加入資料庫或新的讀取模型。