🗂

状態遷移をステータス間の関係から整理する方法

2024/12/15に公開

この記事は「レバテック開発部 Advent Calendar 2024」の15日目の記事です!
昨日はかにさんが担当していました!

はじめに

enumのようなものでステータスを管理することはよくあるかと思います。
タスクの状態や、営業の状態、注文の状態など、多岐にわたる状態管理でenumを使ったステータス管理は便利です。
しかし、このようなステータスは便利ですが、ビジネスの変化や新機能の追加によって、状態管理が難しくなり、保守性に困ることがあります。

この記事では、保守性を向上させるためにenumのステータスを整理する方法を三つ提案します。
これによってステータス管理の課題を避けられたり、課題を言語化しやすくなることを目指します。

enumを整理する三つの手法

他のステータスと同時に成立するステータスは避ける

タスク管理システムの例

あるタスク管理システムがあります。
このシステムでは、タスクを以下のように管理していました。

  • タスクが起票されたら作業中になる
  • タスクが終わったらレビューにする
  • レビューが終わったら完了にする

状態遷移
作業中->レビュー->完了

public enum TaskStatus {
    INPROGRESS, // 作業中
    REVIEW,     // レビュー
    COMPLETED   // 完了
}

要望:作業中にもレビューをしたい

後になって、以下の追加の要望が来たとします。

追加の要望
作業の完了を待たずに、作業中にもレビューすることがあるので、それをシステムでも適切に表現したいという要望が来たとします。

この追加の要望の解決策は、以下の二つがあるでしょう。

  • 「作業中->レビュー->完了」に、「作業中かつレビュー」を追加する
  • 「作業中->レビュー->完了」というステータスは維持して、レビューに関するステータスを追加する

ステータスを追加する案について
作業中かつレビューを追加する場合は問題があります。
今回はステータスを一つ追加するだけで済みましたが、こんな追加をしてたら管理しきれなくなります。

レビューに関するステータスを追加する
レビューに関するステータスを追加する場合も問題があります。
元のTaskStatusが状態遷移のステータスとして使い物にならなくなることです。
次にとるべき状態はこのステータスだけでわかりません。TaskStatusにレビューというステータスがありますが、このステータスは使って良いのでしょうか?悪いのでしょうか?
これはステータス見てもわからず、実際の運用を知らないといけません。

解決:問題は同時にステータスが成立すること

今回の問題は、仕様追加でenumで管理してたステータスが重複するようになったことです。
作業中なのにレビューがあるし、元のレビューもあるので、どっちがどっちかわかりません。
enumでは一つのステータスしか指定できないので、enumで管理している状態が複数成立すると問題になります。

この問題の解決法は以下のようなものが考えられます。

  1. enumのステータスを「作業中->完了」
  2. レビュー中に関するフラグを別に持つ
    状態遷移
    作業中->完了
public enum TaskStatus {
    INPROGRESS, // 作業中
    // REVIEW,     これがなくなる
    COMPLETED   // 完了
}

public class Task{
    // 省略
    public review_flag,   // このようなステータスが追加される
}

これによって、作業中ならばレビューされてるか、あるいはされていないことが表現されます。
このように状態が同時に成立する場合は、その状態はフラグ等にして、enumのステータスから外した方が良いです。[1]

当たり前の話かもしれませんが、既存のステータスの改修によってこの問題は発生することがあります。
発生するときは大抵開発者は同時に整理する問題に気がついていますが、工数などの事情でenumを多く追加する方針を取ることがあります。
この問題への対処は、常に「このステータスは他と本当に同時に成立することはあり得ないのか?」と自分や周囲に問い続けることです。

分岐と合流がある状態遷移は分割する

営業管理システムの例

ある営業システムは以下のようなステータスを持っていたとします。(休眠顧客関連のステータスが多いのは説明のためです...)

  • 見込み顧客からスタートして、コンタクトしていき、最後には成約する
  • 失注したら、失注ステータスになる
  • もしも、顧客と連絡がつかなくなった場合は休眠顧客となる
  • 休眠顧客には再喚起をして有効な顧客に転換させる

状態遷移

enum CustomerStatus {
  case Prospecting               // 見込み顧客のリストアップ中
  case ContactInitiated          // 初回コンタクト済み
  case QualifiedLead             // クオリファイドリード(商談可能と判断)
  case ProposalSent              // 提案書送付済み
  case Negotiation               // 交渉中
  case ClosedWon                 // 成約(受注)
  case ClosedLost                // 失注
  case Dormant                   // 休眠顧客(再アプローチ可能)
  case Reengaged                 // 再興味喚起中
  case DormantInterested         // 休眠顧客が再興味を示した
}

要望:再接触後に未応答の場合もステータスで管理したい

この営業管理システムで営業プロセスを管理していましたが、あるとき営業部署からこのような要望が来たとします。

追加の要望
休眠顧客に再興味喚起する施策をしている。すべての休眠顧客が再喚起に応じるわけではない。
そのため、再興味喚起したが反応しなかった顧客を管理するステータスを追加したい。

この要望を解決するために、CustomerStatus に新たなステータスを追加することとしました。

