Upgrade to Pro — share decks privately, control downloads, hide ads and more …

承認済みなのに差戻しできてしまうバグ、型で潰せます

 承認済みなのに差戻しできてしまうバグ、型で潰せます

2026年8月20日 tamachi.go での発表資料です。
https://tamachi-go.connpass.com/event/400310/

「承認済み」のはずの申請が、なぜか差戻しできてしまう。
状態遷移のルールが各メソッドの if 文に分散していると、
こうした「ありえない遷移」がすり抜けます。

申請・承認という小さなドメインを題材に、値オブジェクトの
考え方で状態遷移をコードに落とし込む方法を10分で話します。

・type Status string が "banana" を受け入れてしまう理由
・コンストラクタでの自己検証で、不正な状態を作れなくする
・遷移ルールを map の表に1箇所集約し、状態遷移図とコードを一致させる
・状態変更の入口を非公開メソッド1箇所に絞り、迂回経路を塞ぐ

Go / DDD / 値オブジェクト / 状態遷移 / ドメインモデル

Avatar for SHINCHI Takahiro

SHINCHI Takahiro

August 12, 2026

More Decks by SHINCHI Takahiro

Other Decks in Programming

Transcript

  1. SHINCHI Takahiro • ふだんは技術選定・設計、ITコンサルティングに携わる • Go × DDD を学習中 •

    「型で防げるバグ」が今日のテーマ calm-pm-lab.com PM(プロジェクトマネジメン X @shintakapi ト)の記事を書いています 承認済みなのに差戻しできてしまうバグ、型で潰せます 己 自 紹介 2
  2. 原因 ① Status がただの string だった type Status string const

    ( StatusSubmitted Status = "submitted" StatusApproved Status = "approved" ) s := Status("banana") // コンパイルは通る DBやJSONから状態を読み戻すとき、外の 世界から生の文字列が入ってくる。 そこに "banana" が混ざっても、 今の実装は素通し。 コンパイラは、型の中身までは守ってくれない 承認済みなのに差戻しできてしまうバグ、型で潰せます 4
  3. 原因 ② 遷移ルールがメソッドに分散していた func (a *Application) Approve(approverID ApproverID) error {

    if a.status != StatusSubmitted { return ErrNotSubmitted // ← ルールがここに } ... // Reject にも、Resubmit にも、似た if 文が増えていく } メソッドが増えるたび、遷移ルールを書く場所も増える → 全体像(状態遷移図)が、コードのどこにも存在しない 承認済みなのに差戻しできてしまうバグ、型で潰せます 5
  4. 「どれか」ではなく「中身が何か」で決まる、名前を持った値。Goでは3つの性質がそのまま言語仕様に乗る 1 不変である Status に setter は存在しない。値ごと 別の値に置き換える。 var s

    Status = StatusSubmitted // setter は存在しない 2 値として 較できる 3 らの正しさを検証する 同一性は中身で決まる。string を土台 にした型なので == がそのまま使え 不正な値を持ったまま存在させない。 コンストラクタで入口を守る。 る。 StatusSubmitted == s NewStatus("banana") → err s = StatusApproved Java なら equals() を書くところが、Go では言語仕様だけで済む 承認済みなのに差戻しできてしまうバグ、型で潰せます 比 自 値オブジェクトとは 6
  5. 解決の 針 値オブジェクトの考え方で、2つを直す 1 2 コンストラクタで 検証 遷移ルールを表に1箇所集約 NewStatus() で、定義外の値をエラーにする。

    「どこからどこへ動けるか」をmapで表現。 不正な Status は、そもそも作れない。 状態遷移図とコードを一致させ続ける。 己 自 方 承認済みなのに差戻しできてしまうバグ、型で潰せます 7
  6. 改善したコードの全体像 コードの流れは次の通り 外部入力(DB・JSON) → NewStatus() で自己検証 Status transitions → type

    Status string 遷移ルールの表 (map) CanTransitionTo() → 表を引いて 判定する transitionTo() → 入口を 1箇所に絞る a.status = next → 唯一の 代入 ↑ Submit ・ Approve ・ Reject ・ Resubmit —— 全員がここを通る 承認済みなのに差戻しできてしまうバグ、型で潰せます 8
  7. 実装 ① コンストラクタで 検証する // 外部入力(DB・JSONなど)から Status を復元する // 定義外の値はエラーにする(値オブジェクトの自己検証)

    func NewStatus(value string) (Status, error) { s := Status(value) if _, ok := transitions[s]; !ok { return "", fmt.Errorf("不正な状態です: %q", value) } return s, nil } NewStatus("banana") はエラーになる 9 己 自 承認済みなのに差戻しできてしまうバグ、型で潰せます
  8. 実装 ② 遷移ルールを表に1箇所集約する var transitions = map[Status][]Status{ StatusDraft: {StatusSubmitted}, StatusSubmitted:

    {StatusApproved, StatusRejected}, StatusRejected: {StatusSubmitted}, StatusApproved: {}, // 終端状態 } func (s Status) CanTransitionTo( next Status) bool { for _, a := range transitions[s] { if a == next { return true } } return false } 承認済みなのに差戻しできてしまうバグ、型で潰せます ✓ ✓ ✓ この4行のmapが、状態遷移図そのもの 未知の状態は nil スライス → range が0回 → 自動的に「どこへも行けない」 テストもこの表をなぞって書ける 10
  9. 実装 ③ 状態を変更する を1箇所に絞る 呼ぶ側 — 公開メソッド 呼ばれる側 — 関門

    func (a *Application) Submit() error { return a.transitionTo(StatusSubmitted) } // 状態変更の入口はここ1箇所だけ func (a *Application) Approve( id ApproverID) error { if string(id) == string(a.applicantID) { return ErrSelfApproval } return a.transitionTo(StatusApproved) } → func (a *Application) transitionTo( next Status) error { if !a.status.CanTransitionTo(next) { return fmt.Errorf("%w: %s → %s", ErrInvalidTransition, a.status, next) } a.status = next // 代入はここだけ return nil } // Reject / Resubmit も同じ形 なぜ迂回できないのか — Goでは小文字始まりの識別子はパッケージ外から見えない ✗ transitionTo() を直接呼ぶ ✗ a.status に直接代入する ◦ 残る入口は公開メソッドだけ 別パッケージからは フィールドも小文字 その全部が コンパイルエラー 同じくコンパイルエラー transitionTo を通る 11 口 入 承認済みなのに差戻しできてしまうバグ、型で潰せます
  10. この設計で得られたもの デフォルト拒否 1 未知の遷移・未知の状態は、言語仕様から自動的に false。守り忘れが起きない。 表 = テスト 2 遷移表をなぞるだけでテーブル駆動テストが書ける。ケース名がそのまま仕様の文になる。

    「承認済みなのに差戻し」が消える 3 遷移表にない操作は、実行時に必ずエラーで弾かれる。すり抜ける経路がない。 承認済みなのに差戻しできてしまうバグ、型で潰せます 12