概要

入力値のバリデーションは、ほぼすべての業務システムに存在する処理です。しかし「Controller に if 文を並べる」「Service 層にバリデーションと業務ロジックが混在する」といった実装は、保守フェーズで変更コストが膨らむ原因になります。この記事では、注文処理における請求金額(BigDecimal > 0)、納期(本日より後)、在庫数(注文数以上)という3つの業務ルールを題材に、バリデーションロジックをルールオブジェクトとして分離し、結果を ValidationResult にまとめて返す設計パターンを解説します。Java 8 での通常クラスベースの実装から、Java 17 の record による簡潔な表現、Java 21 の sealed interface + switch パターンマッチングによる型安全なルール評価まで、バージョンごとの書き方の変化も整理します。フレームワークに依存しない Pure Java の実装なので、既存プロジェクトへの部分導入にも向いています。

使いどころ

受注画面の入力チェックで、請求金額・納期・在庫数のルールを個別に定義し、エラーメッセージを一括で返す

CSV 一括取込の行単位バリデーションで、ルールオブジェクトを再利用して検証ロジックの重複を排除する

API のリクエストバリデーションで、複数のルール違反をまとめてレスポンスに含める(最初の1件で止めない設計)

コード例

BusinessRuleValidationDemo.java
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);
        }
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

record で Order と ValidationResult を定義でき、不変性が保証される。var による型推論で記述量も減る。

Java 17
// 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

標準 API(record + List)フレームワーク非依存で業務ルールを自前管理するとき。バリデーションロジックの単体テストが書きやすく、ルールの追加・変更が局所的に済む。ルール数が増えると管理対象のクラスが増える。アノテーションベースのバリデーションに比べて宣言的でない。
Bean Validation(Jakarta Validation)フィールドにアノテーション(@NotNull, @Min, @Future)を付けて宣言的にバリデーションしたいとき。Spring / Jakarta EE と組み合わせて使う場面が多い。複数フィールドにまたがるルール(在庫数 >= 注文数)はカスタムバリデータが必要で、アノテーションだけでは表現しにくい。
Vavr(旧 Javaslang)Validation モナドで複数のバリデーション結果を関数的に合成したいとき。エラーの蓄積が自然に書ける。関数型プログラミングの知識が前提になる。チーム全体が Vavr に習熟していないと可読性が下がる。

注意点

BigDecimal の比較には compareTo を使う。equals は scale(小数点以下の桁数)まで一致しないと false を返すため、1.0 と 1.00 が等しくならない

LocalDate.now() をバリデーション内で直接呼ぶとテスト時に日付を固定できない。Clock や Supplier<LocalDate> を引数にする設計を検討すること

バリデーションエラーを最初の1件で打ち切ると、ユーザーが何度も再入力を強いられる。業務系では全エラーを一括返却するのが一般的

null チェックとビジネスルールチェックは別レイヤーに分けるのが望ましい。null の場合に NPE を投げるか、エラーメッセージに含めるかは設計方針として事前に決めておく

FAQ

バリデーションは Controller と Service のどちらに置くべきですか。

入力形式のチェック(null、型、桁数)は Controller 層、業務ルール(在庫 >= 注文数)は Service 層に置くのが一般的です。責任を分けることでテストが書きやすくなります。

BigDecimal の null チェックと金額チェックは分けるべきですか。

分けるのが望ましいです。null は『値が未入力』、金額 <= 0 は『値が不正』という異なる意味を持つため、エラーメッセージも区別するとユーザーに親切です。

バリデーションエラーは例外で通知すべきですか。

業務バリデーションのエラーは想定内の結果なので、例外ではなく戻り値(ValidationResult)で返すのが一般的です。例外はシステムエラーや回復不能な異常に使うのが原則です。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます