概要
入力値のバリデーションは、ほぼすべての業務システムに存在する処理です。しかし「Controller に if 文を並べる」「Service 層にバリデーションと業務ロジックが混在する」といった実装は、保守フェーズで変更コストが膨らむ原因になります。この記事では、注文処理における請求金額(BigDecimal > 0)、納期(本日より後)、在庫数(注文数以上)という3つの業務ルールを題材に、バリデーションロジックをルールオブジェクトとして分離し、結果を ValidationResult にまとめて返す設計パターンを解説します。Java 8 での通常クラスベースの実装から、Java 17 の record による簡潔な表現、Java 21 の sealed interface + switch パターンマッチングによる型安全なルール評価まで、バージョンごとの書き方の変化も整理します。フレームワークに依存しない Pure Java の実装なので、既存プロジェクトへの部分導入にも向いています。
使いどころ
受注画面の入力チェックで、請求金額・納期・在庫数のルールを個別に定義し、エラーメッセージを一括で返す
CSV 一括取込の行単位バリデーションで、ルールオブジェクトを再利用して検証ロジックの重複を排除する
API のリクエストバリデーションで、複数のルール違反をまとめてレスポンスに含める(最初の1件で止めない設計)
コード例
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class BusinessRuleValidationDemo {
// 注文データ
record Order(BigDecimal amount, LocalDate deliveryDate,
int stockCount, int orderCount) {}
// バリデーション結果
record ValidationResult(List<String> errors) {
boolean isValid() {
return errors.isEmpty();
}
}
// 注文バリデーター: 各ルールを独立したメソッドで定義
static class OrderValidator {
/** 請求金額 > 0 */
static void validateAmount(
BigDecimal amount, List<String> errors) {
if (amount == null
|| amount.compareTo(BigDecimal.ZERO) <= 0) {
errors.add("請求金額は0より大きい値を指定してください");
}
}
/** 納期 > 本日 */
static void validateDeliveryDate(
LocalDate deliveryDate, List<String> errors) {
if (deliveryDate == null
|| !deliveryDate.isAfter(LocalDate.now())) {
errors.add("納期は本日より後の日付を指定してください");
}
}
/** 在庫数 >= 注文数 */
static void validateStock(
int stockCount, int orderCount,
List<String> errors) {
if (orderCount <= 0) {
errors.add("注文数は1以上を指定してください");
} else if (stockCount < orderCount) {
errors.add("在庫数(" + stockCount
+ ")が注文数(" + orderCount
+ ")を下回っています");
}
}
/** 複合バリデーション: 全ルールを一括チェック */
static ValidationResult validateOrder(Order order) {
var errors = new ArrayList<String>();
validateAmount(order.amount(), errors);
validateDeliveryDate(order.deliveryDate(), errors);
validateStock(
order.stockCount(), order.orderCount(), errors);
return new ValidationResult(errors);
}
}
public static void main(String[] args) {
System.out.println("=== 正常ケース ===");
var order1 = new Order(
new BigDecimal("10000"),
LocalDate.now().plusDays(7),
100, 5
);
var result1 = OrderValidator.validateOrder(order1);
System.out.println("Valid: " + result1.isValid());
System.out.println("\n=== 複数エラーケース ===");
var order2 = new Order(
new BigDecimal("-100"),
LocalDate.now().minusDays(1),
3, 10
);
var result2 = OrderValidator.validateOrder(order2);
System.out.println("Valid: " + result2.isValid());
for (var error : result2.errors()) {
System.out.println(" - " + error);
}
}
}Version Coverage
record で Order と ValidationResult を定義でき、不変性が保証される。var による型推論で記述量も減る。
// Java 17: record で Order と結果を簡潔に表現
record Order(BigDecimal amount, LocalDate deliveryDate,
int stockCount, int orderCount) {}
record ValidationResult(List<String> errors) {
boolean isValid() { return errors.isEmpty(); }
}
// record を渡して一括検証
var result = OrderValidator.validateOrder(order);Library Comparison
注意点
BigDecimal の比較には compareTo を使う。equals は scale(小数点以下の桁数)まで一致しないと false を返すため、1.0 と 1.00 が等しくならない
LocalDate.now() をバリデーション内で直接呼ぶとテスト時に日付を固定できない。Clock や Supplier<LocalDate> を引数にする設計を検討すること
バリデーションエラーを最初の1件で打ち切ると、ユーザーが何度も再入力を強いられる。業務系では全エラーを一括返却するのが一般的
null チェックとビジネスルールチェックは別レイヤーに分けるのが望ましい。null の場合に NPE を投げるか、エラーメッセージに含めるかは設計方針として事前に決めておく
FAQ
入力形式のチェック(null、型、桁数)は Controller 層、業務ルール(在庫 >= 注文数)は Service 層に置くのが一般的です。責任を分けることでテストが書きやすくなります。
分けるのが望ましいです。null は『値が未入力』、金額 <= 0 は『値が不正』という異なる意味を持つため、エラーメッセージも区別するとユーザーに親切です。
業務バリデーションのエラーは想定内の結果なので、例外ではなく戻り値(ValidationResult)で返すのが一般的です。例外はシステムエラーや回復不能な異常に使うのが原則です。