@RequestParamが多すぎる時のController引数整理を解説する記事のアイキャッチ。若手エンジニアがJava道場風の学習空間で検索条件のまとめ方を学ぶイラスト。

設計・テスト・レビュー

@RequestParamが多すぎる時はどうする?Controller引数の整理方法

AIによる要約

@RequestParamが増えすぎたControllerは、読みづらく、修正漏れも起きやすくなります。検索条件や入力項目がひとまとまりなら、Formクラスや検索条件クラスへまとめることを検討します。ただし、何でもまとめればよいわけではなく、関連する値だけをまとめるのが大事です。

新人SE
新人SE
@RequestParamが10個くらいあるControllerを見ました。これは普通ですか?
ポンコツSE
ポンコツSE
動くことはありますが、レビューでは整理対象になりやすいです。検索条件としてまとまるなら、FormやConditionクラスにした方が読みやすくなります。

検索画面や一覧画面を作っていると、Controllerの引数に@RequestParamがどんどん増えることがあります。

注文一覧のキーワードと状態に、開始日・終了日・担当者・ページ番号が加わったとします。既存の検索URLを引き継ぎつつ、形式の違う日付を入力されたら、入力内容とエラーを検索画面に返すところまで作ります。

この記事のポイント

  • @RequestParamが多すぎるControllerは読みづらい
  • 画面の入力をForm、Serviceに渡す検索条件をConditionにまとめる
  • 単発の値まで無理にクラス化する必要はない
  • 既存のURL・クエリ名・ページの既定値を引き継ぐ

実行用サンプル

注文の検索条件6つをフォームへ移し、入力エラー時に値を画面へ返します。 ソース全文、JUnitの検証コード、pom.xml、READMEを収録しています。JDK 17とMaven 3.9系で mvn test を実行します。Spring Boot 3.5.16のサンプルです。

@RequestParamが増える典型パターン

検索画面や一覧画面では、最初はキーワードだけだった条件が、ステータス、日付範囲、担当者、並び順、ページ番号と増えていきます。

