C# DataAnnotationsでネストしたクラスを再帰的に検証する方法|リフレクションで属性と値を取得する実装解説

C# の DataAnnotations でモデルを検証していると、クラスがメイン→子→孫とネストした途端に「どのプロパティがどの属性に違反しているか」が一気に見えづらくなります。この記事では、リフレクションでネストオブジェクトをたどりながら、検証属性と値を再帰的に取得・検証する実装パターンを、実行可能なコード付きで詳しく解説します。

目次

ネストしたクラスを DataAnnotations 付きで再帰的に検証したい

前提となる要件は次のようなものです。

  • メイン → 子 → 孫…と階層的にネストしたクラス構造がある。
  • 各プロパティには [Required], [Range], [StringLength] などの DataAnnotations 検証属性が付与されている。
  • クライアント側では十分な検証ができないため、サーバー側で 再帰的に検証 し、エラー一覧を取得したい。
  • 合わせて「どのプロパティにどんな検証属性が付いていて、現在値は何か」をダンプしてデバッグにも使いたい。

ここでハマりやすいポイントが、ASP.NET / ASP.NET Core の標準モデルバインディングは「ある程度」ネストを検証してくれるものの、自前でモデルを検証する場合はネストを自動で深掘りしてくれないことです。また、単純に TryValidateObject() を呼ぶだけでは、配列・リスト・辞書をきれいにたどれません。

この記事では、次の 2 ステップの構成で話を進めます。

ステップ目的主な API / 技術
1. ダンププロパティの値と検証属性を再帰的に列挙するTypeDescriptor, ValidationAttribute, リフレクション
2. 検証DataAnnotations に基づきネストオブジェクト全体を検証するValidator.TryValidateProperty, Validator.TryValidateObject

値と属性をたどって「表示(ダンプ)」する ObjectDumper

まずは「どのプロパティにどんな検証属性が付いているか」を確認するためのダンプユーティリティを作ります。ポイントは次の通りです。

  • プロパティ単位で DataAnnotations の検証属性を取得する。
  • メイン → 子 → 孫…を 再帰的にたどる。
  • 配列・List<T> など IEnumerable にも対応する。
  • 親→子→親…のような 循環参照 を検出して無限ループを防ぐ。

ObjectDumper の実装コード


using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Runtime.CompilerServices;

public static class ObjectDumper
{
    public static void DumpWithAttributes(
        object obj,
        int indent = 0,
        HashSet&lt;object&gt;? visited = null,
        string path = "")
    {
        if (obj == null)
        {
            Console.WriteLine($"{new string('\t', indent)}{path}: &lt;null&gt;");
            return;
        }

        visited ??= new HashSet&lt;object&gt;(ReferenceEqualityComparer.Instance);

        // プリミティブ・列挙・日付など「単純な型」はそのまま出力
        if (IsSimple(obj.GetType()))
        {
            Console.WriteLine($"{new string('\t', indent)}{path}: {obj}");
            return;
        }

        // 循環参照対策(同じ参照を 2 回以上たどらない)
        if (visited.Contains(obj))
        {
            Console.WriteLine($"{new string('\t', indent)}{path}: &lt;循環参照&gt;");
            return;
        }
        visited.Add(obj);

        foreach (PropertyDescriptor pd in TypeDescriptor.GetProperties(obj))
        {
            // [Browsable(false)] なプロパティはスキップ
            if (pd.IsBrowsable == false) continue;

            var value = pd.GetValue(obj);
            string currentPath = string.IsNullOrEmpty(path)
                ? pd.Name
                : $"{path}.{pd.Name}";

            // プロパティに付与された検証属性(Required, Range, StringLength 等)
            var validationAttrs = pd.Attributes
                .OfType&lt;ValidationAttribute&gt;()
                .ToArray();

            string attrSummary = validationAttrs.Length == 0
                ? string.Empty
                : $" [{string.Join(", ", validationAttrs.Select(a =&gt; a.GetType().Name))}]";

            if (value == null || IsSimple(pd.PropertyType))
            {
                Console.WriteLine(
                    $"{new string('\t', indent)}{currentPath}: {value ?? "&lt;null&gt;"}{attrSummary}");
            }
            else if (value is IEnumerable seq &amp;&amp; value is not string)
            {
                Console.WriteLine(
                    $"{new string('\t', indent)}{currentPath} (IEnumerable){attrSummary}");

                int i = 0;
                foreach (var item in seq)
                {
                    DumpWithAttributes(item, indent + 1, visited, $"{currentPath}[{i++}]");
                }
            }
            else
            {
                Console.WriteLine(
                    $"{new string('\t', indent)}{currentPath}{attrSummary}");
                DumpWithAttributes(value, indent + 1, visited, currentPath);
            }
        }
    }

