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?

@Transactionalはどこに置くか ― プロキシ越しの注文受付を一つの処理として守る

0
Last updated at Posted at 2026-09-01

@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で詳細②へ送っています。

注文受付の詳細②(続き):変更検出・flush・コミット

注目してほしいのは、在庫の更新に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確認で固定します。

公式リファレンス

参考文献・参照資料

  • Mark Heckler, Spring Boot:立ち上げて実行する、ISBN 9798341626911(AI翻訳版):第4章・第6章のデータアクセスとデータ処理を補助参照。トランザクションの現在の挙動はSpring公式資料と実測を優先。
  • 株式会社NTTデータ『Spring徹底入門 第2版』:第2章のAOPの位置付けを参照。宣言的トランザクションの詳細はSpring公式資料を参照。

実務PlantUMLからは、同じ流れを中間版と詳細版の2つの抽象度で描き分けるという手法だけを参照しました。会社名、業務名、社内識別子、クラス名、テーブル名、元の処理順は掲載せず、在庫引き当てを含む注文受付として新規に描き起こしています。

次回は、starterを追加した結果を自動設定の条件とActuatorから観測します。

シリーズ内リンク

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?