0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

300行の注文コントローラをAIと「1責務ずつ」Service Objectに切り出した手順(在庫→決済→通知)

0
Last updated at Posted at 2026-11-01

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つです。

  1. 例外を握りつぶして、巻き戻しが効かなくなっていないか
  2. トランザクションの開始・終了位置が分割前と同じか
  3. 巻き戻せない副作用(メール、外部API)が、確定前に走っていないか
  4. サービスの引数に、リクエスト(params)やセッションがそのまま渡っていないか

別の案件でコントローラに ActiveRecord の where チェーンが複雑に書かれ、同じ条件があちこちにコピペされていたときも、名前付きスコープとクエリオブジェクトに分けることで重複が減りました。サービス分離とは別の話ですが、「条件に名前を付ける」ことが、AIに指示を出すときの共通語彙になる点は同じでした。

ハマったところ

  • 一括依頼で、決済失敗時に在庫だけ減る分割案が出た(冒頭の件)。テストが先にあったので、マージ前に気づけた
  • 「ついでにリネームもして」を許したら、差分が大きくなりレビューが追いつかなくなった。以降は1依頼1責務で、リネームは別の依頼にしている
  • 通知をイベント化したとき、トランザクション内でのディスパッチを意識しておらず、ステージングでレコード未存在の例外を踏んだ(上のStep 5の注意点)

まとめ

  • 先に責務の地図とテストを作り、分割はその後
  • 切る順は、巻き戻せる処理(在庫)から、巻き戻せない処理(決済・通知)へ
  • 注文の成否に関わるものは Service Object、後処理はイベント。ただしコミット前ディスパッチに注意
  • AIには1回1責務、「変えないもの」を明記し、テスト結果の提示を終了条件にする

結局のところ、AIに任せたのは「切る作業」で、「どこで切るか」の判断は人間に残しました。分割そのものは速くなりましたが、時間がかかったのは境界の判断とテストの用意のほうで、そこを省いた一括依頼がいちばん高くつきました。


正直、ここに辿り着くまでに「一括で頼んで境界を誤る」ところで何度かやり直しました。同じところでハマっている人が遠回りせずに済めば、この記事を書いた意味があります。似た経験や「うちはこう回避した」があれば、ぜひコメントで共有してください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?