申請フォームに開始日と終了日を追加した。両方に @NotNull を付けたのに、「終了日が開始日より前」の申請が保存されてしまう。これはアノテーションが壊れたのではありません。@NotNull が確認するのは一つの値で、二つの日付の関係は別の仕様だからです。
ここでは「開始日・終了日は両端を含み、同じ日でも申請可能」と決めます。二つの値を持つDTOにクラス単位の制約を付け、エラーは終了日へ返します。題材は架空の申請アプリです。コード例は Java 21、Spring Boot 3.5系、spring-boot-starter-web と spring-boot-starter-validation を想定します。
先に、未入力と前後関係を分けて決める

| 開始日 | 終了日 | 判定 | 理由 |
|---|---|---|---|
| 未入力 | 入力あり | エラー | 開始日の必須違反 |
| 入力あり | 未入力 | エラー | 終了日の必須違反 |
| 9月10日 | 9月10日 | OK | 1日だけの申請を認める |
| 9月10日 | 9月11日 | OK | 正しい順序 |
| 9月11日 | 9月10日 | エラー | 終了日が先に来る |
今回の相関チェックでは、片方が null なら比較しません。必須違反は @NotNull に任せます。これで「日付を入力してください」と「終了日を確認してください」が同じ未入力欄に重ならず、原因を示しやすくなります。同日を禁止する業務なら、比較を startDate.isBefore(endDate) に変え、同日のテストも失敗側へ移してください。
DTO全体を検証する制約を作る
まずアノテーションを定義します。@Target(TYPE) にすると、一つの項目ではなくDTO全体へ付けられます。
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "終了日は開始日以降にしてください";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}申請入力の二つの欄は、必須チェックと相関チェックの両方を受けます。
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
@ValidDateRange
public record RequestDates(
@NotNull LocalDate startDate,
@NotNull LocalDate endDate
) {}ConstraintValidator は比較に専念させます。クラス単位のエラーをそのまま返すと画面側でどこを直すべきか分かりにくいため、違反先を endDate に指定します。
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public final class DateRangeValidator
implements ConstraintValidator<ValidDateRange, RequestDates> {
@Override
public boolean isValid(RequestDates value, ConstraintValidatorContext context) {
if (value == null || value.startDate() == null || value.endDate() == null) {
return true; // nullは@NotNullへ任せる
}
if (!value.endDate().isBefore(value.startDate())) {
return true; // 同日を許可する
}
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("終了日は開始日以降にしてください")
.addPropertyNode("endDate")
.addConstraintViolation();
return false;
}
}APIなら @Valid @RequestBody RequestDates dates をコントローラー引数に指定します。画面フォームなら @Valid @ModelAttribute RequestDates dates の直後に BindingResult を置き、endDate の項目エラーを表示します。入口で @Valid を呼ばなければ、制約を書いただけでは入力時に実行されません。
失敗する条件をテストへ固定する
jakarta.validation.Validator を使えば、コントローラーを通さず相関条件を確認できます。ここでは終了日に違反が紐付くかまで見ます。
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
class RequestDatesTest {
private final Validator validator = Validation.buildDefaultValidatorFactory()
.getValidator();
@Test
void 同日と翌日は許し逆転を拒否する() {
LocalDate day = LocalDate.of(2026, 9, 10);
assertTrue(validator.validate(new RequestDates(day, day)).isEmpty());
assertTrue(validator.validate(new RequestDates(day, day.plusDays(1))).isEmpty());
var errors = validator.validate(new RequestDates(day, day.minusDays(1)));
assertEquals(1, errors.size());
assertEquals("endDate", errors.iterator().next().getPropertyPath().toString());
}
}未入力と不正な日付文字列は別の入口です。JSONの "2026-02-30" を LocalDate に変換できない場合、DTOを検証する前に読み取りで失敗します。そのエラーまで今回の @ValidDateRange で処理できるとは考えないでください。Webの受け取り方に応じて、変換エラーの応答も確認します。
どこへ置くかの判断
画面で入力し直せる日付の前後関係は、このように入力DTOへ付けると利用者へ直す項目を返せます。一方、「承認済み申請と期間が重ならない」のようにDBの最新状態が必要な規則は、DTOだけで完結しません。保存直前の業務処理でも確認し、競合が起こるならDB制約や排他も検討します。日付二つだけで判断できるか、他の申請も見ないと判断できないかで責任を分けてください。
Spring Bootでフォーム、Controller、Serviceまで一通り組み立てる学習には『作って学ぶ Spring Boot入門』が候補です。Javaの基本文法、HTML/CSS、基本SQLが前提なので、初めてJavaに触る人向けではありません。この相関チェック自体が本に載っているという意味でもありません。出版社の内容紹介で範囲を確認したうえで、脱初心者の本棚(Amazon)から探せます。Amazonのリンクにはアフィリエイトを含みます。