    // 「単純な型」の判定ロジック
    private static bool IsSimple(Type t) =&gt;
        t.IsPrimitive ||
        t.IsEnum ||
        t == typeof(string) ||
        t == typeof(decimal) ||
        t == typeof(DateTime) ||
        t == typeof(DateTimeOffset) ||
        t == typeof(Guid) ||
        t == typeof(TimeSpan);

    // 参照等価な HashSet 用の比較クラス
    private sealed class ReferenceEqualityComparer : IEqualityComparer&lt;object&gt;
    {
        public static readonly ReferenceEqualityComparer Instance = new();

        public new bool Equals(object? x, object? y) =&gt; ReferenceEquals(x, y);

        public int GetHashCode(object obj) =&gt; RuntimeHelpers.GetHashCode(obj);
    }
}

実装のポイント解説

箇所役割ポイント
IsSimple(Type)単純型かどうかの判定プリミティブや string, DateTime などは再帰せず、その場で値を出力します。
visited + ReferenceEqualityComparer循環参照対策親→子→親…のようなループ構造で無限再帰になるのを防ぎます。
TypeDescriptor.GetProperties(obj)プロパティ列挙プロパティに付与された属性をまとめて取得できます。
pd.Attributes.OfType<ValidationAttribute>()検証属性の取得[Required] など DataAnnotations の属性のみを抽出します。
IEnumerable 判定コレクション対応Orders[0].Amount のように、インデックス付きのパスを生成して再帰します。

呼び出し例


var model = CreateSampleModel(); // 適当なテスト用インスタンス
ObjectDumper.DumpWithAttributes(model);

コンソールには、


Id: 1
Name: test
Child.Prop1: foo
Child.Prop2: &lt;null&gt; [RequiredAttribute]
Child.GrandChild.Number: &lt;null&gt; [RequiredAttribute]

といった形で「パス + 現在値 + 検証属性」が出力され、複雑なモデルでも全体像を把握しやすくなります。

DataAnnotations を使って「有効/無効を判定」する RecursiveValidator

次に本題として、DataAnnotations に基づき ネストしたオブジェクト全体を検証 するユーティリティを作ります。

標準の Validator.TryValidateObject(obj, context, results, validateAllProperties: true) は便利ですが、次のような点で足りないことが多いです。

  • ネストしたクラス(子・孫クラス)の検証は自動では再帰されない。
  • IEnumerable の各要素に対して個別にパスを振る(例: Orders[0].Amount)機能はない。
  • どのパスにどんなエラーが出たかを、自分好みの構造に整形したい。

そこで、プロパティ単位の検証とオブジェクト単位の検証を組み合わせて、自前でたどる RecursiveValidator を用意します。

RecursiveValidator の実装コード


using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;

public record ValidationError(string Path, string Message);

public static class RecursiveValidator
{
    public static List&lt;ValidationError&gt; Validate(
        object? obj,
        string path = "",
        HashSet&lt;object&gt;? visited = null)
    {
        var errors = new List&lt;ValidationError&gt;();
        if (obj == null) return errors;

        visited ??= new HashSet&lt;object&gt;(ReferenceEqualityComparer.Instance);
        if (visited.Contains(obj)) return errors;
        visited.Add(obj);

        var type = obj.GetType();

        // 1) プロパティ単位の検証
        foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
        {
            // インデクサは除外
            if (prop.GetIndexParameters().Length &gt; 0) continue;

            var value = prop.GetValue(obj);
            var currentPath = string.IsNullOrEmpty(path)
                ? prop.Name
                : $"{path}.{prop.Name}";

            // プロパティに付与された DataAnnotations を評価
            var propResults = new List&lt;ValidationResult&gt;();
            var ctx = new ValidationContext(obj) { MemberName = prop.Name };

            Validator.TryValidateProperty(value, ctx, propResults);

            foreach (var r in propResults)
            {
                errors.Add(new ValidationError(
                    currentPath,
                    r.ErrorMessage ?? "無効な値です"));
            }

            // 2) 複合型やコレクションは再帰的に検証
            if (value == null) continue;
            if (IsSimple(prop.PropertyType)) continue;

            if (value is IEnumerable seq &amp;&amp; value is not string)
            {
                int i = 0;
                foreach (var item in seq)
                {
                    var itemPath = $"{currentPath}[{i++}]";
                    errors.AddRange(Validate(item, itemPath, visited));
                }
            }
            else
            {
                errors.AddRange(Validate(value, currentPath, visited));
            }
        }

        // 3) オブジェクトレベルの属性 / IValidatableObject を評価
        var objResults = new List&lt;ValidationResult&gt;();
        var objCtx = new ValidationContext(obj);

        // validateAllProperties: false で OK(プロパティ検証は上で済ませているため)
        Validator.TryValidateObject(obj, objCtx, objResults, validateAllProperties: false);

        foreach (var r in objResults)
        {
            // MemberNames が空なら、オブジェクトそのものに紐づくエラー
            var targetPath = r.MemberNames.Any()
                ? string.Join(", ", r.MemberNames.Select(n =&gt;
                    string.IsNullOrEmpty(path) ? n : $"{path}.{n}"))
                : path;

            errors.Add(new ValidationError(
                targetPath,
                r.ErrorMessage ?? "オブジェクトレベルの検証エラー"));
        }

        return errors;
    }

