先に結論
画面とAPIの両方に同じ重複チェックを書いているなら、メール重複などの業務判断をServiceへ集約します。必須・文字数・形式は各入口のForm/Requestで検証し、Controllerは結果をHTMLやJSONへ返します。同じ業務エラーでも、画面の項目エラーとAPIの409応答は分けて扱えます。


登録画面とAPIを作ったら、メール重複のif文が両方にできた。Serviceへ移すと、今度は例外が画面へ表示されず500になる。この記事は、そのような入力チェックの重複とエラー応答の分け方を整理します。
既存のForm・Serviceの役割分担を使い、同じメール重複エラーをHTMLでは入力欄の下へ、JSON APIでは409へ返す例を確認します。
まず、重複しているのはどのチェックかを見分ける
| 困っているコード・症状 | 判断する場所 | 修正の方向 |
|---|---|---|
| Controllerのif文が必須・文字数・形式で長くなる | Form/Request DTOのBean Validation | 入力の形を宣言し、Controllerは検証結果を見る |
| 画面用とAPI用でメール重複のif文が二つある | 共通のService | 登録処理と一緒に業務判断を集約する |
| Serviceの例外が画面やAPIで500になる | Controller/例外ハンドラ | 業務エラーを画面の項目エラー・APIの応答へ変換する |
| Webでは防げるがバッチから不正な値が入る | バッチなど各入口とServiceの契約 | Webの@Validが他の入口にも効くと思わず、前提をそろえる |
FormとJSON Requestに同じ必須制約が付くことはあります。それは別の入口の入力契約です。一方、DBを参照して決まるメール重複や登録可能な状態の判断は、入口ごとにコピーすると修正漏れが起きやすくなります。認証やURLへのアクセス制御と、Serviceが行う業務上の権限・状態判断も区別します。
確認環境と例の範囲
確認環境はJava 17.0.8、Spring Boot 3.5.16、Spring Framework 6.2.19、Thymeleaf 3.1.5.RELEASE、Hibernate Validator 8.0.1.Finalです。Spring MVCの例で、WebFluxは対象外です。@Validはjakarta.validation.Validを使います。コードのpackage宣言は省略しているため、Javaクラスは同じパッケージへ置いてください。
登録処理の流れは、入力→形式チェック→Commandへ変換→Serviceの重複判定→応答です。ここではRepositoryをインターフェースにして、テストではその応答を固定します。実DBの永続化・同時登録は検証範囲に含めません。
形式チェックはFormに置き、Serviceへ渡す値を作る
画面の入力値を受けるFormです。toCommand()を含めて示します。ServiceはこのFormやBindingResultを受け取りません。
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class UserRegisterForm {
@NotBlank(message = "名前を入力してください")
@Size(max = 50, message = "名前は50文字以内で入力してください")
private String name;
@NotBlank(message = "メールアドレスを入力してください")
@Email(message = "メールアドレスの形式を確認してください")
private String email;
private String departmentCode;
public UserRegisterCommand toCommand() {
return new UserRegisterCommand(name, email, departmentCode);
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getDepartmentCode() { return departmentCode; }
public void setDepartmentCode(String departmentCode) { this.departmentCode = departmentCode; }
}
Serviceへ渡す値は、次のCommandにまとめます。recordのアクセサはname()・email()です。getEmail()ではありません。
public record UserRegisterCommand(String name, String email, String departmentCode) {
}
FormとDTOを分けるか迷っている場合は、FormをServiceへ渡してよいか、DTOを分けるタイミングも参照してください。
重複チェックはServiceの登録処理に集約する
Repositoryへの問い合わせを画面用ControllerとAPI用Controllerへコピーせず、同じServiceを呼びます。
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public long register(UserRegisterCommand command) {
if (userRepository.existsByEmail(command.email())) {
throw new DuplicateEmailException();
}
return userRepository.save(command);
}
}
この例でServiceが使うRepositoryの契約と業務例外です。それぞれ別のJavaファイルにします。
public interface UserRepository {
boolean existsByEmail(String email);
long save(UserRegisterCommand command);
}
public class DuplicateEmailException extends RuntimeException {
}
existsByEmail()の事前確認だけでは、同時に届いた二つの登録を防げません。実運用ではDBの一意制約を用い、競合時の例外も同じ業務エラーへ整理します。ここでは通常の重複判定と応答の分担に範囲を絞っています。
HTML画面では項目エラーに変えて、入力値を保ったまま戻す
ControllerはBindingResult.hasErrors()で形式エラーを先に返し、Serviceが返したメール重複だけをrejectValue()で項目へ関連付けます。エラー時にリダイレクトすると、この例のFormとエラー情報は次のリクエストへ引き継がれません。
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
public class UserHtmlController {
private final UserService userService;
public UserHtmlController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users/new")
public String show(@ModelAttribute("form") UserRegisterForm form) {
return "users/new";
}
@PostMapping("/users")
public String register(@Valid @ModelAttribute("form") UserRegisterForm form,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "users/new";
}
try {
userService.register(form.toCommand());
} catch (DuplicateEmailException e) {
bindingResult.rejectValue("email", "duplicate", "既に使われています");
return "users/new";
}
return "redirect:/users/new?registered";
}
}
BindingResultは対象のFormの直後です。@ModelAttribute("form")とテンプレートのth:object="${form}"も合わせます。この扱いはSpring公式の@ModelAttributeの説明で確認できます。
src/main/resources/templates/users/new.htmlの最小例です。th:errorsで、形式エラーとServiceから受けた重複エラーを同じ入力欄の近くへ表示します。
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>登録</title></head>
<body>
<form th:action="@{/users}" th:object="${form}" method="post">
<label>名前<input th:field="*{name}"></label>
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>
<label>メール<input th:field="*{email}"></label>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></p>
<label>部署コード<input th:field="*{departmentCode}"></label>
<button type="submit">登録</button>
</form>
</body>
</html>
JSON APIでは同じServiceを呼び、重複を409へ変換する
APIはJSONを受け取るRequestを持ちます。Serviceへ渡すCommandはHTML画面と共通です。この例では成功を201、形式エラーをSpring MVCの標準処理で400にします。
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserApiController {
private final UserService userService;
public UserApiController(UserService userService) {
this.userService = userService;
}
@PostMapping("/api/users")
@ResponseStatus(HttpStatus.CREATED)
public RegisteredUser register(@Valid @RequestBody UserRegisterRequest request) {
long id = userService.register(request.toCommand());
return new RegisteredUser(id);
}
public record UserRegisterRequest(
@NotBlank @Size(max = 50) String name,
@NotBlank @Email String email,
String departmentCode) {
public UserRegisterCommand toCommand() {
return new UserRegisterCommand(name, email, departmentCode);
}
}
public record RegisteredUser(long id) {
}
}
メール重複は、このAPIの設計では409 Conflictへ変換します。業務エラーが必ず409になるという意味ではなく、扱うエラーとAPIの契約に応じて決めます。
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = UserApiController.class)
public class UserApiExceptionHandler {
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiError> duplicate(DuplicateEmailException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError("DUPLICATE_EMAIL", "既に使われています"));
}
public record ApiError(String code, String message) {
}
}
assignableTypesでAPI用Controllerに範囲を限定しています。画面用Controllerまで一律にJSONを返すハンドラを適用しないためです。Spring公式の例外ハンドラの説明も参照してください。
HTTP/1.1 409 Conflict
Content-Type: application/json
{"code":"DUPLICATE_EMAIL","message":"既に使われています"}
確認するのはステータスだけでなく、表示と保存の有無
| 入力・条件 | HTML画面の期待結果 | JSON APIの期待結果 | 保存 |
|---|---|---|---|
| 名前・メールが空、または形式が不正 | 200で入力画面へ戻り、該当欄へエラー表示 | 400 | しない |
| 形式は正しいがメールが重複 | 200でメール欄へ「既に使われています」 | 409とDUPLICATE_EMAIL | しない |
| 形式が正しく、重複なし | 302で登録画面へリダイレクト | 201と登録結果のJSON | Service経由で行う |
MockMvcで実際のThymeleaf出力まで検証した例です。userRepositoryは@MockitoBeanで差し替え、ControllerとServiceは実物を使います。次のコードはそのテストクラス内のメソッドです。
@Test
void htmlDuplicateDisplaysErrorAndPreservesInputWithoutSaving() throws Exception {
when(userRepository.existsByEmail("used@example.com")).thenReturn(true);
mockMvc.perform(post("/users").param("name", "山田").param("email", "used@example.com"))
.andExpect(status().isOk())
.andExpect(view().name("users/new"))
.andExpect(model().attributeHasFieldErrors("form", "email"))
.andExpect(content().string(containsString("既に使われています")))
.andExpect(content().string(containsString("value=\"used@example.com\"")));
verify(userRepository, never()).save(any());
}
@Test
void apiDuplicateReturns409WithJsonAndDoesNotSave() throws Exception {
when(userRepository.existsByEmail("used@example.com")).thenReturn(true);
mockMvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"山田\",\"email\":\"used@example.com\"}"))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("DUPLICATE_EMAIL"))
.andExpect(jsonPath("$.message").value("既に使われています"));
verify(userRepository, never()).save(any());
}
実行では、上記に加えてHTMLの空入力、APIの形式不正、HTML/APIの正常登録、FormからCommandへの変換も確認しました。計7件が成功しています。実DBの一意制約や認証・認可の検証を、この7件で代替できるわけではありません。
レビュー前に確認すること
- 画面とAPIでDBを参照する業務チェックをコピーしていないか
- Serviceが画面名・HTTPステータス・BindingResultを知っていないか
- 形式エラーのとき、Serviceや保存処理へ進んでいないか
- 同じ業務エラーをHTMLとJSONのそれぞれの契約へ返せているか
- Web以外の入口もあるなら、Serviceへ渡す値の前提をそろえているか
レビューで説明するなら、「メール重複の判断を共通Serviceへ置き、画面は項目エラー、APIは409へ変換しました。エラー時に保存されないことと、実際の表示を確認しました」と、判断と確認結果をセットにします。
業務エラーを画面へ返した後に入力値や部署の選択肢が消える場合は、同じエラーPOSTで入力値・selected・選択肢一覧を検証する例を確認してください。テストが再表示へ届く前に403で落ちる場合は、CSRF・認証・権限を含めたMockMvcの切り分けを使います。
この記事とあわせて読みたい
まとめ
チェックが重複したら、まず入力の形・業務判断・応答への変換を分けます。Form/Requestで入力を検証し、共通Serviceで業務上の可否を判断し、HTMLとJSONの返し方は各入口で決めます。
「Serviceへ移した」で終えず、入力値が残るか、メッセージが表示されるか、APIの契約どおり返るか、保存されないかまで確認すると、変更理由を説明できます。
入力形式から画面へのエラー表示、業務判断、テストまでをつなげて確認する場合は、Spring Bootのフォーム入力・バリデーション実践ガイドを参照してください。
Javaの基本文法とクラスを学び、入力画面・登録処理・テストをつないでWeb機能を作りたい人は、「Web機能をひとりで作る」本棚(Amazon・広告)も参考にしてください。各本の対象者と前提知識を確認し、今の学習課題に合う1冊を選べます。





