@Transactionalはどこに置くか ― プロキシ越しの注文受付を一つの処理として守る
注文は保存されたのに、同じ処理で登録するはずの通知予約だけ失敗した。この二つを「一つの仕事」と考えるなら、途中までDBへ残る状態を避ける必要があります。
@Transactionalは、メソッドを何となく安全にする印ではありません。どの処理をまとめてコミットし、失敗時にどこまで戻すかを示す境界です。
境界はServiceのユースケースへ置く
@Service
public class OrderApplicationService {
private final StockRepository stockRepository;
private final OrderRepository orderRepository;
private final NotificationPlanRepository notificationPlanRepository;
public OrderApplicationService(
StockRepository stockRepository,
OrderRepository orderRepository,
NotificationPlanRepository notificationPlanRepository) {
this.stockRepository = stockRepository;
this.orderRepository = orderRepository;
this.notificationPlanRepository = notificationPlanRepository;
}
@Transactional
public Order register(CreateOrderCommand command) {
Stock stock = stockRepository
.findByProductCodeForUpdate(command.productCode())
.orElseThrow(() ->
new ProductNotFoundException(command.productCode()));
stock.decrease(command.quantity());
Order order = orderRepository.save(
new Order(command.productCode(), command.quantity()));
notificationPlanRepository.save(
NotificationPlan.forOrder(order.getId()));
return order;
}
}
在庫の引き当て、注文の保存、通知予約の登録を、一つのDBトランザクションとして扱う例です。3つのうち1つでも失敗したら、どれも残らない状態にしたい、という意思表示が@Transactionalです。ControllerではなくServiceへ境界を置くことで、HTTP以外の入口から同じユースケースを呼ぶ場合も一貫します。
在庫をfindByProductCodeForUpdate()で取っているのは、同じ商品への同時注文で在庫を二重に引き当てないためです。ロックの詳細はこの回の後半で扱います。
Controllerから見えるのはService型
この図で読むのは、誰が何を担当しているかです。Controllerは業務を判断せず、Serviceは注文受付の手順だけを持ち、トランザクションの開始と終了はProxyの仕事、という分担を確認してください。SQLがいつ飛ぶかは、この図では追いません。
ControllerのコードがSpring AOP Proxyというクラスを名指しで呼ぶわけではありません。Controllerが受け取る型はOrderApplicationServiceですが、実体はSpringが用意したProxyです。Proxyが呼び出しの前後へトランザクション開始・コミット・ロールバックを差し込みます。
自己呼び出しではProxyを通らない
次の書き方は注意が必要です。境界の話に集中するため、在庫の引き当ては省いた形で示します。
public Order register(CreateOrderCommand command) {
Order order = orderRepository.save(toOrder(command));
reserveNotification(order.getId());
return order;
}
@Transactional
public void reserveNotification(Long orderId) {
notificationPlanRepository.save(NotificationPlan.forOrder(orderId));
}
同じインスタンス内のregister()からreserveNotification()を直接呼ぶと、外側のProxyを通りません。reserveNotification()に付けた@Transactionalが、新しい境界として期待どおり適用されない構成になります。
対策は「自分自身を注入する」という小技から考えず、ユースケースの入口であるregister()へ境界を置くか、本当に独立した責務なら別Beanへ分けます。
例外とロールバックを暗黙にしすぎない
Springの宣言的トランザクションには、既定のロールバック規則があります。すべての例外が同じ扱いではありません。標準の既定設定では、RuntimeExceptionまたはErrorならロールバック、checked exceptionならコミットです。 業務例外をchecked exceptionで表す場合は、期待するロールバックと一致するかを公式リファレンスとテストで確認します。
@Transactional(rollbackFor = OrderRegistrationException.class)
public Order register(CreateOrderCommand command)
throws OrderRegistrationException {
// ...
}
rollbackForを常に付ける、という話でもありません。例外設計とトランザクション境界を一緒に決めます。
抽象度を一段上げて、中で何が起きているかを見る
ここまでの図は「誰の仕事か」に絞りました。実務で詰まるのは、その一段下です。SQLはいつ発行されるのか。何も更新せずにreturnしたとき、トランザクションはどうなるのか。この2つを、同じ流れの詳細版で追います。
読みどころは3つです。ProxyがJPAトランザクションを開始すること。悲観的書き込みロックをどの時点で要求し、いつ解放されるか。そして、例外で処理を終えても境界はrollbackで閉じること。 物理Connectionをいつ取得するか、どのSQLでロックを表現するかは、JPAプロバイダーとDBによって変わります。
例外による早期終了を「何も起きていない」と読まないでください。悲観的書き込みロックの取得後なら、そのロックはrollbackまで保持されます。更新せずに抜ける経路にも、トランザクションを開始してEntityを取得した費用がかかっています。 SQLの表現はDBごとに異なりますが、今回のH2では在庫取得にfor updateが付くことを確認しました。
続いて、詳細①の「在庫が足りる」分岐の続きとして、更新が確定するまでを見ます。詳細①の図では、この分岐はrefで詳細②へ送っています。
注目してほしいのは、在庫の更新にsave()を呼んでいないことです。同じトランザクション内で取得したEntityはJPAの管理下にあり、decrease()で状態を変えると、flush時にスナップショットと比較されてUPDATEが生成されます(変更検出)。コードを書いた場所とSQLが飛ぶ場所が違う、というのがJPAのいちばん大きな段差です。
そして、在庫のUPDATE、注文のINSERT、通知予約のINSERTは、同じトランザクション内で発行されてからコミットされます。flush時の順番は採番方式やJPAプロバイダーに依存しますが、「一つの仕事」として守るとは、この3つが同時に確定するということです。
Spring Boot 3.5.16、Hibernate ORM 6.6.53.Final、H2、GenerationType.IDENTITYの検証では、在庫のselect ... for update、注文INSERT、通知予約INSERT、在庫UPDATEの順で観測しました。IDENTITYではID取得のためsave()時点でINSERTが必要になるためです。SEQUENCE、バッチ設定、別のJPA実装やDBでは順序が変わるので、図はSQL順を保証せず、トランザクションの責務と確定点を表しています。
第5回で扱ったEntityの状態と、第8回で扱うテストのflush()は、この図の同じ場所の話です。テストでは境界がテストメソッド全体へ広がるため、ここで見た「flushの瞬間」がずれて、制約違反が起きないまま検証が通ってしまいます。
DBトランザクションと外部送信は別問題
メールや外部API呼び出しは、DBのロールバックだけでは取り消せません。このサンプルでは、メールを直接送らずNotificationPlanをDBへ保存し、別処理が送信する形にしています。DB更新と外部副作用を同じものとして扱わないためです。
検証プロジェクトでは、注文保存後に例外を発生させ、注文INSERTと在庫の状態変更がどちらもロールバックされることを確認しました。自己呼び出しがProxyを通らない点はSpring Framework公式のproxying semanticsに基づきます。実際のコードでは、Proxyの有無を意識した小技より、ユースケース入口へ境界を置く設計を優先します。
実務ではこうなる
トランザクションを長くすると安全になるわけではありません。外部APIの待ち時間や大量の計算を境界内へ入れると、Connectionとロックの保持時間が伸びます。DBで同時に確定すべき処理へ絞り、外部副作用はoutboxや通知予約のような永続化済みメッセージへ分けます。
悲観ロックを使う場合は、対象と取得順をそろえます。複数の商品を注文する処理でリクエスト順にロックすると、逆順の注文とデッドロックしやすくなります。商品コード順などへ正規化し、タイムアウトと再試行方針も決めます。
REQUIRES_NEWも「確実に保存する」印ではありません。外側が失敗しても内側だけコミットされるため、監査ログなど本当に分離したい用途に限ります。どの失敗で何が残るかを、例外テストとDB確認で固定します。
公式リファレンス
- Spring Framework Reference: Transaction Management
- Spring Framework Reference: Declarative Transaction Management
- Spring Framework Reference: Proxying Mechanisms
参考文献・参照資料
- Mark Heckler, Spring Boot:立ち上げて実行する、ISBN 9798341626911(AI翻訳版):第4章・第6章のデータアクセスとデータ処理を補助参照。トランザクションの現在の挙動はSpring公式資料と実測を優先。
- 株式会社NTTデータ『Spring徹底入門 第2版』:第2章のAOPの位置付けを参照。宣言的トランザクションの詳細はSpring公式資料を参照。
実務PlantUMLからは、同じ流れを中間版と詳細版の2つの抽象度で描き分けるという手法だけを参照しました。会社名、業務名、社内識別子、クラス名、テーブル名、元の処理順は掲載せず、在庫引き当てを含む注文受付として新規に描き起こしています。
次回は、starterを追加した結果を自動設定の条件とActuatorから観測します。


