Spring Frameworkで未入力のフォームの値をデフォルトで設定されている空文字ではなく、nullで受け取る方法をまとめます。
環境は以下です。
Spring Boot 2.3.1.RELEASE
Thymeleaf 3.0.11.RELEASE
Kotlin 1.3.70
以下のようなフォームと、フォームの値を格納するクラスを用意して説明していきます。
<form method="post" action="/todo" th:object="${todoCreateForm}">
<div class="form-group">
<label th:for="*{title}">Title</label>
<input type="text" class="form-control" name="title">
<p class="alert alert-danger" th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></p>
</div>
<div class="form-group">
<label th:for="*{detail}">Detail</label>
<textarea class="form-control" name="detail"></textarea>
<p class="alert alert-danger" th:if="${#fields.hasErrors('detail')}" th:errors="*{detail}"></p>
</div>
<button type="submit" class="btn btn-primary"> create</button>
</form>
data class TodoCreateForm( @field:NotNull val title: String? = null, @field:NotNull val detail: String? = null )
このときデフォルトの挙動では、未入力の項目は空文字として扱われるため、下の画像のように@NotNullのバリデーションエラーにならずに、その後の処理が進んでいきます。

今回の要件であれば@NotEmptyを使うことで問題を解消することはできますが、例えば必須ではない入力項目に対して、@Patternを使った形式チェックをしたい場合などでは、常に空文字を許容できる正規表現を書く必要があります。
こういったケースのように未入力のフォーム値を空文字ではなくnullで扱うためには、以下のようにDataBinderに対象の型とPropertyEditorを設定します。
@Controller @RequestMapping("todo") class TodoController( private val todoQueryService: TodoQueryService, private val todoCreateService: TodoCreateService, private val todoDeleteService: TodoDeleteService ) { @InitBinder() fun allowEmptyDataBinding(binder: WebDataBinder) { binder.registerCustomEditor(String::class.java, StringTrimmerEditor(true)) } // 省略 }
今回はStringの値を空文字ではなくnullで扱いたいのでWebDataBinder#registerCustomEditor()の第一引数にStringを、第二引数にSpringが提供しているStringTrimmerEditorを引数にtrueを指定して作成したインスタンスを設定します。
StringTrimmerEditorのソースコードは以下のようになっています。(一部省略)
// 省略 public class StringTrimmerEditor extends PropertyEditorSupport { @Nullable private final String charsToDelete; private final boolean emptyAsNull; /** * Create a new StringTrimmerEditor. * @param emptyAsNull {@code true} if an empty String is to be * transformed into {@code null} */ public StringTrimmerEditor(boolean emptyAsNull) { this.charsToDelete = null; this.emptyAsNull = emptyAsNull; } @Override public void setAsText(@Nullable String text) { if (text == null) { setValue(null); } else { String value = text.trim(); if (this.charsToDelete != null) { value = StringUtils.deleteAny(value, this.charsToDelete); } if (this.emptyAsNull && value.isEmpty()) { setValue(null); } else { setValue(value); } } } // 省略 }
StringTrimmerEditorのソースコードは以下のようになっていて、上記でStringTrimmerEditorに渡した引数trueはemptyAsNullというフィールドの値として処理されます。
emptyAsNullの値にtrueを設定することで、setAsText()の中の処理で空文字をnullとして設定するようになります。
実際に、サンプルアプリケーションではこれを設定することで、未入力のフォーム値の場合には値にnullが入り、@NotNullのバリデーションエラーによってエラーメッセージが表示されます。

今回はStringTrimmerEditorを使いましたが、Springでは他にもさまざまなPropertyEditorが提供されていて、公式ドキュメントに概要がまとめられているので、以下をみると面白いかもしれません。
参考
StringTrimmerEditor (Spring Framework 5.2.8.RELEASE API)
spring-framework/StringTrimmerEditor.java at master · ndimiduk/spring-framework · GitHub
This entry is released under version 2.0 of the Apache License.