C# PR

【C#】IValidatableObjectを使って柔軟なModelバリデーションを実装する方法

【C#】IValidatableObjectを使って柔軟なModelバリデーションを実装する方法
記事内に商品プロモーションを含む場合があります

こんにちは!

C#でWebアプリケーションを開発していると、ユーザーの入力データを検証(バリデーション)する必要性に迫られることがよくあります。

Data Annotationsは使っているけど、もっと複雑な検証がしたい
プロパティ間の相関関係をチェックするにはどうすればいい?
バリデーションをもっと柔軟に実装する方法はないの?

このような悩みを持つ開発者の方も多いのではないでしょうか?

この記事では、C#のIValidatableObjectインターフェースを使った、より柔軟なバリデーション方法について、基礎から応用まで、具体例を交えながら詳しくご紹介します。

この記事は次のような人におすすめです。

この記事は次のような人におすすめ!
  • C#でのバリデーション実装に悩んでいる方
  • Data Annotationsの制限に困っている方
  • 複数のプロパティ間の関連チェックを実装したい方
  • バリデーションの仕組みをしっかり理解したい方

この記事を読めば、IValidatableObjectの使い方が分かるだけでなく、具体的な実装方法も理解できるようになります。
さらに、よくある課題への対処方法もお伝えしています。

それでは、順を追って詳しく見ていきましょう。

そもそもIValidatableObjectとは

IValidatableObjectは、.NET Frameworkが提供する、モデルクラスに対してカスタムバリデーションを実装するためのインターフェースです。

このインターフェースの最大の特徴は、プロパティ間の相関関係をチェックできるという点です。たとえば、次のようなチェックが簡単に実装できます。

  • 開始日は終了日より前でなければならない
  • パスワードと確認用パスワードが一致している
  • 合計金額は内訳の合計と一致している

Data Annotationsだけでは実装が難しいこれらのチェックも、IValidatableObjectを使えば簡単に実装できます。

IValidatableObjectの基本構造

まず、IValidatableObjectの基本的な構造を見てみましょう。


public interface IValidatableObject
{
    IEnumerable<ValidationResult> Validate(ValidationContext validationContext);
}

このインターフェースには、Validateというメソッドが1つだけ定義されています。このメソッドは以下のような特徴を持っています。

  • ValidationContextを受け取り、検証のためのコンテキスト情報として使用する
  • ValidationResultの列挙を返す
  • バリデーションエラーがない場合は空の列挙を返す

IValidatableObjectを使った簡単な実装例

具体的な例として、ユーザー登録フォームを想定したモデルを見てみましょう。


public class UserRegistration : IValidatableObject
{
    public string Username { get; set; }
    public string Password { get; set; }
    public string ConfirmPassword { get; set; }
    public int Age { get; set; }
    public bool AcceptTerms { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var results = new List<ValidationResult>();

        // パスワードの一致チェック
        if (Password != ConfirmPassword)
        {
            results.Add(new ValidationResult(
                "パスワードと確認用パスワードが一致しません",
                new[] { nameof(Password), nameof(ConfirmPassword) }
            ));
        }

        // 年齢チェック
        if (Age < 13)
        {
            results.Add(new ValidationResult(
                "13歳未満の方は登録できません",
                new[] { nameof(Age) }
            ));
        }

        // 利用規約の同意チェック
        if (!AcceptTerms)
        {
            results.Add(new ValidationResult(
                "利用規約への同意が必要です",
                new[] { nameof(AcceptTerms) }
            ));
        }

        return results;
    }
}

このコードについてポイントを説明します。

バリデーション結果の管理
  • List<ValidationResult>を使ってバリデーションエラーを収集します
  • ValidationResultには、エラーメッセージと関連するプロパティ名を指定します
プロパティ名の指定
  • nameof演算子を使うことで、プロパティ名のタイプミスを防ぎます
  • 複数のプロパティを関連付けることもできます
エラーメッセージ
  • ユーザーにとって分かりやすい、具体的なメッセージを設定します
  • 何が問題で、どうすれば解決できるのかを明確に示します

Data Annotationsとの組み合わせ

IValidatableObjectは、既存のData Annotationsと組み合わせて使うこともできます。


public class UserRegistration : IValidatableObject
{
    [Required(ErrorMessage = "ユーザー名は必須です")]
    [StringLength(50, ErrorMessage = "ユーザー名は50文字以内で入力してください")]
    public string Username { get; set; }

    [Required(ErrorMessage = "パスワードは必須です")]
    [MinLength(8, ErrorMessage = "パスワードは8文字以上で入力してください")]
    public string Password { get; set; }

    [Required(ErrorMessage = "確認用パスワードは必須です")]
    public string ConfirmPassword { get; set; }

    [Range(0, 120, ErrorMessage = "年齢は0から120の間で入力してください")]
    public int Age { get; set; }

