Spring Bootの入力チェックをどこでやるかを解説する記事のアイキャッチ。若手エンジニアがJava道場風の学習空間でControllerとServiceの役割を学ぶイラスト。

設計・テスト・レビュー

Spring Bootで入力チェックが重複する|ControllerとServiceの分け方

先に結論

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

新人SE
新人SE
ControllerにもServiceにも同じチェックがあって、どちらを直せばよいか分かりません。
ポンコツSE
ポンコツSE
まず「入力の形」「業務として許されるか」「どう返すか」を分けます。画面とAPIで共通にしたいのは業務判断です。

登録画面と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を分けるタイミングも参照してください。

関連記事Spring BootのFormとDTOの違い|Serviceへ渡すクラスを分ける判断 先に結論FormをServiceへ直接渡してよいかは、画面の入力契約とServiceの業務処理の契約が同じかで判断します。 記事を読む

重複チェックは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と登録結果のJSONService経由で行う

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の切り分けを使います。

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

関連記事BindingResultの使い方と注意点|@Validの直後に置く理由 BindingResult は、 @Valid で検出した入力エラーをControllerで受け取るための引数です。重要なのは、 @Valid を付けたFormの直後に置くことです。 記事を読む 関連記事Spring Bootの@Validと@Validatedの違い|必須チェックが効かない原因と使い分け Spring Bootの @Valid と @Validated は、通常の入力検証とグループ指定で使い分けます。特に「 @Validated(CreateGroup.class) へ変えたら @NotBlank が効かない」場合は、共通制約の Default グループが検証対象に含まれているかを確認してください。 記事を読む 関連記事Spring BootのServiceに業務ロジックを書く理由 Spring Bootの Service は、業務処理やユースケースを表す中心的な場所です。Controllerに業務ロジックを書くと入口ごとに処理が散らばります。 記事を読む

まとめ

チェックが重複したら、まず入力の形・業務判断・応答への変換を分けます。Form/Requestで入力を検証し、共通Serviceで業務上の可否を判断し、HTMLとJSONの返し方は各入口で決めます。

「Serviceへ移した」で終えず、入力値が残るか、メッセージが表示されるか、APIの契約どおり返るか、保存されないかまで確認すると、変更理由を説明できます。

入力形式から画面へのエラー表示、業務判断、テストまでをつなげて確認する場合は、Spring Bootのフォーム入力・バリデーション実践ガイドを参照してください。

関連記事Spring Bootのフォーム入力・バリデーション実践ガイド|実装からテストまで フォームを作って終わりにしない。入力の約束からエラー時の再表示、Serviceの業務判断、MockMvcでの確認まで、既存記事を読む順と確認する問いをまとめました。 記事を読む

Javaの基本文法とクラスを学び、入力画面・登録処理・テストをつないでWeb機能を作りたい人は、「Web機能をひとりで作る」本棚(Amazon・広告)も参考にしてください。各本の対象者と前提知識を確認し、今の学習課題に合う1冊を選べます。

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