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


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方針に合わせているか
この記事とあわせて読みたい
まとめ
@Transactionalは、何となくDB更新に付けるものではありません。業務処理のまとまりを考え、基本はServiceの更新メソッドに付けます。途中で失敗したときにどこまで戻したいかを説明できるようにしておきましょう。



