300行を超える注文処理のアクションを、Claude Code に「Service Object に分けて」と一括で頼んだことがあります。出てきた分割は一見きれいでしたが、在庫の引き当てと決済の境界が誤っていて、決済失敗時に在庫だけ減ったまま残る構造になっていました。
そこからは、一度に分けさせるのをやめて1責務ずつ、毎回テストで固定してから次へ進めるやり方に切り替えました。この記事は、その手順とAIへの指示の粒度、レビューで見ている点をまとめたものです。題材は Rails の注文アクションですが、Laravel でも考え方は同じです。
前提と書いてあること / ないこと
- 題材: 在庫チェック・決済・メール送信・ポイント付与が1つのアクションに混在している注文処理(実案件を一般化したもの)
- 環境: Rails(Laravel 側は別案件の経験)、Claude Code、既存のリクエストテストあり、またはこれから書く
- この記事のコードは説明用の骨格で、そのままでは動きません(コード検証状態は末尾に記載)。実行結果は載せていません
なぜ一括で分割させると失敗するのか
注文アクションが300行を超えていた案件では、在庫チェック・決済・メール送信・ポイント付与のロジックが1本に混ざっていました。最終的には Service Object に分離してコントローラは20行ほどに収まり、各サービスのユニットテストも書きやすくなりました。結果だけ見れば順当ですが、最初の一括依頼は失敗しています。
原因は、AIがコードの見た目の塊で境界を引くことだと私は考えています。トランザクションの範囲や「どこまで失敗したら巻き戻すか」は、コードの字面からは読み取りにくい。そのため、塊としてきれいに分かれても、業務上の不変条件(決済に失敗したら在庫を戻す)が分割線をまたいでしまいます。
進め方:1責務ずつ切る5ステップ
Step 1: まず分割せず、責務の地図だけ作らせる
最初の指示は「リファクタリングして」ではなく、読み取り専用の分析にします。
このアクションを変更せず、次を表にして。
- 各ブロックの責務(在庫/決済/通知/ポイント/その他)
- 各ブロックが読む・書くDBテーブル
- トランザクションの開始・終了位置
- 失敗したときに巻き戻すべきもの / 巻き戻せない外部副作用(メール送信など)
古い PHP コードを段階的に直したときも、最初に構造分析をさせて依存関係マップを作らせました。変更前に地図を出させる工程は、言語が違っても効きます。この表で、巻き戻せない副作用(メール、外部決済API)をどこに置くかを人間が先に決めます。
Step 2: 現状の挙動をテストで固定する
分割の前に、既存の挙動を拾うリクエストテストを書かせます。正常系だけでなく「在庫不足」「決済失敗」の2本は必須にしています。
# 説明用の骨格(未実行)。実プロジェクトの factory 名・パス・エラー表現に置き換える
RSpec.describe "POST /orders", type: :request do
it "決済失敗なら在庫が減らない" do
# 在庫10の商品を用意し、決済ゲートウェイをスタブして失敗させる
post orders_path, params: valid_params
expect(product.reload.stock).to eq 10
expect(Order.count).to eq 0
end
end
このテストが「今のコードで通る」ことを先に確認します。通らないなら、それは既存バグであり、分割と混ぜてはいけません。
Step 3: 最初に切るのは在庫(副作用が最も戻しやすい)
切り出す順番は 在庫 → 決済 → 通知 にしました。理由は、巻き戻せる処理(DB更新)から先に出し、巻き戻せない処理(外部決済・メール)を最後まで元の位置に残せるからです。
指示は1責務に絞ります。
在庫チェックと引き当てのブロックだけを OrderStock::Reserve に切り出して。
- 触るのは対象ブロックとコントローラの呼び出し箇所のみ
- トランザクション境界・他のブロックの順序は変えない
- 戻り値は Result 風(成功/在庫不足)にして、例外で制御しない
- 終わったら既存テストを実行して結果を貼る
# 説明用の骨格(未実行)
class OrderStock::Reserve
Result = Struct.new(:ok, :error, keyword_init: true)
def initialize(cart:)
@cart = cart
end
def call
# 各明細の在庫を確認し、足りなければ Result.new(ok: false, error: :out_of_stock)
# 足りれば引き当てて Result.new(ok: true)
end
end
Step 4: 決済を切る。ここがいちばん境界を誤りやすい
決済は在庫と同じトランザクションに入るのか、外に出すのかで設計が変わります。Step 1 の表で決めた方針をそのまま指示に書き、**「トランザクションをまたぐ場合は切り出さず、私に確認して」**と明示します。AIが勝手に transaction の範囲を動かすのを止めるためです。
Rails の公式ガイドでは、保存・削除のコールバックの連鎖が1つのトランザクションに包まれ、通常の例外はロールバックされたうえで再送出される、と説明されています。ただし ActiveRecord::Rollback と ActiveRecord::RecordInvalid は、この再送出の一般則から除外されています。また throw :abort で止めた場合、create や destroy などの非bang版は例外ではなく false を返します(Active Record Callbacks の Halting Execution 節。2026-10-31 確認)。通常の例外についてはこの性質を前提にすると、サービス内で例外を握りつぶす実装は、巻き戻しを黙って無効にしかねません。レビューではここを必ず見ます。
Step 5: 通知を切る。ここで「サービスか、イベントか」を分ける
この案件では、メール送信とポイント付与を、注文成立後の後処理として定義しました(ポイント付与が注文の成否と独立してよいかは業務要件しだいで、DB更新なら通常はロールバック可能です)。そう定義した部分は、Service Object で呼び出し順を書くより、イベントにしたほうが素直でした。
別案件の Laravel で、注文完了時の処理(メール・在庫更新・ポイント付与・ログ)が400行を超える1メソッドに詰め込まれ、直すたびに他が壊れていたことがあります。OrderCompleted イベントに4つのリスナーを紐づけて直したところ、各リスナーを独立してテストでき、後処理の追加もリスナー1つで済むようになりました。
ただし、イベント化には落とし穴があります。Laravel の公式ドキュメントによれば、キュー対象のリスナーをデータベーストランザクション内でディスパッチすると、トランザクションのコミット前にキューで処理されうるため、リスナーが依存するレコードがまだ存在しないことがあります。対処として、リスナーに ShouldQueueAfterCommit を実装する方法が示されています(Laravel 12.x Events。2026-10-31 確認)。
Rails 側で同じ目的を満たす手段としては、コミット後には after_commit、ロールバック後には after_rollback が実行される、と公式ガイドに書かれています(Active Record Callbacks の after_commit / after_rollback 節。2026-10-31 確認)。同節には、after_commit の中で例外が起きても、保存済みのデータは巻き戻せないという趣旨の記述もあります。通知の失敗でデータを戻したいのか、戻したくないのかを、先に決めておく必要があります。
サービスとイベントの使い分け(私の基準)
以下は公式の規定ではなく、私の案件での判断基準です。
| 観点 | Service Object | イベント+リスナー |
|---|---|---|
| 失敗したら注文自体を失敗にしたい | ○ 呼び出し側で結果を見る | △ キュー化してコミット後に実行し、失敗を注文処理へ伝播させない設計なら、注文は成立する(同期リスナーは例外が呼び出し元へ伝播し、同一トランザクション内ならロールバック要因にもなる) |
| 実行順序に意味がある(在庫→決済) | ○ 順序がコードに残る | × 順序保証を別途考える必要がある |
| 後処理が今後増えそう(通知・連携) | △ 呼び出し箇所が増える | ○ リスナー追加で足せる |
| 処理の流れを1ファイルで追いたい | ○ | △ 発火元と受け手が離れる |
在庫と決済は順序と成否が注文に直結するので Service Object、通知とポイント付与は後処理なのでイベント、と分けました。
AIへの指示の粒度とレビューポイント
| 指示でやること | 理由 |
|---|---|
| 1回の依頼で触る責務は1つ | 境界の誤りを、差分が小さいうちに見つけられる |
| 「変えないもの」を明示する(トランザクション、順序、公開API) | 暗黙に動かされやすいのがここ |
| 終了条件にテスト実行結果の貼り付けを入れる | 通ったと言うだけの報告を防ぐ |
| 判断が要る箇所は「切らずに質問」と指示する | 勝手に境界を決めさせない |
レビューで私が見ている点は4つです。
- 例外を握りつぶして、巻き戻しが効かなくなっていないか
- トランザクションの開始・終了位置が分割前と同じか
- 巻き戻せない副作用(メール、外部API)が、確定前に走っていないか
- サービスの引数に、リクエスト(
params)やセッションがそのまま渡っていないか
別の案件でコントローラに ActiveRecord の where チェーンが複雑に書かれ、同じ条件があちこちにコピペされていたときも、名前付きスコープとクエリオブジェクトに分けることで重複が減りました。サービス分離とは別の話ですが、「条件に名前を付ける」ことが、AIに指示を出すときの共通語彙になる点は同じでした。
ハマったところ
- 一括依頼で、決済失敗時に在庫だけ減る分割案が出た(冒頭の件)。テストが先にあったので、マージ前に気づけた
- 「ついでにリネームもして」を許したら、差分が大きくなりレビューが追いつかなくなった。以降は1依頼1責務で、リネームは別の依頼にしている
- 通知をイベント化したとき、トランザクション内でのディスパッチを意識しておらず、ステージングでレコード未存在の例外を踏んだ(上のStep 5の注意点)
まとめ
- 先に責務の地図とテストを作り、分割はその後
- 切る順は、巻き戻せる処理(在庫)から、巻き戻せない処理(決済・通知)へ
- 注文の成否に関わるものは Service Object、後処理はイベント。ただしコミット前ディスパッチに注意
- AIには1回1責務、「変えないもの」を明記し、テスト結果の提示を終了条件にする
結局のところ、AIに任せたのは「切る作業」で、「どこで切るか」の判断は人間に残しました。分割そのものは速くなりましたが、時間がかかったのは境界の判断とテストの用意のほうで、そこを省いた一括依頼がいちばん高くつきました。
正直、ここに辿り着くまでに「一括で頼んで境界を誤る」ところで何度かやり直しました。同じところでハマっている人が遠回りせずに済めば、この記事を書いた意味があります。似た経験や「うちはこう回避した」があれば、ぜひコメントで共有してください。