シナリオ
userRepository.save(user) を呼び出すと、ログに以下が出力されます:
org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [users.UK_email]
nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement
スタックトレースはHibernateの内部処理の下に本当の原因を埋めてしまいます。ノイズを無視して注目すべき部分は constraint [users.UK_email] です。これは users テーブルの email カラムに設定されたユニークインデックスです。既に存在するメールアドレスで行を挿入または更新しようとしたということです。
同じエラーはあらゆる制約タイプで発生します:ユニーク、NOT NULL、外部キー、またはチェック制約。何が違反したとしても、括弧内の制約名が正確にどれかを教えてくれます。
エラーを正確に読む
最初のステップは修正することではなく、制約名を読むことです。パターンは常に constraint [table.constraint_name]、または単に constraint [constraint_name] の形式です。
よくある制約名とその意味:
UK_emailまたはusers_email_key— emailのユニーク制約FK_orders_user_id— 外部キー違反(参照先の行が存在しない)users_username_not_null— NOT NULL制約;必須フィールドにnullを送信したCHK_age_positive— チェック制約;値が条件を満たしていない
制約名がわかったら、エンティティまたはDDL内の対象カラムを見つけます。そして実際に永続化しようとしていた値を確認します。
簡単な修正:保存前にチェックする
ユニーク制約違反(圧倒的に最も一般的なケース)の場合、save() を呼び出す前に存在チェックを追加します:
// リポジトリ内
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
Optional<User> findByEmail(String email);
}
// サービス内
public User createUser(CreateUserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("Email already registered: " + request.getEmail());
}
return userRepository.save(new User(request));
}
データベースへの往復による例外も500エラーも発生しません。ユーザーはわかりやすいエラーメッセージを受け取れます。
注意点が一つあります:同じミリ秒内に到着した2つのリクエストが両方チェックを通過し、挿入の競合が発生することがあります。高並行パスでは、フォールバックとして例外をキャッチする処理も追加してください(後述)。
例外を正しくキャッチして処理する
save呼び出しをラップして DataIntegrityViolationException をキャッチします。生のHibernate例外は使わないでください — SpringはコードをORMレイヤーから切り離すために意図的にラップしています。
import org.springframework.dao.DataIntegrityViolationException;
public User createUser(CreateUserRequest request) {
try {
return userRepository.save(new User(request));
} catch (DataIntegrityViolationException ex) {
String message = ex.getMostSpecificCause().getMessage();
if (message != null && message.contains("UK_email")) {
throw new DuplicateEmailException("Email already registered: " + request.getEmail());
}
// 別の制約 — そのままバブルアップさせる
throw ex;
}
}
制約名を含む基底のSQLエラーを取得するために、常に getMostSpecificCause().getMessage() を使用してください。トップレベルの例外メッセージはHibernate 5と6で異なります — そちらはパースしないでください。
根本的な修正:アプリケーション層でバリデーションする
データベース制約は最初の防衛線ではなく、最後の防衛線として扱いましょう。Bean Validationアノテーションを使えば、SQLが実行される前にSpringが不正な入力を検知できます:
// エンティティ
@Entity
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(name = "UK_email", columnNames = "email")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
@NotBlank(message = "Email is required")
@Email(message = "Email format invalid")
private String email;
@Column(nullable = false)
@NotBlank(message = "Username is required")
private String username;
}
// DTO / リクエストオブジェクト
public class CreateUserRequest {
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 3, max = 50)
private String username;
}
// コントローラー — @Valid はサービスに到達する前にBean Validationをトリガーする
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.status(201).body(userService.createUser(request));
}
リクエストボディの @Valid は、サービスレイヤーが呼び出される前に空白または不正な形式のメールアドレスをキャッチします。データベースへのアクセスも DataIntegrityViolationException も発生しません。
グローバル例外ハンドラー(クリーンな方法)
すべてのサービスメソッドでキャッチするのはすぐに繰り返しが多くなります。@ControllerAdvice を使えば制約違反を一箇所で処理できます:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(
DataIntegrityViolationException ex) {
String cause = ex.getMostSpecificCause().getMessage();
String userMessage = "データベース制約が違反されました。";
if (cause != null && cause.contains("UK_email")) {
userMessage = "このメールアドレスはすでに登録されています。";
} else if (cause != null && cause.contains("FK_orders_user_id")) {
userMessage = "参照されたユーザーが存在しません。";
}
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(409, userMessage));
}
}
500ではなくHTTP 409 Conflictを返してください。500はサーバーが壊れたことを意味します。409はクライアントが既存の状態と競合するデータを送信したことを意味します — まったく異なる問題であり、異なるレスポンスが必要です。
外部キー違反
FK_orders_user_id のような制約名は、存在しない親を参照する行を挿入しようとしていることを意味します。まず親の存在を確認してください:
public Order createOrder(CreateOrderRequest request) {
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new EntityNotFoundException(
"User not found: " + request.getUserId()));
Order order = new Order(user, request.getItems());
return orderRepository.save(order);
}
修正が機能することを確認する
- 重複したメールアドレスで登録する — 500ではなく、わかりやすいメッセージとともに409が返ってくるはずです。
- ログを確認する —
DataIntegrityViolationExceptionが未処理の例外として表示されなくなっているはずです。 spring.jpa.show-sql=trueを有効にして、送信されるSQLが期待通りであることを確認します。- 制約の動作をエンドツーエンドで検証するために、モックではなく実際のデータベース(H2インメモリまたはTestcontainers)を使ったインテグレーションテストを書いてください:
@SpringBootTest
@Transactional
class UserServiceTest {
@Autowired UserService userService;
@Test
void createUser_duplicateEmail_throwsDuplicateEmailException() {
userService.createUser(new CreateUserRequest("alice@example.com", "alice"));
assertThrows(DuplicateEmailException.class, () ->
userService.createUser(new CreateUserRequest("alice@example.com", "alice2"))
);
}
}
Tips
データを一括移行していてこのエラーが繰り返し発生する場合は、ユニーク制約を適用する前に重複をチェックしてください。次のクエリ一つで確認できます:
SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1;
まず重複をクリーンアップしてください。FlywayやLiquibaseが汚れたデータに対してマイグレーションを実行すると、まったく同じ例外でスタートアップに失敗します — そしてログはその原因を明確に示してくれません。
エラーログに生のメールアドレスを表示させたくない場合、ハッシュ化する手間は価値があります。ToolCraftのHash GeneratorはSHA-256ハッシュをクライアントサイドで生成します — ブラウザ外にデータは送信されません — これにより、ログアグリゲーターにPIIを漏らすことなくデバッグ中の素早いサニティチェックに便利です。

