設計・テスト・レビュー

Spring Bootの@Transactionalはどこに付ける?

AIによる要約

Spring Bootの@Transactionalは、基本的にRepositoryではなく、業務処理のまとまりを表すServiceメソッドに付けることが多いです。どこからどこまでを1つの更新単位にするかを決めるためのアノテーションであり、何となく付けるものではありません。

新人SE
新人SE
@Transactionalって、Repositoryに付けておけばDB更新は安全になりますか?
ポンコツSE
ポンコツSE
Repository単体ではなく、業務処理のまとまりで考えます。複数の更新を1つの成功・失敗として扱いたいなら、Serviceに付けるのが基本です。

Spring BootでDB更新を扱うと、@Transactionalというアノテーションが出てきます。

初学者は、とりあえずRepositoryや更新メソッドに付ければよいと考えがちですが、現場では「どこからどこまでを1つのトランザクションにするか」がレビューで見られます。

この記事のポイント

  • @Transactionalは更新処理の境界を決める
  • 基本は業務処理のまとまりであるServiceに付ける
  • ControllerやRepositoryに何となく付けない
  • 例外時にどこまでロールバックしたいかを考える

実行用サンプル

先に handle=taken を登録し、新しいユーザーで同じハンドルを使ってプロフィール保存を失敗させます。Service のトランザクション有無だけを変えて、例外後のDB件数を比較します。 JDK 17とMaven 3.9系で mvn test を実行できます。ZIPにはソース全文・テスト・pom.xml・READMEを収録しています。

@Transactionalは何をするものか

@Transactionalは、DB更新を1つのまとまりとして扱うためのアノテーションです。処理の途中で例外が起きた場合、途中までの更新をなかったことにする、という考え方につながります。

@Transactional
public void register(UserRegisterRequest request) {
    User user = userRepository.save(new User(request.getName()));
    profileRepository.save(new Profile(user.getId(), request.getBio()));
}

この例では、ユーザー登録とプロフィール登録を1つのまとまりとして扱いたいです。プロフィール登録で失敗した場合、ユーザーだけ登録されると中途半端な状態になります。

基本はServiceに付ける

最初はユーザー1件・プロフィール1件です。外側のトランザクションがなければ、プロフィール登録だけが失敗して2件・1件になります。Serviceの register() をSpringの管理するBeanから呼び出した場合は、ユーザー登録もロールバックされて1件・1件です。テスト自身には @Transactional を付けず、サービス呼び出しが終了した後に別のRepository呼び出しで数えます。

RegistrationServiceTest.java の全文

package info.syachiku.audit;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataIntegrityViolationException;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest
class RegistrationServiceTest {
    @Autowired
    private RegistrationService service;
    @Autowired
    private RegisteredUserRepository users;
    @Autowired
    private UserProfileRepository profiles;

    @BeforeEach
    void occupiedHandle() {
        profiles.deleteAll();
        users.deleteAll();
        service.register("既存ユーザー", "taken");
    }

    @Test
    void 個々の保存でコミットされプロフィールだけ失敗する() {
        assertThrows(DataIntegrityViolationException.class,
                () -> service.registerWithoutTransaction("新規ユーザー", "taken"));

        assertEquals(2, users.count());
        assertEquals(1, profiles.count());
    }

    @Test
    void 一つのサービス境界ならユーザー保存もロールバックされる() {
        assertThrows(DataIntegrityViolationException.class,
                () -> service.register("新規ユーザー", "taken"));

        assertEquals(1, users.count());
        assertEquals(1, profiles.count());
    }
}

現場では、@TransactionalをServiceの更新メソッドに付けることが多いです。理由は、Serviceが業務処理のまとまりを表す場所だからです。

RegistrationService.java の全文

package info.syachiku.audit;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class RegistrationService {
    private final RegisteredUserRepository users;
    private final UserProfileRepository profiles;

    RegistrationService(RegisteredUserRepository users, UserProfileRepository profiles) {
        this.users = users;
        this.profiles = profiles;
    }

    public void registerWithoutTransaction(String name, String handle) {
        saveUserAndProfile(name, handle);
    }

    @Transactional
    public void register(String name, String handle) {
        saveUserAndProfile(name, handle);
    }

    private void saveUserAndProfile(String name, String handle) {
        RegisteredUser user = users.save(new RegisteredUser(name));
        profiles.save(new UserProfile(user.getId(), handle));
    }
}

このあとに示すController・Repository・自己呼び出しの断片は、境界を考えるための別の例です。配布例で実際に検証するのは、上の RegistrationService です。

Repositoryに付ければよいわけではない

RepositoryはDBアクセスの部品です。1回の保存や検索だけを見ればRepositoryでもよさそうに見えますが、業務処理はRepository呼び出し1回で終わらないことがあります。