    public bool AcceptTerms { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var results = new List<ValidationResult>();

        if (Password != ConfirmPassword)
        {
            results.Add(new ValidationResult(
                "パスワードと確認用パスワードが一致しません",
                new[] { nameof(Password), nameof(ConfirmPassword) }
            ));
        }

        if (Age < 13)
        {
            results.Add(new ValidationResult(
                "13歳未満の方は登録できません",
                new[] { nameof(Age) }
            ));
        }

        if (!AcceptTerms)
        {
            results.Add(new ValidationResult(
                "利用規約への同意が必要です",
                new[] { nameof(AcceptTerms) }
            ));
        }

        return results;
    }
}

このように組み合わせることで、次のような役割分担が実現できます。

単純な検証
(必須チェックや長さチェックなど)はData Annotationsで行います
複雑な検証
(相関チェックなど)はIValidatableObjectで行います

IValidatableObjectを使った具体的な実装例

ここからは、具体例を交えながら詳しく見ていきましょう。

日付の範囲チェック

イベント予約システムを例に、日付の範囲チェックを実装する方法を説明します。


public class EventReservation : IValidatableObject
{
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
    public int ParticipantsCount { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var results = new List<ValidationResult>();

        // 開始日は今日以降であることをチェック
        if (StartDate.Date < DateTime.Today)
        {
            results.Add(new ValidationResult(
                "開始日は今日以降の日付を指定してください",
                new[] { nameof(StartDate) }
            ));
        }

        // 終了日は開始日以降であることをチェック
        if (EndDate < StartDate)
        {
            results.Add(new ValidationResult(
                "終了日は開始日以降の日付を指定してください",
                new[] { nameof(EndDate) }
            ));
        }

        // 予約期間は最大30日までとする
        var duration = (EndDate - StartDate).TotalDays;
        if (duration > 30)
        {
            results.Add(new ValidationResult(
                "予約期間は最大30日までです",
                new[] { nameof(StartDate), nameof(EndDate) }
            ));
        }

        // 参加人数は期間に応じて制限
        int maxParticipants = duration <= 7 ? 100 : 50;
        if (ParticipantsCount > maxParticipants)
        {
            results.Add(new ValidationResult(
                $"参加人数は{maxParticipants}人までです",
                new[] { nameof(ParticipantsCount) }
            ));
        }

        return results;
    }
}

このコードでは、以下のようなポイントがあります。

基本的な日付チェック
  • 開始日が過去の日付でないことを確認します
  • 終了日が開始日より前でないことを確認します
ビジネスルールの実装
  • 予約期間は30日までという制限を設けています
  • 参加人数は期間によって制限が変わります
関連するプロパティの指定
  • 期間チェックではStartDateEndDateの両方を指定します
  • エラーメッセージが両方のフィールドに表示されます

金額計算の整合性チェック

注文フォームを例に、金額計算の整合性をチェックする実装を見てみましょう。


public class OrderForm : IValidatableObject
{
    public List<OrderItem> Items { get; set; }
    public decimal SubTotal { get; set; }
    public decimal TaxRate { get; set; }
    public decimal TaxAmount { get; set; }
    public decimal TotalAmount { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var results = new List<ValidationResult>();

        // 注文アイテムのチェック
        if (Items == null || !Items.Any())
        {
            results.Add(new ValidationResult(
                "注文アイテムを1つ以上指定してください",
                new[] { nameof(Items) }
            ));
            return results;  // これ以上のチェックは不要
        }

        // 小計のチェック
        var calculatedSubTotal = Items.Sum(item => item.Price * item.Quantity);
        if (Math.Abs(SubTotal - calculatedSubTotal) > 0.01m)
        {
            results.Add(new ValidationResult(
                "小計が注文アイテムの合計と一致しません",
                new[] { nameof(SubTotal) }
            ));
        }

        // 消費税のチェック
        var calculatedTaxAmount = SubTotal * TaxRate;
        if (Math.Abs(TaxAmount - calculatedTaxAmount) > 0.01m)
        {
            results.Add(new ValidationResult(
                "消費税額が正しくありません",
                new[] { nameof(TaxAmount) }
            ));
        }

        // 合計金額のチェック
        var calculatedTotal = SubTotal + TaxAmount;
        if (Math.Abs(TotalAmount - calculatedTotal) > 0.01m)
        {
            results.Add(new ValidationResult(
                "合計金額が正しくありません",
                new[] { nameof(TotalAmount) }
            ));
        }

        return results;
    }
}