OrderSearchController.java の抜粋。6項目を個別に受け取る変更前

    @GetMapping("/demo/orders/before")
    @ResponseBody
    public List<Order> before(
            @RequestParam(name = "keyword", required = false) String keyword,
            @RequestParam(name = "status", required = false) String status,
            @RequestParam(name = "fromDate", required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate fromDate,
            @RequestParam(name = "toDate", required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate toDate,
            @RequestParam(name = "staffId", required = false) String staffId,
            @RequestParam(name = "page", defaultValue = "1") int page) {
        return orderService.search(new OrderSearchCondition(keyword, status, fromDate, toDate, staffId, page));
    }

比較用の /demo/orders/before に ?fromDate=bad を付けると400です。Spring MVCが日付の変換に失敗するため、メソッド本体まで進みません。次の例では同じパラメータをFormで受け、画面にエラーを返します。

関連する条件はクラスにまとめる

6項目のクエリ名を維持し、画面の入力を OrderSearchForm、Serviceへ渡す条件を OrderSearchCondition に分けます。@ModelAttribute("searchForm") でフォーム名を決め、BindingResult はForm引数の直後に置きます。

OrderSearchForm.java の抜粋。画面から受け取る6項目

    private String keyword;
    private String status;
    @DateTimeFormat(iso = ISO.DATE)
    private LocalDate fromDate;
    @DateTimeFormat(iso = ISO.DATE)
    private LocalDate toDate;
    private String staffId;
    @Min(value = 1, message = "ページは1以上で入力してください")
    private Integer page;

OrderSearchController.java の抜粋。変更後。検証を通った入力だけをServiceに渡すGET

    @GetMapping("/orders")
    public String list(@Valid @ModelAttribute("searchForm") OrderSearchForm form,
                       BindingResult errors, Model model) {
        if (!errors.hasFieldErrors("fromDate") && !errors.hasFieldErrors("toDate")
                && form.getFromDate() != null && form.getToDate() != null
                && form.getFromDate().isAfter(form.getToDate())) {
            errors.rejectValue("toDate", "date.range", "終了日は開始日以降にしてください");
        }
        if (errors.hasErrors()) {
            return "orders/list";
        }
        model.addAttribute("orders", orderService.search(form.toCondition()));
        return "orders/list";
    }

日付の形式が不正ならフィールドの変換エラーが入り、両方とも有効で開始日が終了日より後なら toDate にエラーを追加します。エラー時は orderService.search を呼ばず、Thymeleafの th:field と th:errors で入力とメッセージを表示します。この画面の例ではHTTP 200を返します。

list.html の抜粋。入力とエラーを保持するThymeleafフォーム

<form method="get" th:action="@{/orders}" th:object="${searchForm}">
    <label>キーワード <input th:field="*{keyword}"></label>
    <label>状態 <input th:field="*{status}"></label>
    <label>開始日 <input th:field="*{fromDate}" placeholder="YYYY-MM-DD"></label>
    <span th:errors="*{fromDate}"></span>
    <label>終了日 <input th:field="*{toDate}" placeholder="YYYY-MM-DD"></label>
    <span th:errors="*{toDate}"></span>
    <label>担当者 <input th:field="*{staffId}"></label>
    <label>ページ <input th:field="*{page}" inputmode="numeric"></label>
    <span th:errors="*{page}"></span>
    <button type="submit">検索</button>
</form>

何でもまとめればよいわけではない

注意したいのは、無関係な値まで1つのクラスに押し込まないことです。検索条件、ページング条件、画面制御用のフラグなどが混ざりすぎると、逆に分かりにくくなります。

OrderSearchForm に入れるのは検索条件とページ番号です。ログイン利用者の権限や管理メニューの表示可否は、検索パラメータから設定できる項目にせず、認証情報や画面表示の処理で決めます。

Formクラスは便利ですが、画面に関係するものを何でも入れる箱ではありません。検索条件なら検索条件、登録入力なら登録入力というまとまりを守ります。

Serviceへ渡す前に整える

Controllerで受けたFormを、そのままServiceに渡してよいかはプロジェクト方針によります。検索条件を正規化してから渡す形にすると、Service以降が扱いやすくなります。

OrderSearchForm.java の抜粋。未指定のページを1にするメソッド

    OrderSearchCondition toCondition() {
        int effectivePage = page == null ? 1 : page;
        return new OrderSearchCondition(keyword, status, fromDate, toDate, staffId, effectivePage);
    }

OrderSearchCondition.java の全文

package info.syachiku.order;

import java.time.LocalDate;

record OrderSearchCondition(String keyword, String status, LocalDate fromDate,
                            LocalDate toDate, String staffId, int page) {

    OrderSearchCondition {
        keyword = normalize(keyword);
        status = normalize(status);
        staffId = normalize(staffId);
    }

    private static String normalize(String value) {
        return value == null || value.isBlank() ? null : value.strip();
    }
}

日付の LocalDate への変換はFormへのバインディング時に行います。文字列の空欄を null にする処理はService用の条件クラスで行います。ページ番号はFormで1以上か検証し、省略と空欄はどちらも toCondition() で1にします。

レビューでは変更しやすさを見られる

レビューでは、変更前後で同じURLとクエリ名を使えるか、空欄の初期値が変わっていないか、入力エラー時に検索へ進まないかを確かめます。

引数が多いこと自体より、条件追加や修正のときに壊れやすいことが問題です。

単発なら@RequestParamでよい

逆に、1つか2つの値しかない処理まで無理にForm化する必要はありません。

OrderSearchController.java の抜粋。キーワードと状態だけなら2つの引数で受け取れる

    @GetMapping("/demo/orders/simple")
    @ResponseBody
    public List<Order> simple(@RequestParam(name = "keyword", required = false) String keyword,
                              @RequestParam(name = "status", required = false) String status) {
        return orderService.search(new OrderSearchCondition(keyword, status, null, null, null, 1));
    }

大事なのは、機械的にクラス化することではなく、読みやすさと変更しやすさです。

サンプルでは比較用のURLを分けています。実際の置き換えでは、既存の /orders と6つのクエリ名を引き継ぎます。テストは同じ6項目でどちらも注文1を返すこと、ページが省略・空欄なら1、page=2 なら2ページ目になることを確かめます。

OrderSearchTest.java の全文

package info.syachiku.order;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class OrderSearchTest {
    @Autowired
    MockMvc mvc;

    @Test
    void twoIndependentConditionsCanUseRequestParam() throws Exception {
        mvc.perform(get("/demo/orders/simple?keyword=Coffee&status=CLOSED"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(1))
                .andExpect(jsonPath("$[0].id").value(3));
    }

    @Test
    void validQueryParametersFindSameOrderBeforeAndAfter() throws Exception {
        String query = "?keyword=Coffee&status=OPEN&fromDate=2026-10-01"
                + "&toDate=2026-10-02&staffId=S01&page=1";

        mvc.perform(get("/demo/orders/before" + query))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(1))
                .andExpect(jsonPath("$[0].id").value(1));
        mvc.perform(get("/orders" + query))
                .andExpect(status().isOk())
                .andExpect(view().name("orders/list"))
                .andExpect(model().attribute("orders", hasSize(1)))
                .andExpect(content().string(containsString("<li>1 Coffee</li>")))
                .andExpect(content().string(containsString("name=\"keyword\" value=\"Coffee\"")))
                .andExpect(content().string(containsString("name=\"staffId\" value=\"S01\"")));
    }

    @Test
    void omittedAndEmptyPageBothUsePageOne() throws Exception {
        mvc.perform(get("/orders"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("<li>1 Coffee</li>")));
        mvc.perform(get("/orders?page=&keyword="))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("<li>1 Coffee</li>")));
        mvc.perform(get("/orders?page=2"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("<li>3 Coffee</li>")));
    }

    @Test
    void invalidDateIs400BeforeAndReturnsFormWithInputAfter() throws Exception {
        mvc.perform(get("/demo/orders/before?fromDate=bad"))
                .andExpect(status().isBadRequest());
        mvc.perform(get("/orders?keyword=Coffee&fromDate=bad"))
                .andExpect(status().isOk())
                .andExpect(view().name("orders/list"))
                .andExpect(model().attributeHasFieldErrorCode("searchForm", "fromDate", "typeMismatch"))
                .andExpect(model().attributeDoesNotExist("orders"))
                .andExpect(content().string(containsString("name=\"keyword\" value=\"Coffee\"")))
                .andExpect(content().string(containsString("name=\"fromDate\" value=\"bad\"")))
                .andExpect(content().string(containsString("開始日はYYYY-MM-DD形式の有効な日付を入力してください")));
    }

    @Test
    void reversedDateRangeIsDisplayedOnToDate() throws Exception {
        mvc.perform(get("/orders?fromDate=2026-10-03&toDate=2026-10-02"))
                .andExpect(status().isOk())
                .andExpect(model().attributeHasFieldErrorCode("searchForm", "toDate", "date.range"))
                .andExpect(model().attributeDoesNotExist("orders"))
                .andExpect(content().string(containsString("終了日は開始日以降にしてください")));
    }

    @Test
    void zeroPageIsRejectedBeforeSearching() throws Exception {
        mvc.perform(get("/orders?page=0"))
                .andExpect(status().isOk())
                .andExpect(model().attributeHasFieldErrorCode("searchForm", "page", "Min"))
                .andExpect(model().attributeDoesNotExist("orders"))
                .andExpect(content().string(containsString("ページは1以上で入力してください")));
    }
}
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0

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