public void register(UserRegisterRequest request) {
    userRepository.save(user);
    profileRepository.save(profile);
    pointRepository.save(firstLoginPoint);
}

この3つの保存を1つの成功・失敗として扱いたいなら、個々のRepositoryではなく、この処理全体を囲むServiceに@Transactionalを付ける方が自然です。

Controllerに付けるのは避ける

Controllerに@Transactionalを付けると、HTTPリクエスト全体をトランザクションとして扱う形になります。画面/APIの入口とDB更新の境界が混ざり、責務が分かりにくくなります。

@PostMapping("/users")
@Transactional
public ResponseEntity<Void> register(@RequestBody UserRegisterRequest request) {
    userService.register(request);
    return ResponseEntity.ok().build();
}

この書き方は、Controllerがトランザクション境界まで持ってしまっています。通常はService側へ移した方がレビューで通りやすいです。

readOnlyを使う場面

検索だけの処理では、@Transactional(readOnly = true)を使う現場もあります。読み取り専用であることを明示し、更新処理との区別を付けるためです。

@Transactional(readOnly = true)
public UserDetailResponse findDetail(Long userId) {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));
    return UserDetailResponse.from(user);
}

ただし、すべての検索に必ず付けるかどうかはプロジェクト方針によります。既存コードの書き方に合わせましょう。

ロールバックされる例外を意識する

トランザクションでは、例外が起きたときにロールバックするかどうかも重要です。Springでは、基本的に実行時例外が起きるとロールバックされる、と理解しておくとよいです。

@Transactional
public void cancel(Long orderId) {
    Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

    order.cancel();

    if (order.isInvalidState()) {
        throw new CannotCancelOrderException(orderId);
    }
}

新人のうちは、例外を握りつぶすコードに注意します。例外をcatchして何もしないと、失敗したのに正常終了した扱いになり、意図したロールバックにならないことがあります。

@Transactional
public void importCsv(File file) {
    try {
        importService.importFile(file);
    } catch (Exception e) {
        log.error("CSV取込に失敗しました", e);
    }
}

この例では、例外をcatchして外へ出していません。仕様として続行するなら別ですが、失敗時にロールバックしたい処理なら危険です。

同じクラス内の呼び出しに注意

少し現場寄りの注意点として、同じクラス内で@Transactional付きメソッドを呼ぶと、期待通り効かないことがあります。Springの仕組み上、プロキシ経由で呼ばれることが前提になるためです。

public void registerAll(List<UserRequest> requests) {
    for (UserRequest request : requests) {
        registerOne(request);
    }
}

@Transactional
public void registerOne(UserRequest request) {
    userRepository.save(new User(request.getName()));
}

新人のうちは深追いしすぎなくてよいですが、@Transactionalを付けたのに効かないときの典型パターンとして覚えておくと役立ちます。

現場レビューではどう見られるか

// レビューコメント例
// ユーザー登録とプロフィール登録が別々の更新になっています。
// 片方だけ成功するとデータ不整合になるため、
// Serviceのregisterメソッドに@Transactionalを付けて1つの更新単位にしてください。

レビューでは、アノテーションが付いているかだけでなく、更新単位が業務的に正しいかを見られます。

レビュー前のセルフチェック

@Transactionalの確認リスト

  • どこからどこまでを1つの更新単位にしたいか説明できるか
  • ControllerではなくServiceに付けているか
  • 複数Repository更新が途中で失敗したときの状態を考えたか
  • 例外を握りつぶしてロールバックを妨げていないか
  • 既存プロジェクトの@Transactional方針に合わせているか

この記事とあわせて読みたい

関連記事Spring BootのServiceに業務ロジックを書く理由 Spring Bootの Service は、業務処理やユースケースを表す中心的な場所です。Controllerに業務ロジックを書くと入口ごとに処理が散らばります。 記事を読む 関連記事Spring BootのRepositoryでよくある戻り値の扱い Spring BootのRepositoryでは、 Optional 、 List 、単一Entityなど戻り値の扱いでレビューされることがあります。大事なのは、0件、1件、複数件を想定し、 get() で雑に取り出さないことです。 記事を読む 関連記事Javaのスタックトレースの読み方|Caused byの後、どのコードを開けばいい? Caused byと自作コードの行を手掛かりに、例外の原因・渡された値・次に調べる場所を絞る手順を確認できます。 記事を読む

まとめ

@Transactionalは、何となくDB更新に付けるものではありません。業務処理のまとまりを考え、基本はServiceの更新メソッドに付けます。途中で失敗したときにどこまで戻したいかを説明できるようにしておきましょう。

-設計・テスト・レビュー
-,