enum CustomerStatus {
  // 省略
  case ClosedLost                // 失注
  case Dormant                   // 休眠顧客(再アプローチ可能)
  case DormantRecontactAttempted // NEW!!! 再接触後未応答
  case Reengaged                 // 再興味喚起中(休眠状態からの再接触成功)
  case DormantInterested         // 休眠顧客が再興味を示した
}

発生する問題
この追加には問題があります。
顧客と連絡がつかなくなった場合の分岐である「休眠顧客」にしか影響のないはずの「再接触後未応答」が、実装上は他にも影響を与えるからです。

休眠顧客に関する文脈以外では再接触後未応答を追加した影響は避けたいはずです。
そうなると、実装上は次の二つの道があります。

  • is_dormant関数でも用意して判定する。ただ、switch文が使われてたり、このステータスが他システムからも使われている場合はどうする?
  • switch文も多くの箇所で今回の改修の影響はないと思われる。しかし、defaultを多用するのは避けたい。堅牢な開発を目指すならば、case文の網羅性を担保したいからだ。

つまりは、休眠顧客にだけ影響する話なのに、それ以外の箇所に対しても影響範囲調査をして、改修が本当に妥当なのかを検証していく必要があります。

解決:休眠顧客のステータスを分割する

影響範囲の調査が不必要に広がった理由は、再接触後未応答という概念が休眠顧客にしか関係してないのに、enumを通して休眠顧客の文脈外にも再接触後未応答という概念が漏れてることです。
再接触後未応答を元のenumであるCustomerStatusに追加しなければ解決です。しかし、休眠顧客のステータスもenumで管理したいとします。

これは次で解決できます。

  • 「未アプローチ、再接触後未応答、再興味喚起中、休眠顧客が再興味を示した」で表現できる休眠顧客の新しいステータスを追加する
  • CustomerStatus は新しい休眠ステータスの時は休眠顧客とする
  • 「再接触後未応答、再興味喚起中、休眠顧客が再興味を示した」のステータスをCustomerStatus から消す

状態遷移

// 休眠顧客に関するステータスが消えてる
enum CustomerStatus {
  // 省略
  case ClosedLost                // 失注
  case Dormant                   // 休眠顧客(再アプローチ可能)
}

// NEW
enum DormantStatus {
  case UnRecontact               // 未アプローチ
  case DormantRecontactAttempted // NEW!!! 再接触後未応答
  case Reengaged                 // 再興味喚起中(休眠状態からの再接触成功)
  case DormantInterested         // 休眠顧客が再興味を示した

}

なぜ、このように綺麗に休眠顧客のステータスを別のステータスに移動できたのでしょうか?
それは休眠顧客に関する、営業プロセス全体のステータスの状態遷移の分岐と合流を揃えやすかったからです。
言い換えると、状態が分岐したとしても、入力と出力が一定の範囲内ならば、分割された後の状態をカプセル化できます。

一つ前の状態を包括してるかどうかで考えてみる

要望:営業管理システムをさらにステータスを分割したい

前回の営業システムでは見込み顧客に関するステータスを別ステータスとして切り出しました。
しかしながら、まだ元のステータスは多く残ってる状態です。

今後のためにも、さらにステータスを分解できないかと今回は考察してみます。

状態遷移
休眠顧客の詳細を省いた上で図とenumを再掲。

enum CustomerStatus {
  case Prospecting               // 見込み顧客のリストアップ中
  case ContactInitiated          // 初回コンタクト済み
  case QualifiedLead             // クオリファイドリード(商談可能と判断)
  case ProposalSent              // 提案書送付済み
  case Negotiation               // 交渉中
  case ClosedWon                 // 成約(受注)
  case ClosedLost                // 失注
  case Dormant                   // 休眠顧客(再アプローチ可能)
}

解決:包括するステータスで分割する

分割・整理するための軸を探してみましょう。

ここまでに挙げた二つの分割・整理するための軸を再掲します。

  • 状態は同時に他のステータスが成立しないようにする
  • 分岐と合流のセットは分割する

さて、分岐と合流のセットは休眠顧客だけであり、これ以上の対処はできそうにないです。
一方で、今回の例でも同時に他のステータスが成立するところはあります。
例えば、交渉中は提案書送付済みであることを明らかに含みます。交渉中であるとは、提案書送付済みであることも意味するのです。

enumの状態管理は他と区別する意味がありますが、提案書送付済みは交渉中などの次のステップではないという了解があれば、区別するという用途は満たしています。
言い換えると、ある状態はそれよりも一つ前のステップを包括できていれば、それらのステータスの区別はできます。

一方で、失注の一つ前のステップは交渉中とも提案書送付済みともわかりません。失注は一つ以上前のステップを包括できていないです。

包括の関係で整理
この関係に基づいて整理した図を示します。

  • 商談は受注から見て、包括している関係であるクオリファイドリードまでを含めたステータスのブロック
  • リード開発は初回コンタクト済みから見て、包括している関係である見込み顧客リストアップ中を含めたステータスのブロック
  • クオリファイドリードから見て、休眠顧客と初回コンタクト済みは包括できない。そのため、その間でブロックが区切れる
  • 失注と休眠顧客は包括できるブロックがない

