はじめに
Spring Bootでの長期インターンで得た経験をもとに、繰り返しフォームを扱うDTO設計に関するTipsを紹介します。スタッフを管理するようなシステムを例に、「可読性・再利用性・保守性」を意識したコーディングと、実務で用いられていた典型的な工夫を紹介します(あくまでインターン生として感じた工夫です)。
自己紹介
- 商学部出身
- 兵庫の情報系大学院生(27卒予定)
- サーバーサイド大好き人間
- 趣味はハッカソン
- バックエンドエンジニア志望
- 長期インターンにてSpring Bootを使用(半年以上)
背景と目的
フォーム構造が複雑になると、Controllerでのパラメータ受け取りやバリデーションの責務が分散しやすく、再表示処理も不安定になるなどの設計上の課題が増えていきます。本記事では、長期インターンで学んだ設計観点として、以下を紹介します。
- WrapperDtoによるフォーム構造の集約
- バインディング命名規則と動的フォーム連携設計
- 階層的バリデーション戦略と責務の再帰適用
- Null回避と初期表示戦略によるフォーム安定性
WrapperDtoによるフォーム構造の集約
繰り返しフォームの入力を扱う際、DTOをそのままリストで受け取るのではなく、ラッパーオブジェクト(WrapperDto)でまとめて管理することで、バリデーション・再表示・Service連携がシンプルになります。
// スタッフ1名分の入力項目を保持するDTO
public class StaffDto {
// 氏名(必須・空文字不可)
@NotBlank
private String name;
// メールアドレス(形式チェックあり)
@Email
private String email;
}
// StaffDTO のリストをまとめて受け取るためのラッパー
public class StaffWrapperDto {
private List<StaffDto> staffs = new ArrayList<>();
}
StaffDto を複数扱うため、StaffWrapperDto 内でリストとして集約している。この構造により、フォームデータを1つのオブジェクトとして受け取れる。
// スタッフ情報を一括登録
@PostMapping("/staffs/bulk")
public String submitStaffs(@ModelAttribute StaffWrapperDto wrapper) {
// 入力リストをService層に渡して保存処理
staffService.saveAll(wrapper.getStaffs());
// 一覧画面へリダイレクト
return "redirect:/staffs";
}
staffService.saveAll()
にはList<StaffDto>
をそのまま渡せるため、Service層の引数設計とも統一される。
バインディング命名規則と動的フォーム連携設計
バインディング命名規則(list[0].field形式)を使用することで、テンプレートとDTOリストを自動的にマッピングできます。これにより、動的に追加・削除されるフォームとも自然に連携できます。
<tr th:each="staff, stat : *{staffs}">
<td><input type="text" th:field="*{staffs[__${stat.index}__].name}" /></td>
<td><input type="email" th:field="*{staffs[__${stat.index}__].email}" /></td>
</tr>
th:each
とth:field
を組み合わせることで、HTMLテンプレート側でもインデックス付きの構造を簡単に記述できる。
階層的バリデーション戦略と責務の再帰適用
フォームの正当性を確保するため、ネストしたDTO構造全体にバリデーションを適用できるようにします。WrapperDtoに @Valid
を付ければ、内包する各DTOのバリデーションが再帰的に適用されます。
public class StaffWrapperDto {
@Valid
@Size(min = 1, message = "1名以上のスタッフ情報を入力してください")
private List<StaffDto> staffs = new ArrayList<>();
}
@Valid
によって StaffDto に書かれたバリデーションルール(e.g.@NotBlank
)が自動適用される。また、@Size
により空のリスト送信もブロックできる。
@PostMapping("/staffs/bulk")
public String submitStaffs(@ModelAttribute @Valid StaffWrapperDto wrapper, BindingResult result, Model model) {
// 入力チェックでエラーがあれば、エラーメッセージ付きで再表示
if (result.hasErrors()) {
model.addAttribute("errorMessage", "入力に誤りがあります");
return "staff/form";
}
// 正常時はスタッフ情報を保存し、一覧画面へリダイレクト
staffService.saveAll(wrapper.getStaffs());
return "redirect:/staffs";
}
BindingResult
を使用することで、バリデーション失敗時にもフォームを再表示できる。エラーメッセージは Model に渡し、View 側で制御している。
<div th:if="${#fields.hasErrors('staffs')}" th:errors="*{staffs}" class="alert alert-danger"></div>
staffsフィールドに関するバリデーションエラーを一括で表示。
Null回避と初期表示戦略によるフォーム安定性
テンプレート上で th:each
を使ってリストを展開するには、あらかじめ中身が存在している必要があります。そのため、初期表示時に空のDTOを用意しておきます。
@GetMapping("/staffs/bulk")
public String showStaffForm(Model model) {
StaffWrapperDto wrapper = new StaffWrapperDto();
// 空のスタッフDTOを3件分追加して、フォームの初期行を構成
List<StaffDto> staffs =wrapper.getStaffs();
for (int i = 0; i < 3; i++) {
staffs.add(new StaffDto());
}
// フォームに渡すオブジェクトをModelに追加
model.addAttribute("staffForm", wrapper);
return "staff/form";
}
初期値として空の StaffDto をあらかじめ複数入れておくことで、テンプレート上の
th:each
を安定して表示できる。
NGパターン:フィールド単位でのリスト受け取り
@PostMapping("/staffs/bulk")
public String submit(@RequestParam List<String> name, @RequestParam List<String> email) {
return "redirect:/staffs";
}
この場合、name[i] と email[i] の整合性はフレームワーク側では保証されない。また、バリデーションや再表示が困難になる。
まとめ
- フォーム構造はWrapperDtoで集約し、Controllerを簡潔に保つ
- テンプレートとDTOの命名規則を統一し、動的フォームにも自然に対応
- バリデーションはDTOに集約し、再帰的検証とエラー制御を簡潔化
- 初期表示時の安定性を設計段階で確保
おわりに
今後も、実務で得た細かい知見をTipsとして紹介していきます。質問や改善のフィードバックがあれば、ぜひコメントで教えてください!