    private static bool IsSimple(Type t) =&gt;
        t.IsPrimitive ||
        t.IsEnum ||
        t == typeof(string) ||
        t == typeof(decimal) ||
        t == typeof(DateTime) ||
        t == typeof(DateTimeOffset) ||
        t == typeof(Guid) ||
        t == typeof(TimeSpan);

    private sealed class ReferenceEqualityComparer : IEqualityComparer&lt;object&gt;
    {
        public static readonly ReferenceEqualityComparer Instance = new();

        public new bool Equals(object? x, object? y) =&gt; ReferenceEquals(x, y);

        public int GetHashCode(object obj) =&gt; RuntimeHelpers.GetHashCode(obj);
    }
}

RecursiveValidator の流れ

ステップ内容関連 API
1. プロパティ検証各プロパティの DataAnnotations を評価し、エラーを ValidationError として収集Validator.TryValidateProperty()
2. 再帰処理クラス・構造体・コレクションなど複合型を再帰的にたどるリフレクション、IEnumerable
3. オブジェクト検証オブジェクト全体に付与された属性や IValidatableObject を評価Validator.TryValidateObject()
4. 循環参照対策同一参照を 2 回以上たどらないように HashSet で管理ReferenceEquals(), HashSet<object>

検証の呼び出し例と出力イメージ


var model = CreateSampleModel(); // 後述のサンプルクラスを利用
var errors = RecursiveValidator.Validate(model);

if (errors.Count &gt; 0)
{
    foreach (var e in errors)
    {
        Console.WriteLine($"{e.Path}: {e.Message}");
    }
}

例えば、子クラスと孫クラスのプロパティが未入力の場合、コンソールには次のようなメッセージが出力されます。


Child.Prop2: prop2 は必須です
Child.GrandChild.Number: number は必須です

このように、パス付きのエラー情報 を得られるので、API レスポンスを JSON で返す際にも、クライアント側で「どの項目をハイライトするか」を簡単に判断できます。

サンプルクラス定義と Required 属性の正しい使い方

よくある落とし穴が、[Required] を非 nullable 値型に付けてしまうことです。C# の int, bool など非 null の値型は、常に何らかの値(既定値)が入るため、[Required] を付けても「必須チェック」にはなりません。

「入力されていない状態をエラーにしたい」場合は、次のような設計を検討しましょう。

目的プロパティ型付与する属性備考
未入力(null)を禁止したいint?, decimal? など[Required]値が null の場合にエラーにできます。
0 や負の値を禁止したいint, decimal[Range(1, int.MaxValue)] などビジネスロジックとしての範囲チェックを行います。
空文字・空白を禁止したいstring[Required], [StringLength][Required(AllowEmptyStrings = false)] で空文字も弾けます。

修正済みのサンプルクラス定義


using System.ComponentModel.DataAnnotations;

public class MainClass
{
    public int Id { get; set; }
    public string? Name { get; set; }

    public ChildClass? Child { get; set; }
}

public class ChildClass
{
    public string? Prop1 { get; set; }

    [Required(ErrorMessage = "prop2 は必須です")]
    public string? Prop2 { get; set; }

    public GrandChildClass? GrandChild { get; set; }
}

public class GrandChildClass
{
    public int Id { get; set; }

    // パターン A: null を許さず必須としたい場合
    [Required(ErrorMessage = "number は必須です")]
    public int? Number { get; set; }

