カオスになりがちな業務ロジックをステートマシンで綺麗に作る
「あの処理を追加するなら...ここと、ここと、あとあそこも修正が必要で...」
要件追加のたびにそんな調査が発生する状況、覚えがありませんか?
株式会社 Finatext クレジットチームのエンジニア @fumiyakk です。この記事では、そんな状況を改善するためにチームで採用したステートマシン設計を紹介します。
この記事は Finatext Advent Calendar 2025 の 7 日目の記事です。
はじめに
プロダクトを 0-1 で作る時って、何も悩みがなくて楽しいですよね。
みんなでワイワイ話してアーキテクチャを考えて、ガンガン開発して、動くものを作って、たくさん使ってもらう。事業が成長して、どんどん人が増えてくる。すると、いつの間にかいろんな要件が盛り込まれて、当初想定していなかった処理が継ぎ足されていき、じわじわと開発が苦しくなって。。。
そのアーキテクチャの複雑さを解決するために、DDD やクリーンアーキテクチャ、マイクロサービスやモジュラーモノリスなどいろんな手法で試行錯誤していることと思います。クレジットチームではモジュラーモノリスを採用しています。モジュラーモノリス採用の経緯や詳細は別の記事に書いてあるので、興味がある方はぜひご覧ください。 1.苦しんでたどり着いたモジュラーモノリス ・ 2.モジュラーモノリスのモジュール間連携
いろんな手法を試してみても、実際の開発現場ではどうしても業務ロジックが境界づけられたコンテキストをまたがってしまったり、処理の前後関係が高度に複雑化して負債が積もってしまうことがあります。戦略的にも戦術的にも DDD を実践できていれば解消できるのかもしれませんが、現実的にはビジネスサイドの理解を得られずに時間が作れなかったり、メンバーの設計思想がチグハグなまま進めざるを得ないなど、なかなか難しいのが実情です。
私たちはこうした複雑化した業務ロジックを整理するために、ステートマシンの概念を導入しました。この記事では、ステートマシンを使って業務ロジックを整理した事例を紹介します。ステートマシンを作ってから 2 年弱が経過して、ますますこのアプローチの有用性を感じているので、ぜひ参考にしていただければと思います。
ステートマシンとは
ステートマシン(Finite State Machine: FSM / 有限オートマトン)とは、システムが 「今どの状態にあるか」を定義し、ある「きっかけ(イベント)」によって「次の状態」へどう変化するか をモデル化したものです。
以下の 3 つの要素で構成されます。
- 状態 (State): そのオブジェクトが今どういう状況か。
- イベント (Event / Trigger): 状態を変えるきっかけとなるアクション。
- 遷移 (Transition): 「ある状態」の時に「あるイベント」が起きたら、「次の状態」へ移ること。
これらの要素を組み合わせて、システムの振る舞いを定義します。
Go の場合、github.com/looplab/fsm というライブラリが有名です。これを使えば、簡単なコードでステートマシンを実装できます。 元の状態とイベントと次の状態を定義した struct を作成すると、ステートマシンとして動作します。
ですが、string 型に依存してしまうので実装がフラジャイルになってしまいます。また、コールバックを設定することもできるのですが、状態遷移のロジックが分散してしまい、複雑な業務ロジックを表現するのが難しいです。
かゆい所に手が届かない感じがしたので、私たちは独自実装でステートマシンを作成しました。
ステートマシンを拡張してやりたいこと
ステートマシンの基本的な概念は上記の通りですが、以下の特徴を追加して実装しました。
- 状態とイベントを型安全に扱う
- 状態遷移の前にフックを設定できる
- 状態遷移の後にフックを設定できる
具体的な実装例を交えて説明します。
1. 状態とイベントを型安全に扱う
Go の Generics を活用することで、状態とイベント(アクション)を型安全に扱えるステートマシンを実装しました。
まず、ステートマシンの核となる型定義を見てみましょう。
// StateTransitionMap はアクションに対応する状態遷移を管理する
type StateTransitionMap[Entity any, Action comparable] map[Action]StateTransition[Entity, Action]
// StateTransition は状態遷移を実行する関数
type StateTransition[Entity, Action any] func(context.Context, Entity, Action, time.Time) error
StateTransitionMap は 2 つの型パラメータを持ちます。
-
Entity: 状態を持つエンティティの型(例:*User) -
Action: 状態遷移のきっかけとなるアクションの型(例:UserAction)
この設計により、ステートマシンを使う側は具体的な型を指定する必要があります。
// User エンティティの状態を表す型
type UserStatus string
const (
UserStatusActive UserStatus = "ACTIVE"
UserStatusNameChanged UserStatus = "NAME_CHANGED"
)
// User エンティティに対するアクションを表す型
type UserAction string
const (
UserActionUpdateName UserAction = "UPDATE_NAME"
)
この例では、User エンティティの状態遷移は以下のように表現されます。
これらの型を使ってステートマシンを初期化すると、以下のようになります。
func NewUserFSM(repo UserRepository) fsm.StateTransitionMap[*User, UserAction] {
return fsm.StateTransitionMap[*User, UserAction]{
UserActionUpdateName: updateNameTransition(repo),
}
}
updateNameTransition は状態遷移の具体的な処理を定義した関数です。StateTransition 型のシグネチャに従って実装します。
func updateNameTransition(repo UserRepository) fsm.StateTransition[*User, UserAction] {
return func(ctx context.Context, user *User, action UserAction, at time.Time) error {
// 状態を Active から NameChanged に変更
user.Status = UserStatusNameChanged
return repo.UpdateUser(ctx, user)
}
}
この関数は StateTransition[*User, UserAction] 型を返すため、引数の型が厳密にチェックされます。例えば、誤って *Contract 型を受け取ろうとするとコンパイルエラーになります。
この実装には以下のメリットがあります。
-
コンパイル時のチェック: 間違った型のエンティティやアクションを渡すとコンパイルエラーになります。例えば
ContractActionをUserFSMに渡そうとするとコンパイルが通りません。 - typo の防止: 文字列リテラルを直接使う場合と異なり、定義されていないアクションを使おうとするとコンパイルエラーになります。
- IDE のサポート: 型が明確なため、補完やリファクタリングが効きやすくなります。アクション名を変更する際も、IDE の一括置換機能で安全にリファクタリングできます。
2. 状態遷移の前にフックを設定できる
状態遷移を実行する前に、バリデーションなどの前処理を挟みたいケースがあります。例えば「この状態からのみ遷移を許可する」「特定の条件を満たしている場合のみ遷移できる」といった制約です。
このような前処理を実現するために、Validator フックを用意しています。
// Validator は状態遷移の前に実行されるバリデーション用フック
// いずれかのバリデーションがエラーを返すと、状態遷移はキャンセルされる
func Validator[Entity, Action any](vs ...StateTransition[Entity, Action]) Hook[Entity, Action] {
return func(t StateTransition[Entity, Action]) StateTransition[Entity, Action] {
return func(ctx context.Context, e Entity, a Action, at time.Time) error {
for _, v := range vs {
if err := v(ctx, e, a, at); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
}
return t(ctx, e, a, at) // すべてのバリデーションが通ったら状態遷移を実行
}
}
}
Validator は可変長引数でバリデーション関数を受け取り、すべてのバリデーションが成功した場合のみ状態遷移を実行します。
具体的なバリデーション関数の例として、「現在の状態が Active の場合のみ遷移を許可する」というチェックを実装してみましょう。
func validateUserIsActive() fsm.StateTransition[*User, UserAction] {
return func(ctx context.Context, user *User, action UserAction, at time.Time) error {
if user.Status != UserStatusActive {
return fmt.Errorf("user status must be ACTIVE, but got %s", user.Status)
}
return nil
}
}
このバリデーション関数を FSM に登録するには、RegisterHooks メソッドを使います。
func NewUserFSM(repo UserRepository) fsm.StateTransitionMap[*User, UserAction] {
sm := fsm.StateTransitionMap[*User, UserAction]{
UserActionUpdateName: updateNameTransition(repo),
}
// UpdateName アクションに対してバリデーションを登録
sm.RegisterHooks(
fsm.Validator(validateUserIsActive()),
UserActionUpdateName,
)
return sm
}
これにより、UpdateName アクションが実行される前に必ず validateUserIsActive が呼び出され、ユーザーの状態が Active でない場合はエラーが返されて状態遷移がキャンセルされます。
複数のバリデーションを登録することも可能です。
sm.RegisterHooks(
fsm.Validator(
validateUserIsActive(),
validateUserHasPermission(),
),
UserActionUpdateName,
)
3. 状態遷移の後にフックを設定できる
状態遷移が成功した後に、他モジュールへの伝播やイベントの発行などの後処理を実行したいケースがあります。このような後処理を実現するために、Distributor フックを用意しています。
// Distributor は状態遷移が成功した後に実行される後処理用フック
func Distributor[Entity, Action any](ds ...StateTransition[Entity, Action]) Hook[Entity, Action] {
return func(t StateTransition[Entity, Action]) StateTransition[Entity, Action] {
return func(ctx context.Context, e Entity, a Action, at time.Time) error {
if err := t(ctx, e, a, at); err != nil {
return err // 状態遷移が失敗したら後処理は実行しない
}
for _, d := range ds {
if err := d(ctx, e, a, at); err != nil {
return fmt.Errorf("distribution failed: %w", err)
}
}
return nil
}
}
}
Distributor は状態遷移が成功した場合にのみ後処理を実行します。状態遷移が失敗した場合は後処理はスキップされます。
具体的な後処理関数の例として、「名前変更後に他モジュールへ伝播する」という処理を実装してみましょう。
func notifyNameChanged(contractModule ContractModule) fsm.StateTransition[*User, UserAction] {
return func(ctx context.Context, user *User, action UserAction, at time.Time) error {
// Contract モジュールに名前変更を伝播
return contractModule.UpdateContractUserName(ctx, user.ID, user.Name)
}
}
この後処理関数を FSM に登録するには、Validator と同様に RegisterHooks メソッドを使います。
func NewUserFSM(repo UserRepository, contractModule ContractModule) fsm.StateTransitionMap[*User, UserAction] {
sm := fsm.StateTransitionMap[*User, UserAction]{
UserActionUpdateName: updateNameTransition(repo),
}
// バリデーション(前処理)
sm.RegisterHooks(
fsm.Validator(validateUserIsActive()),
UserActionUpdateName,
)
// 伝播(後処理)
sm.RegisterHooks(
fsm.Distributor(notifyNameChanged(contractModule)),
UserActionUpdateName,
)
return sm
}
このように Validator と Distributor を組み合わせることで、状態遷移の前後に任意の処理を挟むことができます。実行順序は以下のようになります。
-
Validatorによるバリデーション(失敗したらここで終了) - 状態遷移の本体処理(失敗したらここで終了)
-
Distributorによる後処理
ステートマシンの実装例
ここまで説明してきた実装を組み合わせると、ユースケース層からは以下のように FSM を呼び出すだけでシンプルに状態遷移を実行できます。
type usecase struct {
userRepo repository.UserRepository
userFSM fsm.StateMachine[*entity.User, entity.UserAction]
}
func (u *usecase) UpdateUserName(ctx context.Context, userID uuid.UUID, newName string) error {
// 名前のバリデーション
if newName == "" {
return fmt.Errorf("user name cannot be empty")
}
// ユーザーを取得
user, err := u.userRepo.GetUser(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// 名前を更新
user.Name = newName
// FSM で状態遷移を実行(バリデーションや後処理も含めて実行される)
if err := u.userFSM.Transition(ctx, entity.UserActionUpdateName, user, time.Now()); err != nil {
return fmt.Errorf("failed to transition user state: %w", err)
}
return nil
}
Transition メソッドを呼び出すと、以下の処理が順番に実行されます。
-
バリデーション:
Validatorに登録された関数が実行され、現在の状態がActiveかチェック -
状態遷移: ユーザーの状態を
NameChangedに変更し、データベースに保存 -
後処理:
Distributorに登録された関数が実行され、Contract モジュールに名前変更を伝播
これらの処理はすべて FSM の定義に集約されているため、ユースケース層のコードはシンプルなままです。
実際の業務での活用
この FSM の設計は、特にモジュラーモノリスのような境界づけられたコンテキストを持つアーキテクチャで威力を発揮します。
FSM を使わない場合の問題点
モジュール間で連携が必要な処理を素朴に実装すると、以下のような問題が発生しがちです。
// FSM を使わない場合のユースケース
func (u *UpdateUserNameUsecase) Execute(ctx context.Context, userID uuid.UUID, newName string) error {
user, err := u.repo.GetUser(ctx, userID)
if err != nil {
return err
}
// バリデーションがユースケースに散らばる
if user.Status != UserStatusActive {
return errors.New("user is not active")
}
user.Name = newName
user.Status = UserStatusNameChanged
if err := u.repo.UpdateUser(ctx, user); err != nil {
return err
}
// 他モジュールへの伝播もユースケースに書く必要がある
// 新しい要件が追加されるたびにここが肥大化していく
if err := u.contractModule.UpdateContractUserName(ctx, userID, newName); err != nil {
return err
}
return nil
}
このアプローチでは、要件が追加されるたびにユースケースが肥大化し、どの処理がどの状態遷移に紐づいているのか把握しづらくなります。
FSM を使う場合のメリット
FSM を使うと、状態遷移に関連する処理が一箇所に集約されます。
- 関心の分離: ユースケースは本来の処理に集中し、イベントに関連する他の処理(バリデーションや伝播など)は FSM に委譲
-
拡張性: 新しいバリデーションや後処理を追加する際、FSM の定義に
RegisterHooksを追加するだけ - 可読性: あるアクションに対してどんな処理が行われるか、FSM の定義を見れば一目瞭然
- テスタビリティ: バリデーションや後処理を個別の関数として切り出せるため、単体テストが書きやすい
例えば「ユーザー名変更時にメールを送信する」という要件が追加された場合、ユースケースのコードを変更せずに FSM の定義だけを更新すれば対応できます。
sm.RegisterHooks(
fsm.Distributor(
notifyNameChanged(contractModule),
sendEmailNotification(notificationModule), // 新しい後処理を追加
),
UserActionUpdateName,
)
このように、FSM を使うことで状態遷移に関連するロジックを整理し、変更に強いコードベースを維持できます。
まとめ
この記事では、複雑化した業務ロジックを整理するためにステートマシンを独自実装した事例を紹介しました。
私たちが実装したステートマシンの特徴は以下の 3 点です。
- 型安全: Generics を活用し、エンティティとアクションを型パラメータ化することで、コンパイル時に不正な組み合わせを検出できる
- 前処理フック(Validator): 状態遷移の前にバリデーションを実行し、条件を満たさない場合は遷移をキャンセルできる
- 後処理フック(Distributor): 状態遷移の成功後に、他モジュールへの伝播などの後処理を実行できる
ステートマシンを導入することで、状態遷移に関連する処理が FSM の定義に集約され、ユースケース層のコードがシンプルになりました。新しい要件が追加された際も、FSM にフックを追加するだけで対応できるため、変更に強いコードベースを維持できています。
ここまで読んでいただいた方には理解していただけるかと思いますが、ステートマシンを使わずに SQS や Pub/Sub をなどのメッセージングシステムを使って非同期に処理を分散させる方法と似たような思想です。ですがメッセージングシステムを使うとアプリケーションロジックがインフラにも散らばってしまい、全体像が把握しづらくなるデメリットがあります。設計当初も同じような議論がありましたが、最終的にはアプリケーション内で完結するステートマシンの方が理解しやすく、保守性が高いと判断して採用しました。
2 年弱運用してきた実感として、このアプローチがシステムの継続的な開発速度向上に大きく寄与していると感じています。特に機能追加としてありがちな「とあるイベントをきっかけに複数の処理を実行する」というパターンの要求に対して、負債を作ることなくスピーディーに開発できているのが強みです。B2B のシステム開発では特にこうした要件が頻出するため、ステートマシンの導入は非常に有効でした。
1 つのアーキテクチャパターンとして、ぜひ参考にしていただければ幸いです。
Discussion