先に結論
FormをServiceへ直接渡してよいかは、画面の入力契約とServiceの業務処理の契約が同じかで判断します。画面専用項目・型・検証ルールがずれる、APIやバッチからも同じ処理を呼ぶなら、Service用のDTO/Commandへ変換します。小さい機能で前提が一致している場合まで、名前だけを変えたクラスを機械的に増やす必要はありません。


FormをそのままServiceへ渡すとレビューで指摘された。でもDTOを作っても同じフィールドをコピーするだけに見える。この記事は、FormとDTOを分けるタイミングと、分けた後に値をどう渡すかで迷う人向けです。
名前の説明にとどめず、form.toCommand()の実装、入力エラーの戻し先、Serviceまで値が届く確認例を示します。
FormとDTOを分けるタイミングを先に判断する
| いまの状況 | 判断 | 理由 |
|---|---|---|
| 単一の小さい機能で入力項目・型・制約が一致する | プロジェクト方針を確認し、共有のままにする選択もある | クラス名だけ違うコピーを増やしても責務は明確にならない |
| 確認用パスワードなど画面だけの項目がある | Service用の値へ変換する | Serviceへ表示・入力補助の都合を持ち込まない |
| 画面とAPIやバッチから同じ処理を呼びたい | 各入口から共通のCommandへ変換する | 入口が増えても業務処理の契約をそろえられる |
| 文字列と日付など、入口と業務で必要な型が違う | 変換とエラーの扱いを決めて分ける | 表示形式を業務処理へ押し込まない |
| Entityをそのまま入力に使っている | 入力専用のForm/Requestを設計する | 入力を許す項目とDB保存モデルを分離する |
Formも広い意味ではデータ受け渡し用のDTOです。Springが「Form」と「DTO」を別の種類として強制するわけではありません。この記事では、画面入力用をForm、Serviceの入力用をCommandと呼び、役割を区別します。
確認環境と、入力から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クラスは同じパッケージへ置いてください。
POST /users
↓ name・email・departmentCodeをFormへ束縛
↓ @Validで形式チェック
├ エラーあり → 入力画面へ戻す(Serviceは呼ばない)
└ エラーなし → form.toCommand()
↓ UserRegisterCommand
↓ UserService.register(command)
日付の文字列をLocalDateへ変換するような場合は、変換できない入力をどこでエラーにするかも決めます。未検証の文字列をtoCommand()でいきなりparseするだけでは、入力エラーが例外になってしまいます。以下の例は、同じ型の値を明示的に渡す最小構成です。
Formに入力制約とtoCommand()を実装する
Spring MVCが値を束縛し、Thymeleafが値を読み出せるよう、getter/setterも含めて示します。toCommand()ではServiceへ渡す項目を明示します。
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; }
}
このメソッドは値を新しいオブジェクトへ渡すだけです。DBへの問い合わせやメール重複の判断は入れません。項目数が多い、複数の入力元がある、変換規則が複雑な場合は専用Mapperへ切り出す選択もあります。
Serviceへ渡すCommandは、画面の表示都合を持たせない
public record UserRegisterCommand(String name, String email, String departmentCode) {
}
Java 17のrecordを使うと、コンストラクタとname()・email()・departmentCode()というアクセサが生成されます。getter型のDTOを使うプロジェクトでも考え方は同じですが、呼び出し方を混ぜないようにします。
この例の値はすべて不変なStringなので、Formを後から変更してもCommandの値は変わりません。recordがListや配列まで深くコピーしてくれるわけではないため、可変オブジェクトを持つ場合は別途設計します。
Controllerは検証してから変換し、Serviceを呼ぶ
次は登録画面用Controllerの全体です。BindingResultで形式エラーを確認してからtoCommand()を呼びます。メール重複などの業務エラーはServiceが判断し、Controllerは画面への返し方を決めます。
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";
}
}
テンプレートの名前はusers/new.html、モデル名はformです。画面側のth:object="${form}"・th:field="*{email}"を一致させます。入力チェックとHTML/JSONの返し分けは、ControllerとServiceでチェックが重複するときの整理方法に、テンプレートを含めて示しています。
ServiceはFormではなくCommandを受け取る
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契約と例外クラスです。それぞれ別ファイルに置き、Repositoryの実装はプロジェクトの永続化方法に合わせます。
public interface UserRepository {
boolean existsByEmail(String email);
long save(UserRegisterCommand command);
}
public class DuplicateEmailException extends RuntimeException {
}
ServiceはBindingResultや画面名を知りません。画面に確認用項目が増えても、業務処理に必要な値が同じならCommandとServiceを変えずに済みます。実DBでメール重複を防ぐ場合は、事前確認に加えて一意制約と競合時の扱いが必要です。
EntityをForm代わりにするときは、入力可能な項目を確認する
Entityに管理者フラグや更新したくない項目のsetterがあると、入力との束縛対象を広げてしまうことがあります。DBの項目が多いからと、そのままリクエストを受けるクラスへ流用しないようにします。
この例のFormとCommandには管理者フラグがありません。テストでは余分なadmin=trueが送られても、Repositoryへ渡るCommandが明示した3項目だけであることを確認しました。ただしFormを分けるだけで認可が完成するわけではなく、部署などの指定を許してよいかは別の業務判断です。Spring公式もWeb入力用に設計したオブジェクトや束縛対象の制限を案内しています。
変換できたかは、Serviceへ届いた値で確認する
toCommand()を呼んだだけで終えず、実際に渡った値とエラー時の分岐を確かめます。以下はSpring BootのMockMvcテストクラス内のメソッドです。ControllerとServiceは実物、Repositoryのみ@MockitoBeanで差し替えています。
@Test
void htmlValidInputBecomesCommandAndRedirects() throws Exception {
mockMvc.perform(post("/users").param("name", "山田").param("email", "new@example.com")
.param("departmentCode", "DEV").param("admin", "true"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/users/new?registered"));
ArgumentCaptor<UserRegisterCommand> captured = ArgumentCaptor.forClass(UserRegisterCommand.class);
verify(userRepository).save(captured.capture());
assertThat(captured.getValue()).isEqualTo(new UserRegisterCommand("山田", "new@example.com", "DEV"));
}
Formを変更しても、変換済みCommandが変わらないことも確認します。
@Test
void commandIsSnapshotOfValuesNotReferenceToMutableForm() {
UserRegisterForm form = new UserRegisterForm();
form.setName("山田");
form.setEmail("new@example.com");
form.setDepartmentCode("DEV");
UserRegisterCommand command = form.toCommand();
form.setName("変更後");
assertThat(command).isEqualTo(new UserRegisterCommand("山田", "new@example.com", "DEV"));
}
| 確認する入力・操作 | 確認する結果 |
|---|---|
| 正常な名前・メール・部署コードをPOST | その3項目のCommandがRepositoryへ渡り、302になる |
| 名前・メールが空のままPOST | 入力画面へ戻り、Serviceの重複確認・保存へ進まない |
| toCommand()後にFormの名前を変更 | 先に作ったCommandの名前は変わらない |
| APIから同じ登録処理を呼ぶ | 画面用Formを使わず、同じCommandでServiceへ届く |
上記を含む7件のテストを実行し、成功を確認しました。DB実装や認証・認可まで検証したサンプルではありません。
レビューでは「なぜ分けたか」を一文で説明する
「画面入力用のFormと、画面・APIで共通に使う登録Commandを分けました。入力エラーがないときだけ変換し、Serviceには登録に必要な値を渡します」と説明すると、クラスを増やした理由と処理の順序が伝わります。
- 画面の項目が増えたとき、Serviceまで変更する必要があるか
- DTO/Commandがどの入口と処理の間で使われるか説明できるか
- 変換前に形式エラーを扱えているか
- 変換メソッドにDB操作や業務判断を混ぜていないか
- Entityへ入力させたくない項目まで束縛していないか
Commandへ変換する前の入力エラーでは、画面用Formを残す必要があります。再表示で値が消える場合は、Formを作り直さず入力値と部署の選択状態を保持する実測例で、Form・BindingResult・選択肢一覧のどれが欠けたか確認してください。
この記事とあわせて読みたい
まとめ
FormとDTOを分ける判断は、フィールドの数やクラス名ではなく、画面入力と業務処理の変更理由が一致しているかです。ずれるなら、各入口からService用のCommandへ変換して契約を分けます。
分けた後は、toCommand()の実装と呼ぶ順番をそろえ、正常な値が届くこと、入力エラーなら先へ進まないことを確認します。
FormとServiceの境界だけでなく、エラー時の再表示や検証まで順に確認する場合は、Spring Bootのフォーム入力・バリデーション実践ガイドを参照してください。
Javaの基本文法とクラスを学び、入力画面・登録処理・テストをつないでWeb機能を作りたい人は、「Web機能をひとりで作る」本棚(Amazon・広告)も参考にしてください。各本の対象者と前提知識を確認し、今の学習課題に合う1冊を選べます。