public class OrderItem
{
    public string ProductName { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

この実装におけるポイントを説明します。

早期リターン
  • 致命的なエラー(注文アイテムがない)の場合は、それ以上のチェックを行いません
  • パフォーマンスの改善とコードの簡潔さにつながります
金額計算の精度
  • decimal型を使用して金額を扱います
  • 小数点以下の計算誤差を考慮して、差分の絶対値で比較します
複雑な計算の分割
  • 小計、消費税、合計金額と、段階的にチェックします
  • エラーの原因を特定しやすくなります

条件付きバリデーション

ユーザーの選択や入力値に応じて、バリデーションルールを変更する必要がある場合の実装を見ていきましょう。


public class DeliveryOrder : IValidatableObject
{
    public string DeliveryType { get; set; }  // "Standard" または "Express"
    public DateTime? PreferredDate { get; set; }
    public string PreferredTimeSlot { get; set; }
    public string Address { get; set; }
    public string BuildingName { get; set; }
    public string RoomNumber { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var results = new List<ValidationResult>();

        // 配送タイプに応じたバリデーション
        switch (DeliveryType)
        {
            case "Standard":
                // 通常配送の場合のチェック
                if (PreferredDate.HasValue &&
                    PreferredDate.Value < DateTime.Now.AddDays(3))
                {
                    results.Add(new ValidationResult(
                        "通常配送の場合、配送希望日は3日後以降を指定してください",
                        new[] { nameof(PreferredDate) }
                    ));
                }
                break;

            case "Express":
                // 急配の場合のチェック
                if (!PreferredDate.HasValue)
                {
                    results.Add(new ValidationResult(
                        "急配の場合、配送希望日は必須です",
                        new[] { nameof(PreferredDate) }
                    ));
                }
                if (string.IsNullOrEmpty(PreferredTimeSlot))
                {
                    results.Add(new ValidationResult(
                        "急配の場合、配送希望時間帯は必須です",
                        new[] { nameof(PreferredTimeSlot) }
                    ));
                }
                break;

            default:
                results.Add(new ValidationResult(
                    "配送タイプを選択してください",
                    new[] { nameof(DeliveryType) }
                ));
                break;
        }

        // 住所関連のチェック
        if (!string.IsNullOrEmpty(BuildingName) || !string.IsNullOrEmpty(RoomNumber))
        {
            if (string.IsNullOrEmpty(BuildingName))
            {
                results.Add(new ValidationResult(
                    "建物名を入力してください",
                    new[] { nameof(BuildingName) }
                ));
            }
            if (string.IsNullOrEmpty(RoomNumber))
            {
                results.Add(new ValidationResult(
                    "部屋番号を入力してください",
                    new[] { nameof(RoomNumber) }
                ));
            }
        }

        return results;
    }
}

この実装のポイントを説明します。

配送タイプによる分岐
  • switch文を使って、配送タイプごとに異なるバリデーションルールを適用します
  • それぞれの場合で必要なチェックを明確に分離します
関連フィールドの条件付きチェック
  • 建物名か部屋番号のどちらかが入力された場合、両方を必須とします
  • 相互に依存する項目のバリデーションを実装します
NULL許容型の扱い
  • DateTime?のようなNULL許容型を適切にチェックします
  • HasValueプロパティを使って、値の有無を確認します

キャリア形成/給与還元
ひとつひとつ真摯に向き合う企業
ONE_WEDGE社員募集

株式会社 ONE WEDGEでは、新たな仲間を募集しています!

私たちと一緒に、革新的で充実したキャリアを築きませんか?
当社は、従業員が仕事と私生活のバランスを大切にできるよう、充実した福利厚生を整えています。

  • 完全週休2日制(土日休み)で、祝日や夏季休暇、年末年始休暇もしっかり保証!
  • 様々な休暇制度(有給、慶弔、産前・産後、育児、バースデー休暇)を完備!
  • 従業員の成長と健康を支援するための表彰制度、資格取得支援、健康促進手当など!
  • 生活を支えるテレワーク手当、記事寄稿手当、結婚祝金・出産祝金など、様々な手当を提供!
  • 自己啓発としての書籍購入制度や、メンバー間のコミュニケーションを深める交流費補助!
  • 成果に応じた決算賞与や、リファラル採用手当、AI手当など、頑張りをしっかり評価!
  • ワークライフバランスを重視し、副業もOK!

株式会社 ONE WEDGEでは、一人ひとりの従業員が自己実現できる環境を大切にしています。
共に成長し、刺激を与え合える仲間をお待ちしております。
あなたの能力と熱意を、ぜひ当社で発揮してください。
ご応募お待ちしております!

ホームページ、採用情報は下記ボタンからご確認ください!

応募、ご質問など、LINEでお気軽にご相談ください♪

まとめ

ここまで、IValidatableObjectの使い方について詳しく解説してきました。ポイントをおさらいしましょう。

  • IValidatableObjectは柔軟なバリデーションを可能にします
  • 複数のプロパティ間の相関チェックが実装できます
  • 既存のData Annotationsと組み合わせて使えます
  • パフォーマンスと責任分離を意識することが重要です
  • 適切なエラーメッセージの設定が必要です
  • テストがしやすい設計を心がけましょう

IValidatableObjectは、確かに実装の手間は必要です。しかし、複雑なバリデーションを求められる場面で、その真価を発揮します。

フォームの入力チェックやデータの整合性確認など、さまざまな用途で活用できます。ユーザー登録フォーム、注文フォーム、予約システムなど、複雑なビジネスルールを含むシステムで特に重宝します。

IValidatableObjectは、C#でのバリデーション実装における強力なツールの一つです。ぜひ、あなたのプロジェクトでも活用してみてください。

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です