はじめに
ピースミール・テクノロジー 進地 です。
GoとDDDの学習記録、連載の第2回です。前回はレイヤをパッケージ構成で表現する話を書きました。
今回は、前回さらっと流した Status(申請の状態)を主役にします。テーマは値オブジェクトと状態遷移です。前回の最後に置いた「差戻しされた申請は再提出できるのか?」という問いに、Goの型で答えます。
想定読者
- 前回の記事を読んだ人(未読でも、Goの基本文法が分かれば読めるように書きます)
- 値オブジェクトを「知ってはいるが、Goでどう書くか迷っている」人
この記事のスコープ
値オブジェクトの基本と状態遷移の実装に絞ります。リポジトリのDB実装は次回に書く予定です。
サンプルコードは前回と同じリポジトリで公開しています。本記事時点のコードは article-02 タグ、前回からの差分は article-01...article-02 の比較 で確認できます。
前回に引き続き学習中ですので、「もっと良いやり方がある」という点はぜひコメントで教えてください。
「差戻しされた申請は再提出できるのか?」はドメインへの問い
まず、この問いはGoの問題ではありません。業務の問題です。
差戻しされた申請のその後は、組織によって違います。修正して再提出できる運用もあれば、差戻し=却下で、新しい申請を作り直す運用もある。つまりこれは「実装をどうするか」の前に「うちの業務ではどういうルールなのか」を決める話で、その決めごとを言語化してコードに写し取るのがDDDの本体です。
この記事では、次のルールを採用します。
- 差戻しされた申請は、修正のうえ再提出できる。再提出すると「申請中」に戻る
- 承認済みの申請には、もう何もできない(終端状態)
状態遷移図にするとこうなります。
問題は、この図をどうやってコードに写すかです。図はドキュメントに描いた瞬間から古くなり始めますが、コードは動き続けます。図とコードを一致させ続ける一番確実な方法は、コード自体を図のように書くことだと考えました。
いまの Status は "banana" を受け入れてしまう
前回の Status はこうでした。
// domain/application/status.go(前回時点)
package application
type Status string
const (
StatusDraft Status = "draft" // 下書き
StatusSubmitted Status = "submitted" // 申請中
StatusApproved Status = "approved" // 承認済み
StatusRejected Status = "rejected" // 差戻し
)
定数を定義して満足していたのですが、これには2つの弱点があります。
1つ目。type Status string は定義上ただのstringなので、こういうコードがコンパイルを通ります。
s := application.Status("banana") // エラーにならない
プログラム内で状態を変えるだけなら定数しか使わないので問題は起きにくいのですが、DBやJSONから状態を読み戻す場面では、外の世界から生の文字列が入ってきます。そこに "banana" が混ざっても、今の実装は素通しです。
2つ目。冒頭の状態遷移図が、コードのどこにも存在しません。「申請中でなければ承認できない」というルールは前回 Approve() の中のif文に書きましたが、この方式でメソッドを増やすと、遷移ルールが各メソッドのif文に分散していきます。「結局この申請はどこからどこへ動けるのか」の全体像を知りたければ、全メソッドを読むしかない。
この2つを、値オブジェクトの考え方で直します。
値オブジェクトの3つの性質を Go で満たす
値オブジェクトには、よく挙げられる3つの性質があります。それぞれGoでは何に対応するかを整理します。
1. 不変である
一度作った値は変更しない。Goでは Status を値型のまま扱い、状態を変えるときは「変更」ではなく「新しい値への置き換え」にします。後で見るとおり、置き換えの箇所はエンティティ側の1箇所に絞ります。
2. 値として比較できる
値オブジェクトの同一性は「どれであるか」ではなく「中身が同じか」で決まります。Status は type Status string と、stringを土台にして定義した型なので、中身の文字列が同じなら == で等しいと判定されます。
a := StatusSubmitted
b := Status("submitted")
fmt.Println(a == b) // true
複数の値を組にした値オブジェクト、たとえば「金額と通貨」をまとめたstruct(構造体)を作る場合も、Goでは全フィールドが等しければ == がtrueになります。JavaのDDDサンプルで equals() をオーバーライドしていた部分が、Goでは言語仕様だけで済みます。
3. 自らの正しさを検証する
不正な値を持った値オブジェクトは存在させない。ここが今回の主役で、コンストラクタ関数で入口を守ります。
// NewStatus は外部入力(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") はエラーになります。判定に使っている transitions が何者かは、次で説明します。
遷移ルールは表にして1箇所に置く
冒頭の状態遷移図を、そのままGoのmapに写します。
// transitions は許可される状態遷移の一覧。
// 「どの状態からどこへ動けるか」というドメインルールはここに集約する
var transitions = map[Status][]Status{
StatusDraft: {StatusSubmitted}, // 下書き → 提出
StatusSubmitted: {StatusApproved, StatusRejected}, // 申請中 → 承認 or 差戻し
StatusRejected: {StatusSubmitted}, // 差戻し → 再提出
StatusApproved: {}, // 承認済みは終端状態
}
// CanTransitionTo は next への遷移が許可されているかを返す
func (s Status) CanTransitionTo(next Status) bool {
for _, allowed := range transitions[s] {
if allowed == next {
return true
}
}
return false
}
この4行のmapが状態遷移図そのものです。仕様変更、たとえば「承認済みでも取り消せるようにしたい」が来ても、直すのはこの表の1行だけです。
地味な見た目ですが、細部に3つの仕掛けがあります。
1. 終端状態の空スライスは省略できない
StatusApproved: {} を「遷移先がないなら書かなくていいのでは」と消したくなりますが、消せません。この表は遷移ルールと「正しい状態値の一覧」を兼ねていて、先ほどの NewStatus() は「表にキーとして存在するか」で不正値を弾いているからです。行を消すと NewStatus("approved") までエラーになります。「終端状態は、行はあるが遷移先が空」という表現になっています。
2. 未知の状態は自動的に「どこへも行けない」
Goのmapは存在しないキーを引くとゼロ値を返します。スライスのゼロ値は nil で、nilスライスのrangeは1回も回りません。つまり万一どこかで不正なStatusが生まれても、CanTransitionTo() は必ずfalseを返す。デフォルト拒否がGoの言語仕様から自然に手に入ります。
3. テストが表の写しで書ける
ルールをデータにしたので、テストもデータで書けます。
// domain/application/status_test.go(抜粋)
func TestStatus_CanTransitionTo(t *testing.T) {
tests := []struct {
name string
from Status
to Status
want bool
}{
{"下書きは提出できる", StatusDraft, StatusSubmitted, true},
{"申請中は差戻しできる", StatusSubmitted, StatusRejected, true},
{"差戻しは再提出できる", StatusRejected, StatusSubmitted, true},
{"差戻しをそのまま承認はできない", StatusRejected, StatusApproved, false},
{"承認済みからは何もできない", StatusApproved, StatusSubmitted, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.from.CanTransitionTo(tt.to); got != tt.want {
t.Errorf("%s → %s: got %v, want %v", tt.from, tt.to, got, tt.want)
}
})
}
}
ケース名がそのまま仕様の文になっているところが気に入っています。Goのテーブル駆動テストと遷移表方式は相性が良いです。
再提出をドメインのふるまいとして実装する
エンティティ側です。前回 Approve() の中に書いていた状態チェックを、遷移表を使う形に書き直し、差戻しと再提出を足しました。
// domain/application/application.go(抜粋)
// Submit は申請を提出する(下書き → 申請中)
func (a *Application) Submit() error {
return a.transitionTo(StatusSubmitted)
}
// Approve は申請を承認する(申請中 → 承認済み)。
// 「承認者は申請者と同一人物であってはならない」というビジネスルールはここに書く
func (a *Application) Approve(approverID ApproverID) error {
if string(approverID) == string(a.applicantID) {
return ErrSelfApproval
}
return a.transitionTo(StatusApproved)
}
// Reject は申請を差戻す(申請中 → 差戻し)
func (a *Application) Reject(approverID ApproverID) error {
if string(approverID) == string(a.applicantID) {
return ErrSelfApproval
}
return a.transitionTo(StatusRejected)
}
// Resubmit は差戻された申請を再提出する(差戻し → 申請中)。
// 実装は Submit と同じ遷移先だが、業務上は別のふるまいなので別メソッドにする
func (a *Application) Resubmit() error {
return a.transitionTo(StatusSubmitted)
}
// transitionTo は遷移ルールを検証してから状態を変更する。
// 状態変更の入口をここ1箇所に絞る
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
}
ポイントを3つ。
1. 状態への代入はコード全体で1行だけ
a.status = next という代入は transitionTo() の中にしかありません。SubmitもApproveもRejectもResubmitも、全部この関門を通ります。しかも transitionTo は小文字始まり(非公開)なので、パッケージ外から関門を迂回する手段がない。前回書いた「フィールド非公開で不変条件を守る」の発展形です。
2. SubmitとResubmitは実装が同じでも、あえて分ける
2つのメソッドの中身は完全に同じです。DRY原則的には統合したくなりました。それでも分けたのは、業務の言葉では「提出」と「再提出」が別の行為だからです。将来「再提出時は差戻しコメントへの回答を必須にする」のような仕様が来たとき、分けてあれば置き場所に困りません。実装の重複よりユビキタス言語を優先する、というDDD側に倒した判断です。
3. エラーは errors.Is で判定でき、メッセージには遷移情報が残る
fmt.Errorf("%w: %s → %s", ErrInvalidTransition, ...) とラップしているので、呼び出し側は errors.Is(err, application.ErrInvalidTransition) で種類を判定しつつ、ログには rejected → approved という具体的な情報が残ります。機械が判定する部分と人間が読む部分の両立です。
mainで動かすと、冒頭の状態遷移図をなぞる出力になります。
提出: submitted
自己承認: 自分の申請を自分で承認・差戻しすることはできません
差戻し: rejected
差戻し中の承認: この状態からその操作はできません: rejected → approved
再提出: submitted
承認: approved
承認後の再提出: この状態からその操作はできません: approved → submitted
復元: 不正な状態です: "banana"
「差戻しされた申請は再提出できるのか?」への答えが、「差戻し中の承認」と「再提出」の2行に出ています。差戻し中の承認は遷移表が拒否し、再提出は申請中に戻る。ルールが表の1箇所にあるので、この出力と状態遷移図と遷移表は必ず一致します。
もっと厳密にやるなら、状態ごとに型を分ける手もある
今回の実装は実行時チェックです。app.Approve() を下書き状態で呼んでもコンパイルは通り、実行してエラーになります。これをコンパイル時に弾きたければ、状態ごとに型を分ける方法があります。
// 概念のスケッチ(今回は不採用)
type DraftApplication struct{ /* ... */ }
type SubmittedApplication struct{ /* ... */ }
// 提出すると「申請中の申請」という別の型になる
func (a *DraftApplication) Submit() *SubmittedApplication { /* ... */ }
// Approve は SubmittedApplication にしか生えていないので、
// 下書きを承認するコードはそもそもコンパイルできない
func (a *SubmittedApplication) Approve(approverID ApproverID) (*ApprovedApplication, error)
型システムで遷移を強制できるのは魅力ですが、今回は見送りました。理由は、状態ごとに型を分けると「状態を問わず申請を扱いたい場面」が書きにくくなるからです。
たとえば「自分の申請を一覧表示する」機能を考えてみてください。一覧には下書きも申請中も差戻しも混ざります。では、この一覧を入れるスライスの型は何になるでしょうか。DraftApplication と SubmittedApplication は別の型なので、同じスライスには入りません。Goにはsum型(「AかBかCのいずれか」を1つの型として表す仕組み)がないため、こういう場面では共通のinterfaceを定義して、受け取った側で型スイッチ(値がどの型かを調べて分岐する構文)を書くことになります。状態を問わず申請を扱う場所すべてでこれが必要になるので、状態が4つでもコード量がかなり膨らみます。
状態遷移がドメインの中心的な複雑さである(たとえばワークフローエンジンを作る)なら検討する価値がありますが、今回の規模なら実行時チェックとテーブル駆動テストで十分と判断しました。
この「どこまで型に守らせるか」の線引きは、Goの表現力と相談しながら決める話で、正解が1つではないところだと思います。
やってみて迷ったこと
今回も、書いていて答えが出なかった点を残しておきます。
申請者本人による差戻しは「取り下げ」なのでは問題
Reject() に「本人は差戻せない」というチェックを入れましたが、書いてから迷いが生まれました。申請者本人が自分の申請を引っ込めたいケースは業務上ありえて、それは「差戻し」ではなく「取り下げ」という別のふるまいなのではないか。今回は「差戻しは承認者の行為。取り下げは未実装の別概念」と整理して先に進みましたが、コードを書いたことで初めてドメインの語彙の穴に気づいた、という順番でした。モデリングは机上で完結しない、を身をもって知った例です。
自己承認チェックが遷移表の外にいる問題
「申請者と承認者は別人」というルールは遷移表に載せられず、Approve() の中のif文に残っています。遷移表が扱えるのは「状態AからBへ動けるか」だけで、「誰が動かすか」という条件(ガード条件)は表の外です。状態遷移の理論では遷移にガードを付けて表現しますが、そこまでやるとmapでは済まなくなります。ルールの置き場所が2種類に分かれているのは少し気持ち悪く、まだ整理しきれていません。
まとめ
- 「差戻し後に再提出できるか」は実装ではなくドメインの決めごと。決めたルールをコードに写すのがDDDの本体
- 値オブジェクトの自己検証(
NewStatus)は、DBやJSONからの復元という「外の世界との境界」で効く - 遷移ルールはmapの表に1箇所化する。図とコードとテストが同じ形になり、未知の値へのデフォルト拒否も言語仕様から手に入る
- 状態ごとに型を分ければコンパイル時に守れるが、Goにsum型がない以上コストも大きい。規模と相談して決める
次回はリポジトリ編です。インメモリ実装をRDBに差し替え、interfaceを domain に置いた設計が実装の交換でどう効くかを確かめます。今回作った NewStatus() が、DBからの復元でようやく本領を発揮する回でもあります。
ここまで読んでいただきありがとうございました。「うちでは取り下げをこう扱っている」のような業務側の事例も歓迎です。
サンプルコードの全体はリポジトリで公開しています。本記事時点のコードは article-02 タグを参照してください。
本記事のサンプルコードは説明用に簡略化しています。エラーハンドリングや並行性の考慮は最小限です。