    // パターン B: 1 以上の値を必須としたい場合
    //[Range(1, int.MaxValue, ErrorMessage = "number は 1 以上で必須です")]
    //public int Number { get; set; }
}

サンプルモデル生成と検証の一連の流れ


static MainClass CreateSampleModel()
{
    return new MainClass
    {
        Id = 1,
        Name = "test",
        Child = new ChildClass
        {
            Prop1 = "foo",
            Prop2 = null, // 未入力 → Required に違反
            GrandChild = new GrandChildClass
            {
                Id = 10,
                Number = null // 未入力 → Required に違反(int? の場合)
            }
        }
    };
}

static void Main()
{
    var model = CreateSampleModel();

    Console.WriteLine("=== DumpWithAttributes ===");
    ObjectDumper.DumpWithAttributes(model);

    Console.WriteLine();
    Console.WriteLine("=== RecursiveValidator ===");
    var errors = RecursiveValidator.Validate(model);

    if (errors.Count == 0)
    {
        Console.WriteLine("エラーはありません。");
    }
    else
    {
        foreach (var e in errors)
        {
            Console.WriteLine($"{e.Path}: {e.Message}");
        }
    }
}

このメインメソッドを実行すると、先に紹介したダンプ結果と検証結果が一度に確認でき、実際の API モデルに組み込む前にロジックを単体テストしやすくなります。

ASP.NET / ASP.NET Core での活用イメージ

このような再帰的な DataAnnotations 検証は、ASP.NET / ASP.NET Core の Web API 層で特に有効です。例えば次のような使い方が考えられます。

  • 複数の API が同じ中間層ライブラリのモデルを共有しており、モデル定義と検証ロジックをライブラリ側で一元管理したい。
  • クライアント側で完全な検証ができない(または信用できない)ため、サーバー側で 必ず DataAnnotations に基づいて再チェック したい。
  • ModelState の構造に依存しない形で、横断的に検証結果を扱いたい。

例えば、ASP.NET Core のアクションメソッド内で次のように利用できます。


[HttpPost]
public IActionResult PostMain([FromBody] MainClass model)
{
    var errors = RecursiveValidator.Validate(model);

    if (errors.Count &gt; 0)
    {
        // ModelState 風の形式に変換
        var errorDict = errors
            .GroupBy(e =&gt; e.Path)
            .ToDictionary(
                g =&gt; g.Key,
                g =&gt; g.Select(x =&gt; x.Message).ToArray());

        return BadRequest(new
        {
            Message = "入力値にエラーがあります。",
            Errors = errorDict
        });
    }

    // 正常処理
    return Ok();
}

クライアント側では、返却された Errors のキー(例: Child.GrandChild.Number)を用いて、ネストした入力フォームの該当要素を特定し、エラー表示に利用できます。

よくある落とし穴と対策

[Required] と値型の誤用

先ほども触れた通り、int や bool などの非 null 値型に [Required] を付けても、ほとんどの場合期待する動作にはなりません。実際には「null かどうか」しか見ていないためです。

次のような表で整理しておくと、チーム内のレビューでも共有しやすくなります。

型Required の意味典型的な誤解推奨パターン
int常に値が入っているので Required はほぼ無意味0 のときにエラーになると勘違い[Range] を使う、または int? にする
int?null 時にエラー(未入力の検知)0 と null の違いを曖昧に扱ってしまう「未入力」と「0」というビジネス上の意味を整理してから採用
stringnull や空文字をエラーにできる(設定次第)空白文字だけが入った場合の扱いを忘れがち必要に応じて Trim を噛ませた上で検証、またはカスタム属性を作成

循環参照とパフォーマンス

エンティティフレームワークなど ORM と組み合わせると、ナビゲーションプロパティで親子が相互参照しているケースがよくあります。このような場合、単純な再帰処理ではすぐにスタックオーバーフローを起こしてしまうため、参照等価に基づく visited 管理 はほぼ必須です。

また、頻繁に検証を行う場合には、リフレクションのコストを抑えるために次のような最適化も検討できます。

  • Type ごとに「検証対象のプロパティ一覧」をキャッシュする(ConcurrentDictionary<Type, PropertyInfo[]> など)。
  • IsSimple(Type) の判定も同様にキャッシュしておく。
  • 属性情報(ValidationAttribute[])をキャッシュしておき、ランタイムでは値の取得と TryValidateProperty の呼び出しだけにする。

IEnumerable の扱いとパス設計

