[Spring Boot] 繰り返しフォームを扱うDTO設計

## はじめに

Spring Bootでの長期インターンで得た経験をもとに、繰り返しフォームを扱うDTO設計に関するTipsを紹介します。スタッフを管理するようなシステムを例に、「可読性・再利用性・保守性」を意識したコーディングと、実務で用いられていた典型的な工夫を紹介します（あくまでインターン生として感じた工夫です）。

### 自己紹介
- 商学部出身
- 兵庫の情報系大学院生（27卒予定）
- サーバーサイド大好き人間
- 趣味はハッカソン
- バックエンドエンジニア志望
- 長期インターンにてSpring Bootを使用（半年以上）


### 背景と目的

フォーム構造が複雑になると、Controllerでのパラメータ受け取りやバリデーションの責務が分散しやすく、再表示処理も不安定になるなどの設計上の課題が増えていきます。本記事では、長期インターンで学んだ設計観点として、以下を紹介します。
- WrapperDtoによるフォーム構造の集約
- バインディング命名規則と動的フォーム連携設計
- 階層的バリデーション戦略と責務の再帰適用
- Null回避と初期表示戦略によるフォーム安定性


## WrapperDtoによるフォーム構造の集約

繰り返しフォームの入力を扱う際、DTOをそのままリストで受け取るのではなく、ラッパーオブジェクト（WrapperDto）でまとめて管理することで、バリデーション・再表示・Service連携がシンプルになります。

```java
// スタッフ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つのオブジェクトとして受け取れる。

```java
// スタッフ情報を一括登録
@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リストを自動的にマッピングできます。これにより、動的に追加・削除されるフォームとも自然に連携できます。

```html
<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のバリデーションが再帰的に適用されます。

```java
public class StaffWrapperDto {
    @Valid
    @Size(min = 1, message = "1名以上のスタッフ情報を入力してください")
    private List<StaffDto> staffs = new ArrayList<>();
}
```
> `@Valid` によって StaffDto に書かれたバリデーションルール（e.g. `@NotBlank`）が自動適用される。また、`@Size` により空のリスト送信もブロックできる。

```java
@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 側で制御している。


```html
<div th:if="${#fields.hasErrors('staffs')}" th:errors="*{staffs}" class="alert alert-danger"></div>
```
> staffsフィールドに関するバリデーションエラーを一括で表示。


## Null回避と初期表示戦略によるフォーム安定性

テンプレート上で `th:each` を使ってリストを展開するには、あらかじめ中身が存在している必要があります。そのため、初期表示時に空のDTOを用意しておきます。

```java
@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パターン：フィールド単位でのリスト受け取り

```java
@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として紹介していきます。質問や改善のフィードバックがあれば、ぜひコメントで教えてください！