サブスクリプションのバグは状態のバグです。解約した顧客がアクセスを持ち続ける。支払いが遅れていたアカウントが支払ったのに、ロックされたままになる。カードを登録していない人のトライアルが終わり、その後どうなるかを決めたルールがない。どれも、誰も描かなかった遷移です。
ステートマシン図(状態遷移図とも呼ばれます)は、1 つのオブジェクトが取りうるすべての状態と、それを別の状態へ移すすべてのイベントを並べたものです。足りない遷移は、サポートチケットではなくレビューの段階で見つかります。この記事では、SaaS のサブスクリプションを例にステートマシン図を作ります。実際のシステムと比べられるよう Stripe Billing が文書化しているステータスを使い、記法は出てきた時点で説明します。
ステートマシン図を描くべきとき
1 つのオブジェクトにルールのあるライフサイクルがあるなら、描く価値があります。注文、サブスクリプション、支払い、サポートチケット、デプロイなどです。次のような兆候に覚えがあるはずです。
- 値が 3 つより多い
status列があり、コードのあちこちでそれを確認している。 is_canceledとis_pausedのように、互いに矛盾しうるブール値のフラグがある。- あるステータスから何かが「起こりうるか」で議論になる。
複数の参加者の間のやり取りには、メッセージの順番を示すシーケンス図が向いています。多くの人や引き継ぎを含む業務プロセスには、BPMN 図のほうが合います。どちらもステートマシン図の出番ではありません。
記法
| 要素 | 記法 | 意味 |
|---|---|---|
| 状態 | 角の丸い長方形 | イベントが来るまでオブジェクトがとどまる状態 |
| 開始疑似状態 | 小さな塗りつぶしの円 | ステートマシンの開始点 |
| 終了状態 | 輪の中の塗りつぶしの円 | ステートマシンが終了した |
| 遷移 | event [guard] / action とラベルを付けた矢印 |
イベントが起き、ガードが真なら遷移する |
| 選択 | ひし形 | 出ていく矢印のガードで行き先が決まる |
| 複合状態 | 状態を含む状態 | 内部のすべてに共通するルール |
| 内部の振る舞い | entry /、exit /、do / |
入るとき、出るとき、その状態にいる間に実行される |
遷移のラベルはどの部分も省略できます。invoice paid だけでイベント、[setting: unpaid] だけでガード、cancel / revoke access はアクションを伴うイベントです。構文と記号は UML 2.5.1 仕様に基づいています。
サブスクリプションのステータス
この例では、カードによる自動支払いを前提に、Stripe のサブスクリプションの概要にあるステータスを使います。
| ステータス | 意味 | アクセス |
|---|---|---|
incomplete |
初回の支払いがまだ済んでいない。顧客には 23 時間の猶予がある | なし |
incomplete_expired |
初回の支払いが一度も成功しなかった。何も請求されない | なし |
trialing |
トライアル期間中 | あり |
active |
正常な状態 | あり |
past_due |
最新の請求書の支払いが失敗したか、試行されなかった | 自分で決める |
unpaid |
再試行が尽き、設定でこの結果が選ばれている | なし |
paused |
支払い方法がないままトライアルが終わり、一時停止が選ばれている | なし |
canceled |
終了。Stripe は終端の状態と説明している | なし |
多くのプロダクトは、支払いの再試行が続いている間は past_due でもアクセスを残します。この例もそうしています。この判断は図に書いておきましょう。忘れられがちなのはこの点です。
遷移を組み立てる
状態を 1 つずつ取り上げ、そこでどんなイベントが起こりうるかを考えます。
- 開始。
subscribe [trial]でtrialingへ、subscribe [no trial]でincompleteへ進みます。 - 初回の支払い。
incompleteからinvoice paidでactiveに進みます。支払いがないまま 23 時間が過ぎると、サブスクリプションはincomplete_expiredに移り、Stripe は請求書を無効にします。この状態は最終です。やり直すには、顧客が新しいサブスクリプションを始めます。 - トライアルの終了。
trial endsで選択に進みます。[payment succeeds]ならactiveへ、この例では[no payment method]ならpausedへ進みます。この場合に何が起きるかは Stripe の設定で選べます。請求書を作成する(デフォルト)、解約する、一時停止する、の 3 つです。3 つすべてではなく、実際に使っている設定を描きましょう。 - 更新。
activeからrenewal payment failsでpast_dueに移り、invoice paidで戻ります。 - 再試行が尽きたとき。 もう 1 つの選択です。設定に応じて、サブスクリプションは
unpaidかcanceledに移るか、past_dueのまま残ります。unpaidからは、請求書を支払えばactiveに戻ります。 - 終了。
cancelでactiveからcanceledに移ります。期間末での解約は独立した状態ではありません。期間が終わるまでフラグ付きのactiveのままで、終わった時点でcanceledに移ります。
アクセスのための複合状態
ここでアクセスを与える状態は trialing、active、past_due の 3 つです。これらを 1 つの複合状態 Access granted の中に描けば、ルールを 1 度書くだけで済みます。entry / grant access と exit / revoke access です。複合状態から出る遷移は、どの状態から始まってもアクセスを取り消すので、どの経路でも取り消し忘れは起きません。trialing から active のように複合状態の中で状態を移っても、複合状態から出たことにはならず、これらのアクションは実行されません。
ダイアグラムの内容
- Access granted
- trialing
- active
- past_due
- paused
- incomplete
- incomplete_expired
- unpaid
- canceled
- subscribe [trial]
- subscribe [no trial]
- invoice paid
- after 23 h / void invoice
- trial ends
- [payment succeeds]
- [no payment method]
- resume [payment succeeds]
- renewal payment fails
- retries exhausted
- [setting: unpaid]
- [setting: cancel]
- cancel
図の読み方は次のとおりです。
- 上部。 開始点はトライアルの有無で分かれます。複合状態の中の
trialingか、外のincompleteです。incompleteからは支払いでactiveに進み、支払いのないまま 23 時間が過ぎるとincomplete_expiredを経て右側の終了状態に至ります。 - 中央は Access granted。
trial endsは選択に至ります。支払いが成功すればactiveへ、支払い方法がなければ左側のpausedへ進みます。更新が失敗するとactiveからpast_dueに移り、支払いで戻ります。 - 下部。 再試行が尽きると、2 つ目の選択でサブスクリプションは
unpaidかcanceledに送られます。unpaidは支払えばまだactiveに戻れますが、canceledは終了状態で終わります。 - 枠に入るエッジ はアクセスを与え、枠から出るエッジはアクセスを取り消します。
図からコードへ
この図は遷移表に対応します。表にないものはすべて拒否します。
type Status = 'incomplete' | 'incomplete_expired' | 'trialing' | 'active'
| 'past_due' | 'unpaid' | 'paused' | 'canceled';
const transitions: Record<Status, Partial<Record<string, Status>>> = {
incomplete: { invoice_paid: 'active', expired: 'incomplete_expired' },
trialing: { trial_ended_paid: 'active', trial_ended_no_method: 'paused' },
active: { payment_failed: 'past_due', cancel: 'canceled' },
past_due: { invoice_paid: 'active', retries_exhausted: 'unpaid' },
unpaid: { invoice_paid: 'active' },
paused: { resumed: 'active' },
incomplete_expired: {},
canceled: {},
};
function next(status: Status, event: string): Status {
const target = transitions[status][event];
if (!target) throw new Error(`${event} is not allowed in ${status}`);
return target;
}
イベントが決済事業者の Webhook から届く場合は、順番を前提にしてはいけません。Stripe の Webhook のドキュメントには、イベントが生成された順に届くことは保証しないと書かれています。各イベントはサブスクリプションの現在のステータスを取得するきっかけとして扱い、その状態に遷移を適用しましょう。
よくある間違い
- イベントの抜け。 状態ごとに、すべてのイベントを確認します。「
past_dueのときに顧客が解約したらどうなるか」は、図が答えるべき問いです。 - フラグを状態として描く。 期間末での解約、割引、シート数は状態の属性であって、状態ではありません。
- アクションを状態として描く。 「督促メールを送る」は遷移のアクションか
entryの振る舞いであり、サブスクリプションがとどまる場所ではありません。 - 終了状態がない。 ライフサイクルがどこで終わるかを示し、解約済みのサブスクリプションを復活させるコードを誰も書かないようにします。
- 重なったり穴があったりするガード。 1 つの選択から出るガードは、すべてのケースをちょうど 1 回ずつカバーする必要があります。必要なら
[else]を加えます。 - 1 つの図に 2 つのライフサイクル。 サブスクリプションとその請求書は、別々のステートマシンを持ちます。混ぜずに、イベントでつなぎましょう。
古くならないアーキテクチャ図で説明しているとおり、図はそれを実装するコードのそばに置き、同じプルリクエストで両方をレビューします。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- UML パックから、ステータスごとに 状態 を追加します。
trialing、active、past_dueを囲むように、Access granted という名前の基本パックの フレーム を追加します。- 基本パックから、開始状態用に小さく塗りつぶした 楕円 を、選択ごとに ひし形 を、終了状態ごとに点の入った円の アイコン を追加します。
- 状態どうしを
event [guard] / actionとラベルを付けたエッジでつなぎます。 - 複合状態の入場と退場のアクションを書いた UML の ノート を追加します。
この例で使うダイアグラムノードは 14 個で、Free プランの範囲に収まります。MCP で接続した AI エージェントを使えば、ステータスの enum を貼り付けて状態の下書きを任せ、遷移は自分で 1 つずつ確認できます。
よくある質問
ステートマシン図とフローチャートの違いは何ですか? フローチャートはプロセスの手順を順番に示します。ステートマシン図は、オブジェクトが取りうる状態と、その間を移すイベントを示します。つまり、次に何が起きるかではなく、何が許されるかに答える図です。
ステータス列の値はすべて状態にすべきですか? 振る舞いを変える値はすべて状態にすべきです。2 つの値がどの場合でも同じように振る舞うならまとめ、1 つの値が 2 つの振る舞いを隠しているなら分けます。
Webhook はステートマシンのどこに位置しますか? Webhook はイベントです。検証し、事業者から現在のステータスを取得してから遷移を適用します。イベントは順不同で届いたり、複数回届いたりするからです。
複合状態は必要ですか? アクセスや請求のように、複数の状態が 1 つのルールを共有しているなら使いましょう。多くの矢印に繰り返し書くはずのルールを、入場アクションと退場アクションの 1 組にまとめられます。
遷移のラベルはどこまで詳しく書くべきですか? イベントはドメインの言葉で名付け、同じイベントで 2 本の矢印が出るときだけガードを加えます。アクセスの付与のように、正しさに関わるアクションは図に書きましょう。