IEnumerable に対する再帰呼び出しでは、要素ごとにパスをどう表現するかが地味に重要です。代表的なパターンは次の通りです。

表現例メリットデメリット
Orders[0].Amount配列・リストなどインデックスベースのコレクションと相性が良い順序が変わるとパスがズレる可能性がある
OrderLines[OrderId=123].Priceビジネスキーで表現できるため意味が分かりやすい実装が複雑になりがち、コレクション要素にキー情報が必要
Items[*].Name「どれかの要素にエラーがある」ことだけ示したいときに有効具体的にどの要素か特定しづらい

この記事の実装ではシンプルに Orders[0].Amount 方式を採用していますが、ビジネス要件に応じてカスタマイズする余地があります。

実務で便利な拡張アイデア

ここまでの実装だけでも十分に実用的ですが、実務では次のような拡張を加えるとさらに使い勝手が良くなります。

独自の ValidationAttribute を活用する

DataAnnotations の標準属性だけでは表現しにくいビジネスルールは、独自の ValidationAttribute として実装してしまうのがおすすめです。例えば、日付の前後関係をチェックする属性などです。


public class DateRangeCompareAttribute : ValidationAttribute
{
    public string OtherProperty { get; }

    public DateRangeCompareAttribute(string otherProperty)
    {
        OtherProperty = otherProperty;
    }

    protected override ValidationResult? IsValid(
        object? value,
        ValidationContext validationContext)
    {
        var otherProp = validationContext.ObjectType.GetProperty(OtherProperty);
        if (otherProp == null)
        {
            return new ValidationResult(
                $"{OtherProperty} プロパティが見つかりません。");
        }

        var thisDate = value as DateTime?;
        var otherDate = otherProp.GetValue(validationContext.ObjectInstance) as DateTime?;

        if (thisDate.HasValue &amp;&amp; otherDate.HasValue &amp;&amp; thisDate &lt; otherDate)
        {
            return new ValidationResult(
                ErrorMessage ?? $"{validationContext.MemberName} は {OtherProperty} 以降の日付を指定してください。");
        }

        return ValidationResult.Success;
    }
}

このようなカスタム属性も、RecursiveValidator の中では他の属性と同様に自動的に評価されます。

エラー結果を UI 向けに整形する

ValidationError(Path, Message) のリストは汎用的で扱いやすい形式ですが、そのままでは SPA フロントエンドなどと連携しにくいケースがあります。その場合は、次のような形に変換すると扱いやすくなります。


public class ValidationErrorResponse
{
    public string Message { get; set; } = string.Empty;

    // "Child.GrandChild.Number" -&gt; ["number は必須です", ...]
    public Dictionary&lt;string, string[]&gt; Errors { get; set; } = new();
}

static ValidationErrorResponse ToResponse(List&lt;ValidationError&gt; errors)
{
    var dict = errors
        .GroupBy(e =&gt; e.Path)
        .ToDictionary(
            g =&gt; g.Key,
            g =&gt; g.Select(x =&gt; x.Message).Distinct().ToArray());

    return new ValidationErrorResponse
    {
        Message = "入力値にエラーがあります。",
        Errors = dict
    };
}

これをそのまま JSON で返せば、フロントエンド側はキーとメッセージの配列だけを見ればよく、実装がシンプルになります。

まとめ:ネストした C# モデルの DataAnnotations 検証を「見える化」&「再帰化」する

この記事では、C# の DataAnnotations を使ってネストしたモデルを検証する際の定番課題に対して、次のような解決策を紹介しました。

  • ObjectDumper で、プロパティのパス・現在値・検証属性を再帰的にダンプし、複雑なモデルの状態を可視化する。
  • RecursiveValidator で、TryValidateProperty と TryValidateObject を組み合わせつつ、クラス・コレクションを自前で再帰して「どこにどんな DataAnnotations エラーがあるか」の一覧を取得する。
  • [Required] を値型に付けても意味が薄いことを理解し、int? や [Range] を使ってビジネス要件に沿った定義にする。
  • 循環参照・コレクション・パフォーマンスなどの落とし穴に注意しつつ、必要に応じてキャッシュやカスタム属性で拡張する。

中間層ライブラリなどで複数の API からモデルと検証ロジックを共有したい場合、ここで紹介したパターンは非常に汎用性が高く、そのままプロジェクトに組み込んで使うことができます。まずはテスト用プロジェクトでサンプルコードを動かしてみて、自分たちのモデル構造に合わせたカスタマイズを加えてみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次