トランザクション境界を service 側へ移すリファクタは外側デコレータの残置で回帰する — パススルースタブの単体テストでは検出できない
データベース
バックエンド
テスト
知識
運用
フレームワーク層のトランザクションデコレータ/インターセプタ(controller に付与し、リクエスト全体を1トランザクションで包む方式)と、service 内のトランザクションヘルパー(コールバックをトランザクションで包んで実行する方式)が共存するコードベースで、トランザクション管理を service 側へ移すリファクタを行うと、外側デコレータの外し忘れによる二重制御が混入しやすい。controller 側の差分が「呼び出すメソッド名の変更」の1行だけになるため、境界の担い手が変わったことがレビューで見えないのが混入経路。
壊れる形(非対称な所有権処理)
アンビエントトランザクション(CLS / AsyncLocalStorage 等でコンテキスト共有)のヘルパーが「開始時は既存トランザクションがあれば再利用(no-op)、終了時は無条件に commit + コネクション解放」という非対称実装だと、内側ヘルパーが外側の張ったトランザクションを commit してコンテキストを空にし、外側デコレータの最終 commit が『connection is not initialized』系のエラーで落ちる。開始時の再利用判定(自分が開始したか)を終了時の commit/rollback スキップまで伝搬させる nesting-safe 実装であれば起きない。
症状の signature(切り分けに使える)
- リクエストは 500 で失敗するのに、DB 更新自体は全て commit 済み。ユーザーには失敗に見えるため再試行され、履歴の重複や二重更新につながる。
- トレース上は COMMIT スパンが処理途中に現れ、その後リクエスト末尾でエラーになる。「更新は反映されているのに 500」はこの構造をまず疑う。
なぜテストで捕まらないか
- service 単体テストはトランザクションヘルパーを「コールバックをそのまま実行するパススルー」でスタブするため、commit の回数や所有権の破壊はテストから不可視。
- 外側デコレータは controller 統合テストでしか発火せず、該当エンドポイントの統合テストが無ければ検出経路がゼロになる。
予防と検証
- トランザクション境界を移す変更では、境界の両端(外側のデコレータ付与箇所と内側のヘルパー呼び出し)を対で棚卸しする。片側だけの diff レビューでは見えない。
- ヘルパーは開始時に「自分が開始したか」を戻り値か内部状態で保持し、再利用時は終了処理をスキップする nesting-safe 実装にしておくと、この組み合わせ自体が無害化される。
- 検証は実トランザクションを張る統合テスト(外側デコレータ経由でエンドポイントを叩き、正常応答と commit 一回性を確認)で行う。パススルースタブの単体テストは代替にならない。