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


検索画面や一覧画面を作っていると、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に入れていないか
この記事とあわせて読みたい
まとめ
@RequestParamが多すぎるControllerは、検索条件や入力項目をクラスにまとめるサインです。
ただし、何でもまとめればよいわけではありません。関連する値をひとまとまりにし、ControllerとServiceの引数を読みやすくすることを目的に整理しましょう。