確認リスト

  • 変更前と同じクエリで同じ注文が返るか
  • ページ番号の省略と空欄で1ページ目になるか
  • 形式エラーや逆順の日付では入力が残り、検索結果が表示されないか
  • 無関係な値まで同じFormに入れていないか

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

関連記事Spring Bootの@RequestParamとFormクラスの違いは?使い分けを解説 @RequestParam は単発の値を受け取るときに便利です。一方、Formクラスは画面入力や検索条件のように複数項目を1つの入力として扱いたいときに向いています。 記事を読む 関連記事JavaのisEmptyとisBlankの違い|null・空文字・空白の判定方法 isEmptyは文字数が0かを判定し、空白だけの文字列はfalseです。isBlankは空文字または空白文字だけならtrueになります。 記事を読む 関連記事Spring BootのControllerでやってはいけないこと Spring Bootの Controller は、リクエストを受け取り、入力を確認し、 Service を呼び出し、レスポンスを返す入口です。業務ロジック、DBアクセス、長い条件分岐、Entityの直接返却を詰め込むと、レビューで指摘されやすくなります。 記事を読む

まとめ

@RequestParamが多すぎるControllerは、検索条件や入力項目をクラスにまとめるサインです。

ただし、何でもまとめればよいわけではありません。関連する値をひとまとまりにし、ControllerとServiceの引数を読みやすくすることを目的に整理しましょう。

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