この図のように包括関係で状態をブロック化して整理ができます。

階層化と軸の考察

階層化の意義と利点

今回の話は一つのステータスで管理されている状態を、ステータス間の関係を使って階層化したものでした。

  1. ステータスが重複しているものは重複を消す
  2. 始点と終点がある。つまり、分岐と合流がある場合、その分岐されから合流までの支流、または分岐と合流をまとめて別ブロックにして階層化する
  3. それよりも一つ以上前のステップを包括する関係が連なる場合、それがつらなくなったところまでを別ブロックにして階層化する

このような手法によってステータスを整理し、階層化すると状態管理の煩雑さを抑え、次のような利点が生まれます。

  • 管理の単純化
    ステータスの全体像が見えやすくなり、新しい状態を追加する際にどこに組み込むべきかを直感的に判断できます。
  • 影響範囲の明確化
    階層化されたブロックごとに影響範囲を切り分けることで、改修の際に影響を受ける範囲を限定できます。

階層化を行う軸の種類

階層化の軸は複数存在しますが、それぞれの適用はユースケースに応じて選ぶ必要があります。
本記事で紹介したものはステータス間の関係に着目したものです。実務ではそれ以外の軸も考慮する必要があるでしょう。
今回の事例として出した営業ステータスに沿って、他の軸の例も紹介します。

アクター軸

ステータスがどのアクターによって操作されるかで階層化を行う方法です。

  • 例: 顧客ステータスを、「営業が操作するステータス」と「顧客が直接操作するステータス」に分ける
    • 営業が管理するステータス: 見込み顧客、クオリファイドリード
    • 顧客が関与するステータス: 成約、休眠

ビジネスプロセス軸

ステータスが属する業務プロセスで階層化を行う方法です。プロセスごとに責任を分割できるため、変更が限定的になります。

  • 例: 「商談プロセス」と「アフターフォロープロセス」に分けることで、両者のロジックを分離する。
    • 商談プロセス: 提案送付、交渉中、成約
    • アフターフォロー: サポート開始、契約更新

ステートの影響範囲軸

ステータス変更が影響を与える範囲で階層化を行う方法です。変更に伴う副作用を最小化する意図があります。

  • 例: 「通知が必要なステータス」と「内部ロジックのみ変更するステータス」に分ける。
    • 通知対象: 提案送付、成約
    • 内部専用: 初回コンタクト済み、休眠

時間軸

ステータスの時間的な流れに基づき階層化する方法です。

  • 例: 「短期的に頻繁に変わるステータス」と「長期的に保持されるステータス」を分ける。
    • 短期的: 提案送付、交渉中
    • 長期的: 成約、失注

軸の判断材料とどこまで分割すべきか

判断材料
軸を何を選ぶべきか判断も必要です。プロジェクトの目的や特性に応じて、次のポイントを基に選定を行えると良いと思います。

  • 変更頻度: 頻繁に変更されるステータスを他のステータスから分離する。
  • 影響範囲: ステータス変更が影響する範囲を明確にし、他の範囲に影響を及ぼさないようにする。
  • 関与するアクター: 各アクターが扱うステータスを分離して責務を明確化する。

どこまで分割すべきか
ステータスの分割しすぎも問題です。ステータスにまとめると分析やクエリが楽になるというメリットがあります。
変更頻度が少なくて、今困ってないステータスを分割すると無駄に煩雑になる可能性があります。

実際のステータスの検討では、変更頻度や影響範囲、ビジネス上の価値を考えながら行っていくべきです。

そもそもステータスとして表現しなくても良い可能性もある

軸に沿って分割して考えていくと、ステータスとして表現しなくても良いと思うこともあります。

今回紹介したようなステータスは、何らかのイベントに応じた状況を管理したものです。
それならば、イベントを保存しておいて、そのイベントからステータスを構築することもできます。

この考え方については私の以下の記事でも触れているので、興味があったらお読みください。
https://zenn.dev/levtech/articles/7e4e171819451c

まとめ

今回はenumを使う単一のステータスで管理されている状態遷移の整理をしました。
多くの人が取り組んでいる内容とは思いますが、このようなステータスを整理する方法を言語化してるところはあまり見ないので記事にしてみました。

今回の内容は論理学や集合論で説明できそうな気がしています。あるいは既に私が知らないだけでそういう説明がなされてるかもしれませんが...。[2]
状態管理について、数学的な方面からも機会があれば学習していきたいですね。

明日はhigakitさんが投稿します!
レバテック開発部 Advent Calendar 2024」をぜひご購読ください!

脚注
  1. 実際のところ、ステータスはDBで管理されることが大半と思います。そのため、これらのステータスはある意味で正規化が崩れてると言えます。ステータスの分割は、DBの性能やクエリの書きやすさ、保守性といったものをトレードオフにして考える必要があります。 ↩︎

  2. 数学的に説明を試みたとしても、それによる理解を実務に活用しようとしたら今回の記事のようなものは生まれるのではないかとも考えてもいます。 ↩︎

レバテック開発部

Discussion