【ITニュース解説】How a State Machine (AASM) Ended Our Recurring Payment Failures in Rails
2026年10月15日に「Dev.to」が公開したITニュース「How a State Machine (AASM) Ended Our Recurring Payment Failures in Rails」について初心者にもわかりやすく解説しています。
ITニュース概要
Rails支払い処理のバグは安易なステータス更新が原因。AASMステートマシンで遷移ルールを厳密化し、無効状態を排除した。DBロック、冪等性、履歴で再発バグを解決した。
ITニュース解説
あるRailsを使ったサービスで、顧客の定期的な支払いを管理するシステムが抱えていた深刻な問題について解説する。このシステムでは、銀行振込やクレジットカードなど多様な支払い方法を扱い、その情報は外部の決済プロバイダーや顧客管理システム(CRM)と常に同期する必要があった。
しかし、この支払いシステムは、開発者を悩ませる多くのバグを繰り返し発生させていた。例えば、支払いが「ありえない状態」で停止したり、再試行後に同じ支払いが二重に処理されたり、システムの集計結果が実際の取引と一致しないといった状況が頻発した。バグを修正しても、しばらくすると似たような問題が再発し、根本的な解決には至らなかった。
問題の原因を深く掘り下げてみると、そのパターンは明確だった。システムの「支払いのステータス」は、単なる文字列型のデータとしてデータベースに保存されており、このステータスはシステムの様々な場所から、何の制約もなく自由に更新されていたのだ。例えば、支払いがあったら「支払い済み」、バックグラウンド処理が失敗したら「失敗」、外部システムからの通知(ウェブフック)が来たらその内容に合わせてステータスを更新するといった具合で、コードのあちこちでそれぞれの都合に合わせてステータスが変更されていた。
この設計には、主に三つの大きな問題があった。一つ目は、「ありえない状態遷移が可能」だったことだ。失敗した支払いが再試行プロセスを経ずにいきなり「支払い済み」になったり、返金された支払いが再び「処理中」に戻ったりするような、論理的に矛盾する状態変化がシステム上で起こり得る状況だった。これを阻止する仕組みが一切なかったのである。二つ目は、「正しい状態に関する唯一の情報源がない」ことだった。支払いに関する「こうあるべき」というルールが、コントローラー、バックグラウンドジョブ、コールバック処理、ウェブフックハンドラーなど、コードの様々な場所に分散して記述されていた。それぞれの場所が独自の前提でステータスを扱っていたため、全体としての一貫性が保たれなかった。三つ目は、「再試行やウェブフック処理の複雑さ」だ。決済プロバイダーからのイベント通知は、稀に同じものが複数回送られたり、正しい順序で届かないことがあった。このような状況でルールがなければ、最後にデータベースに書き込まれた情報が「正」とみなされ、誤った状態がシステムに定着してしまった。
つまり、バグは特定のコード一行にあるのではなく、ステータス管理の「設計そのもの」にあったのだ。この設計では、矛盾する状態を許容してしまうため、遅かれ早かれデータが壊れてしまうのは避けられなかった。
そこで導入されたのが、「ステートマシン」という考え方だ。ステートマシンとは、システムの特定の要素(この場合は支払い)が取り得る「状態」と、その状態を別の状態へと変化させる「イベント」、そして「どのような状態からどのような状態へ、どのイベントによって遷移できるか」という「遷移ルール」を明確に定義するシンプルな仕組みのことだ。
具体的には、支払いの状態として「保留中」「処理中」「成功」「失敗」「返金済み」といった固定のセットを定める。そして、状態を変化させるイベントとして「処理開始」「決済完了」「失敗としてマーク」「返金」といったものを定義する。そして、「保留中から処理中へは処理開始イベントで遷移できる」「処理中から成功へは決済完了イベントで遷移できる」といった、許可された遷移のリストを明確に書き出すのだ。
このルールを一つにまとめることで、無効な状態遷移はもはや静かにデータを壊す原因ではなく、システムがエラーとして検知し、ログに記録し、適切に処理できる明確な問題となる。例えば、「保留中」の支払いに「決済完了」イベントを適用しようとすると、システムはそれを拒否し、エラーとして通知するようになる。
このステートマシンをRailsアプリケーションで実現するために、「AASM」というRubyの有名なライブラリ(gem)が活用された。AASMを使うと、支払いモデル(Payment)の中に、その状態と遷移ルールを宣言的に記述できる。aasmブロックの中で、stateを使って支払いモデルが取り得る状態を列挙し、initial: trueで初期状態を指定する。そしてeventブロックの中で、状態を変化させるイベント名と、そのイベントによって「どの状態からどの状態へ遷移できるか」をtransitionsで定義する。例えば、「処理開始」イベントは、「保留中」または「失敗」の状態から「処理中」へ遷移できると定める。
AASMの導入によるメリットはいくつかある。まず、宣言された遷移だけが許可されるという点だ。「保留中」の支払いに「決済完了」イベントを実行しようとすると、AASMはAASM::InvalidTransitionというエラーを発生させ、不正なデータが静かに書き込まれるのを防ぐ。次に、「ガード(Guard)」機能を使ってビジネスルールを状態遷移のそばに置ける点だ。「返金」イベントは、refundable?という特定の条件メソッドがtrueを返す場合にのみ実行される。これにより、「支払いから90日以内であること」といったビジネスルールが、そのルールが保護する遷移のすぐ隣に記述され、コードの可読性と保守性が向上する。さらに、may_event?のようなメソッドを使って、あるイベントが現在実行可能かどうかを事前にチェックできる。これは、バックグラウンドジョブやウェブフックハンドラーで、遷移が不可能な場合にエラーを発生させるのではなく、処理を優雅にスキップしたい場合に非常に便利だ。そして、AASMのイベントメソッドには、末尾に!が付くもの(例:settle!)と付かないもの(settle)がある。settle!は、状態遷移を実行し、さらにデータベースにレコードを保存する。保存に失敗した場合はエラーを発生させる。一方、settleはメモリ上のオブジェクトの状態だけを変更し、データベースへの保存は行わないため、この違いを理解することが重要である。
AASMを導入したことで、これまでシステムのあちこちに散らばっていたpayment.update(status: "...")のような直接的なステータス更新は、payment.start_processing!やpayment.settle!といった、意図が明確なイベント呼び出しに置き換えられることになった。
しかし、ステートマシンを導入しただけでは、まだ全ての支払いエラーを防ぐことはできなかった。AASMは「無効な遷移」を防ぐが、「同時に二つの有効な遷移が起こる可能性」や「同じイベントが複数回届く可能性」からは保護してくれないのだ。決済システムのような、厳密な整合性が求められる場面では、これらの問題も非常に重要になるため、さらに三つの対策が追加された。
一つ目は、「遷移中のレコードをロックする」ことだ。例えば、二つのバックグラウンド処理が同時に同じ支払いレコードを読み込み、両方が「処理中」の状態にあると判断し、「決済完了」させようとする状況があり得る。このような同時実行によるデータ競合を防ぐために、データベースの「行ロック」という仕組みを利用する。payment.with_lock do ... endのように記述することで、with_lockブロック内では、データベースがそのレコードをロックし、他の処理が同じレコードを更新しようとすると、ロックが解除されるまで待機する。これにより、一方の処理が決済を完了させると、もう一方の処理はすでに「成功」状態になったレコードを見て、二重の決済完了を防ぐことができる。
二つ目は、「イベント処理を冪等(べきとう)にする」ことだ。決済プロバイダーからのウェブフック通知は、同じイベントが重複して届くことが避けられない場合がある。このような重複を問題なく処理するためには、同じイベントを複数回受け取っても、結果が一度だけ処理された場合と変わらないようにする「冪等性」が重要になる。具体的には、イベントIDを保存する専用のテーブルを作成し、そのイベントIDにデータベースのユニーク制約を設ける。新しいイベントが届くたびに、まずそのイベントIDをテーブルに保存しようと試みる。もし同じIDが既に存在すれば、ユニーク制約違反のエラーが発生し、そのイベントは既に処理済みと判断して何もせず処理を終える。これにより、重複したイベントが届いても、支払いへの影響は一度きりとなる。
三つ目は、「副作用を状態遷移のロジックから切り離す」ことだ。例えば、支払いが成功した際に顧客にメールを送ったり、外部のCRMシステムと連携したりする処理を、ステート遷移の最中に直接実行してしまうと、もしメール送信やCRM連携が遅延したり失敗したりした場合、支払い自体の状態更新までがブロックされてしまう可能性がある。これでは、外部サービスの状態に支払いの状態が左右されてしまう。この問題を避けるため、状態遷移がデータベースにコミット(永続化)された後に、別プロセスとして非同期ジョブを起動するようにする。Railsではafter_commitコールバックを使うことで、トランザクションが正常に完了した後にのみ、CRM同期ジョブのような後続処理をキューに追加できる。これにより、たとえCRMが一時的にダウンしていたとしても、支払い自体の状態は正しく更新されており、CRM同期ジョブは独自のスケジュールでリトライを試みることができる。
そして、もう一つ重要な対策として、「全ての状態遷移の履歴を記録する」ことが挙げられる。本番環境で何らかの問題が発生した際、「この支払いに何が、どのような順番で起こったのか?」という質問が常に最初に出てくる。これに答えるため、AASMのafter_all_transitionsコールバックを利用して、全ての状態遷移の前後、発生したイベントを専用のテーブルに記録する。これにより、サポート担当者、経理部門、そして開発者が問題をデバッグする際に役立つ、詳細な監査証跡が手に入る。システム上で何か矛盾が生じた場合でも、この履歴を見れば何が起きたのかを正確に追跡できるようになる。
ステートマシンの導入と合わせて、その動作を保証するためのテストも不可欠だ。ステートマシンのテストは比較的簡単で、同時にビジネスルールの良いドキュメントにもなる。例えば、「処理中の支払いであれば決済が成功する」といった正しい遷移のテストや、「保留中の支払いには決済イベントが拒否される」といった無効な遷移のテストを記述する。特に、無効な遷移に対するテストを丁寧に書くことは、将来的にルールが曖昧になるのを防ぐ上で非常に重要だ。
もし既存のシステムにステートマシンを導入する場合、既存のデータの移行作業が必要となる。これは地味な作業だが、最も重要かもしれない。まず、現在のstatusカラムにどのような値が存在するかを全て洗い出す。次に、これらの既存の値を、新しく定義したステートマシンの状態のどれにマッピングするかを決定する。そして、実際のデータ移行は、大量のデータがあってもシステムに負荷をかけないように、バッチ処理で少しずつ実行し、移行前後のデータ数の一貫性を確認することが重要だ。
これらの変更を行った結果、システムの支払いに関するバグの性質は劇的に変化した。これまで悩まされていた繰り返しの障害や、支払い集計の不一致といった問題は発生しなくなった。それは、これらの問題を引き起こしていた無効な状態が、もはやシステムに書き込まれることが不可能になったからだ。残ったバグも、状態遷移の履歴を確認することで、何がどのような順序で発生したのかを正確に把握できるようになり、診断が格段に容易になった。また、支払いの動作に変更を加える際には、必ず明確に定義されたイベントを介する必要があるため、コードレビューも以前より簡単になった。
この経験から得られた教訓はいくつかある。まず、もしシステム内のレコードが時間の経過とともに状態を変えるようなライフサイクルを持っているのなら、それは「ステートマシン」として明示的にモデル化すべきだ。次に、無効な状態は、そもそもシステムで表現できないようにするか、少なくとも明確にエラーとして通知されるようにすべきだ。さらに、ステートマシンは、データベースの「行ロック」やイベント処理の「冪等性」と組み合わせて使うことで、その真価を発揮する。また、メール送信や外部システムとの連携といった「副作用」は、状態遷移のロジックから切り離し、状態がデータベースにコミットされてから非同期的に実行するようにすべきだ。そして、全ての状態遷移は必ずログに記録すること。いつ、どんな問題が発生しても、その履歴は想像以上に早く必要になるだろう。
もちろん、ステートマシンを導入するには、初期段階での設計に多くの時間をかけたり、既存データの移行が必要だったり、コールバックの中に重いビジネスロジックを詰め込まないように規律を保ったりといったトレードオフがある。しかし、お金が関わるようなシステムにおいては、これらの手間をかけるだけの価値は十分にあると言えるだろう。