はじめに
業務システムを作っていると、データの「ステータス(状態)」は必ず出てきます。
draft (下書き)
review (レビュー中)
approved (承認済み)
published (公開済み)
rejected (却下)
archived (アーカイブ)
これが5個、6個と増えていくと、だんだん仕様の怪しい「裏ルート」が生まれ始めます。
「あれ、API直接叩いたら下書きからいきなり公開状態にできちゃわない?」
「公開済みになったデータを再編集したら、ステータスはどうなるの?」
「アーカイブしたものを元に戻すボタンがないんだけど、バッチで戻したらバグる?」
こういう曖昧さを放置したままコードを書くと、後で痛い目を見ます。
この記事では、この手の「ありえないステータス遷移」を、モデル検査(形式検証)の考え方を使って設計段階で潰す方法を整理します。
許す遷移だけを「ホワイトリスト」で書く
まず、一番基本となるアプローチです。
「禁止事項」を書くのではなく、「これとこれだけは遷移してヨシ」というホワイトリストを書きます。
図にすると分かりやすいですね。
基本ルールは 「この矢印がない遷移は、絶対にすべて禁止」 です。
ツールに「悪い裏ルート」を探させる
もしこの遷移のルールを雑に実装してしまったとします。
モデル検査ツール(AlloyやTLA+など)に「公開の条件」を探索させると、こんな反例を叩き出してきます。
COUNTEREXAMPLE
trace: draft -> publish_directly
state: WorkflowState(status='published', approved_by=None)
見事に「承認ルートのすり抜け」が発覚しました。
下書きからいきなり公開されていて、しかも approved_by が空っぽです。コンプライアンス的に大問題になるやつですね。
ツールを使わなくても、設計レビューで「この矢印を無視して遷移できる抜け道はないか?」と自問自答するだけで、こういうバグはかなり防げます。
不変条件(絶対に守るルール)を宣言する
ステータス設計では、Excelに巨大な「〇×遷移表」を書いて満足しがちですが、人間は必ず表を見落とします。
遷移表だけでなく、システム全体として「これだけは絶対に守り抜く」という不変条件を言葉にしておくことが大事です。
【不変条件】
・ published (公開済み) に到達するデータは、必ず過去に approved (承認) を通っていること
・ archived (アーカイブ) 状態に入ったデータは、二度と他の状態には戻らないこと
・ rejected (却下) されたデータは、そのまま直接 published になることは絶対にない
こういうルールを明文化しておくと、単体テストで「下書きから直接公開APIを叩いたら弾かれるか」というテストケースが自然に生まれます。
実務でバグらせない実装パターン
ステータス遷移のバグは、「遷移の判定ロジックがコードのあちこちに散らばる」ことで発生します。
1. 遷移ガードを1箇所に集める
コントローラーごとに if 文を書くのはやめましょう。遷移表そのものをコードで表現します。
const allowedTransitions = {
draft: ["review"],
review: ["approved", "rejected"],
approved: ["published"],
rejected: ["draft"],
published: ["archived"],
archived: [],
};
function canTransit(fromStatus, toStatus) {
return allowedTransitions[fromStatus].includes(toStatus);
}
すべてのAPI処理は、必ずこの canTransit を通るように強制します。
2. 「管理者だけの例外」に気をつける
「基本はダメだけど、管理者権限ならどのステータスからでもアーカイブできるよ」
こういうビジネス要件が入ると、遷移表が一気にカオスになります。
この場合は、通常ユーザーの遷移表と管理者の遷移表を分けるか、例外操作用の専用APIを作って切り離すのが安全です。
3. バッチ処理の「直接UPDATE」を許さない
API側はしっかり遷移チェックを入れたのに、夜間バッチが空気を読まずにDBを UPDATE status = 'published' と直接書き換えてしまい、不正状態が生み出される。あるあるです。
バッチ処理も必ずアプリケーションのドメインロジック(canTransit)を通すようにするか、DBのトリガーや制約で物理的に防ぐ必要があります。
4. ステータスとフラグを混ぜない
status = 'canceled' というステータス管理と、canceled_at IS NOT NULL というフラグ管理が別々に存在すると、いつか必ずズレて地獄を見ます。
「状態の正」をどこに置くかは、設計段階で決めておきましょう。
遷移に条件を付ける
実際のワークフローでは、単に from -> to だけでは足りないことがあります。
たとえば、
review -> approved
という遷移があっても、誰でも承認できるわけではありません。
条件があります。
承認者ロールを持つ
自分が作成した申請ではない
必要な添付ファイルがある
期限内である
つまり、遷移はこうなります。
from
to
condition
effect
この condition を曖昧にすると、ありえない遷移が混ざります。
遷移の副作用
状態遷移には副作用がつくことがあります。
approved -> published:
公開日時をセット
通知を送る
検索インデックスを更新
この副作用も仕様です。
状態だけ変わって副作用が失敗したら、どう扱うのでしょうか。
published なのに検索に出ない
approved なのに通知されない
archived なのに公開ページが残る
こういう状態がありえるなら、モデルに副作用状態を足すことがあります。
例外遷移をどう扱うか
業務には例外があります。
管理者だけは published -> draft に戻せる
緊急時だけ approved を飛ばせる
期限切れなら自動で rejected になる
例外を雑に入れると、遷移表が壊れます。
おすすめは、例外も明示的な遷移として書くことです。
published -> draft
condition: role = admin and reason is present
effect: audit_log を残す
例外は「例外だから仕方ない」ではなく、条件付き遷移として扱います。
監査ログとの関係
ワークフローでは、誰が遷移させたかが重要です。
from_status
to_status
actor_id
reason
request_id
created_at
これを残しておくと、あとで追えます。
特に管理者例外や手動修正がある場合、監査ログは必須に近いです。
形式検証では「不正遷移しない」を見ます。
監査ログでは「何が起きたか追える」を守ります。
両方必要です。
テストに落とす
遷移表があるなら、テストはかなり書きやすくなります。
許可される遷移は成功する
許可されない遷移は失敗する
遷移時の副作用が1回だけ起きる
権限がないユーザーは遷移できない
全組み合わせを表で回すだけでも強いです。
さらにモデル検査で反例が出たなら、その反例をテストにします。
draft -> published が直接できない
archived -> review に戻れない
のようなテストです。
状態を増やしすぎない
ワークフロー設計では、状態を増やしすぎるとつらくなります。
paid
paid_but_mail_failed
paid_but_stock_failed
paid_but_point_failed
のように、全部をステータスに入れると爆発します。
状態として持つべきものと、副作用の進捗として持つべきものを分けます。
order_status
payment_status
notification_status
のように分けたほうが分かりやすいこともあります。
まとめ
ワークフローのシステムでは、
・ 許す遷移(ホワイトリスト)を明示する
・ 「公開前に承認を通る」「アーカイブ後は戻らない」といった不変条件を定義する
これだけで、設計の解像度が跳ね上がります。
「とりあえずステータスを持たせて、あとからAPIで書き換えればいいや」と実装から入るのはかなり危険です。
コードを書く前に、ホワイトボードでもいいので「状態遷移図」と「不変条件」を書く。形式検証のこのエッセンスを取り入れるだけで、安全なワークフローが作れるようになります。
