mcpField
V
validation-patterns

validation-patterns

github.com/wshaddix開発86537 回インストール35 週間前に更新
リポジトリ

validation-patterns について

ASP.NET Core 開発者向けのバリデーションパターンガイドです。属性からアプリケーションのパイプラインまでを扱い、方式の選び方とよくある注意点を説明します。

主な機能

  1. 01複数レイヤーの検証責務を比較
  2. 02DataAnnotations と独自属性を解説
  3. 03IValidatableObject と IValidateOptions を紹介
  4. 04FluentValidation と MediatR パイプラインを説明
  5. 05ファイルアップロードと手動検証を提示

活用シーン

  1. 01リクエストモデルの検証方式を選定
  2. 02起動時に設定オプションを検査
  3. 03複雑または非同期のルールを集約

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill validation-patterns

インストール後、Claude Code で/validation-patternsを入力して読み込まれたか確認します。

詳細

ASP.NET Core の検証パターン

理由

検証は、セキュリティとユーザーエクスペリエンスの両方にとって重要です。適切に検証しないと、無効なデータやセキュリティ上の脆弱性、わかりにくいエラーメッセージにつながります。これらのパターンでは、複数のレイヤーで検証を行う包括的なアプローチを紹介します。

検証戦略

レイヤー目的技術
クライアント側即時にフィードバックを返し、サーバーの負荷を軽減するjQuery Validation、HTML5
モデルバインドデータ型/形式の検証モデルバインダー
アプリケーションビジネスルールの検証FluentValidation、DataAnnotations
構成起動時の検証IValidateOptions
データベース制約の適用EF Core Configurations

検証アプローチの決定ツリー

複雑さに応じて検証アプローチを選択します。

  1. DataAnnotations(既定) -- 宣言型の [Required]、[Range]、[StringLength]、[RegularExpression] 属性を使用します。単純なプロパティ単位の制約に最適です。
  2. IValidatableObject -- Validate() を実装して、複数プロパティにまたがるルールを検証します。日付範囲の比較や条件付き必須フィールドに最適です。
  3. カスタム ValidationAttribute -- ValidationAttribute をサブクラス化して、再利用可能なプロパティ単位のルールを作成します。
  4. IValidateOptions<T> -- DI サービスを利用して、起動時に構成/オプションクラスを検証します。
  5. FluentValidation -- 流暢な API で複雑かつテスト可能な検証を行うサードパーティライブラリです。非同期バリデーターやデータベースに依存するルールに最適です。

パターン 1:DataAnnotations

System.ComponentModel.DataAnnotations 名前空間では、属性を使った宣言型の検証を提供します。

using System.ComponentModel.DataAnnotations;

public sealed class CreateProductRequest
{
    [Required(ErrorMessage = "Product name is required")]
    [StringLength(200, MinimumLength = 1)]
    public required string Name { get; set; }

    [Range(0.01, 1_000_000, ErrorMessage = "Price must be between {1} and {2}")]
    public decimal Price { get; set; }

    [RegularExpression(@"^[A-Z]{2,4}-\d{4,8}$",
        ErrorMessage = "SKU format: AA-0000 to AAAA-00000000")]
    public string? Sku { get; set; }

    [EmailAddress]
    public string? ContactEmail { get; set; }

    [Url]
    public string? WebsiteUrl { get; set; }

    [Range(0, int.MaxValue, ErrorMessage = "Quantity cannot be negative")]
    public int Quantity { get; set; }
}
属性リファレンス
属性目的例
[Required]null ではなく、空でないこと[Required]
[StringLength]最小/最大の長さ[StringLength(200, MinimumLength = 1)]
[Range]数値/日付の範囲[Range(1, 100)]
[RegularExpression]パターンとの一致[RegularExpression(@"^\d{5}$")]
[EmailAddress]メール形式[EmailAddress]
[Phone]電話番号形式[Phone]
[Url]URL 形式[Url]
[CreditCard]Luhn チェック[CreditCard]
[Compare]プロパティの一致[Compare(nameof(Password))]
[MaxLength] / [MinLength]コレクション/文字列の長さ[MaxLength(50)]
[AllowedValues](.NET 8 以降)許可リスト内の値[AllowedValues("Draft", "Published")]
[DeniedValues](.NET 8 以降)拒否リスト内の値[DeniedValues("Admin", "Root")]
[Length](.NET 8 以降)1 つの属性で最小値と最大値を指定[Length(1, 200)]
[Base64String](.NET 8 以降)Base64 形式[Base64String]

パターン 2:カスタム ValidationAttribute

ドメイン固有のルールに対応する、再利用可能な検証属性を作成します。

プロパティ単位
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class FutureDateAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(
        object? value, ValidationContext validationContext)
    {
        if (value is DateOnly date && date <= DateOnly.FromDateTime(DateTime.UtcNow))
        {
            return new ValidationResult(
                ErrorMessage ?? "Date must be in the future",
                [validationContext.MemberName!]);
        }

        return ValidationResult.Success;
    }
}

public sealed class CreateEventRequest
{
    [Required]
    [StringLength(200)]
    public required string Title { get; set; }

    [FutureDate(ErrorMessage = "Event date must be in the future")]
    public DateOnly EventDate { get; set; }
}
クラス単位
[AttributeUsage(AttributeTargets.Class)]
public sealed class DateRangeAttribute : ValidationAttribute
{
    public string StartProperty { get; set; } = "StartDate";
    public string EndProperty { get; set; } = "EndDate";

    protected override ValidationResult? IsValid(
        object? value, ValidationContext validationContext)
    {
        if (value is null) return ValidationResult.Success;

        var type = value.GetType();
        var startValue = type.GetProperty(StartProperty)?.GetValue(value);
        var endValue = type.GetProperty(EndProperty)?.GetValue(value);

        if (startValue is DateOnly start && endValue is DateOnly end && end < start)
        {
            return new ValidationResult(
                ErrorMessage ?? $"{EndProperty} must be after {StartProperty}",
                [EndProperty]);
        }

        return ValidationResult.Success;
    }
}

パターン 3:IValidatableObject

モデル内で複数プロパティにまたがる検証を行うには、IValidatableObject を実装します。

public sealed class CreateOrderRequest : IValidatableObject
{
    [Required]
    [StringLength(50)]
    public required string CustomerId { get; set; }

    [Required]
    public DateOnly OrderDate { get; set; }

    public DateOnly? ShipByDate { get; set; }

    [Required]
    [MinLength(1, ErrorMessage = "At least one line item is required")]
    public required List<OrderLineItem> Lines { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (ShipByDate.HasValue && ShipByDate.Value <= OrderDate)
        {
            yield return new ValidationResult(
                "Ship-by date must be after order date",
                [nameof(ShipByDate)]);
        }

        if (Lines.Sum(l => l.Quantity * l.UnitPrice) > 1_000_000)
        {
            yield return new ValidationResult(
                "Total order value cannot exceed 1,000,000",
                [nameof(Lines)]);
        }

        if (Lines.Any(l => l.RequiresShipping) && ShipByDate is null)
        {
            yield return new ValidationResult(
                "Ship-by date is required when order contains shippable items",
                [nameof(ShipByDate)]);
        }
    }
}

IValidatableObject とカスタム属性の使い分け: 検証ロジックが 1 つのモデル固有の場合は、IValidatableObject を使用します。同じルールを複数のモデルに適用する場合は、カスタム ValidationAttribute を使用します。


パターン 4:IValidateOptions

DI サービスを利用して、起動時に構成/オプションクラスを検証します。

public sealed class DatabaseOptions
{
    public const string SectionName = "Database";

    public string ConnectionString { get; set; } = "";
    public int MaxRetryCount { get; set; } = 3;
    public int CommandTimeoutSeconds { get; set; } = 30;
    public int MaxPoolSize { get; set; } = 100;
    public int MinPoolSize { get; set; } = 0;
}

public sealed class DatabaseOptionsValidator : IValidateOptions<DatabaseOptions>
{
    public ValidateOptionsResult Validate(string? name, DatabaseOptions options)
    {
        var failures = new List<string>();

        if (string.IsNullOrWhiteSpace(options.ConnectionString))
        {
            failures.Add("Database connection string is required.");
        }

        if (options.MaxRetryCount is < 0 or > 10)
        {
            failures.Add("MaxRetryCount must be between 0 and 10.");
        }

        if (options.MinPoolSize > options.MaxPoolSize)
        {
            failures.Add($"MinPoolSize ({options.MinPoolSize}) cannot exceed MaxPoolSize ({options.MaxPoolSize}).");
        }

        return failures.Count > 0
            ? ValidateOptionsResult.Fail(failures)
            : ValidateOptionsResult.Success;
    }
}
登録
builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services.AddSingleton<IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();

パターン 5:FluentValidation のセットアップ

NuGet パッケージ
<PackageReference Include="FluentValidation" Version="11.9.*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.*" />
構成
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddFluentValidationClientsideAdapters();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
基本的なバリデーター
public class SignUpRequest
{
    public required string FirstName { get; set; }
    public required string LastName { get; set; }
    public required string Email { get; set; }
    public required string Username { get; set; }
    public required string Password { get; set; }
    public required string ConfirmPassword { get; set; }
    public required bool AcceptsTos { get; set; }
}

internal sealed class SignUpValidator : AbstractValidator<SignUpRequest>
{
    public SignUpValidator()
    {
        RuleFor(x => x.FirstName)
            .NotEmpty().WithMessage("Please enter a first name")
            .MinimumLength(3).WithMessage("First name must be at least 3 characters long")
            .MaximumLength(50).WithMessage("First name cannot exceed 50 characters")
            .Matches(@"^[a-zA-Z\s'-]+$").WithMessage("First name contains invalid characters");

        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Please enter an email")
            .EmailAddress().WithMessage("Please enter a valid email address")
            .MustAsync(BeUniqueEmail).WithMessage("An account with this email already exists");

        RuleFor(x => x.Password)
            .NotEmpty().WithMessage("Please enter a password")
            .MinimumLength(8).WithMessage("Password must be at least 8 characters long")
            .Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter")
            .Matches(@"[a-z]").WithMessage("Password must contain at least one lowercase letter")
            .Matches(@"[0-9]").WithMessage("Password must contain at least one number")
            .Matches(@"[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character");

        RuleFor(x => x.ConfirmPassword)
            .Equal(x => x.Password).WithMessage("Passwords do not match");

        RuleFor(x => x.AcceptsTos)
            .Equal(true).WithMessage("You must accept our Terms of Service to sign up");
    }

    private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
    {
        return !await _dbContext.Users.AnyAsync(u => u.Email == email, cancellationToken);
    }
}
条件付き検証
public class OrderValidator : AbstractValidator<OrderRequest>
{
    public OrderValidator()
    {
        RuleFor(x => x.ShippingAddress)
            .NotEmpty()
            .When(x => x.RequiresShipping)
            .WithMessage("Shipping address is required when shipping is needed");

        RuleFor(x => x.PickupLocation)
            .NotEmpty()
            .When(x => !x.RequiresShipping)
            .WithMessage("Pickup location is required for in-store pickup");

        When(x => x.IsExpressShipping, () =>
        {
            RuleFor(x => x.ShippingAddress.Country)
                .Must(BeSupportedCountry)
                .WithMessage("Express shipping is not available for this country");
        });
    }
}
コレクションの検証
public class OrderRequestValidator : AbstractValidator<OrderRequest>
{
    public OrderRequestValidator()
    {
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Order must contain at least one item")
            .Must(items => items.Count <= 100).WithMessage("Order cannot contain more than 100 items");

        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(x => x.ProductId)
                .NotEmpty().WithMessage("Product is required");

            item.RuleFor(x => x.Quantity)
                .GreaterThan(0).WithMessage("Quantity must be greater than 0")
                .LessThanOrEqualTo(999).WithMessage("Quantity cannot exceed 999");
        });
    }
}

パターン 6:MediatR の検証パイプライン動作

internal sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        if (!validators.Any())
        {
            return await next(cancellationToken).ConfigureAwait(false);
        }

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            validators.Select(v => v.ValidateAsync(context, cancellationToken)))
            .ConfigureAwait(false);

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count == 0)
        {
            return await next(cancellationToken).ConfigureAwait(false);
        }

        throw new ValidationException(failures);
    }
}

public class ValidationException : Exception
{
    public IReadOnlyList<ValidationFailure> Errors { get; }

    public ValidationException(IEnumerable<ValidationFailure> failures)
        : base("Validation failed")
    {
        Errors = failures.ToList().AsReadOnly();
    }

    public IDictionary<string, string[]> ToDictionary()
    {
        return Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(e => e.ErrorMessage).ToArray());
    }
}
登録
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<Program>();
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});

パターン 7:手動検証

DataAnnotations の検証をプログラムから実行します。

public static class ValidationHelper
{
    public static (bool IsValid, IReadOnlyList<ValidationResult> Errors) Validate<T>(
        T instance) where T : notnull
    {
        var results = new List<ValidationResult>();
        var context = new ValidationContext(instance);

        bool isValid = Validator.TryValidateObject(
            instance, context, results, validateAllProperties: true);

        return (isValid, results);
    }
}

重要: validateAllProperties: true を指定しない場合、Validator.TryValidateObject が検証するのは [Required] 属性のみです。


パターン 8:ファイルアップロードの検証

public class FileUploadValidator : AbstractValidator<FileUploadRequest>
{
    private readonly string[] _allowedExtensions = [".jpg", ".jpeg", ".png", ".pdf"];
    private const long MaxFileSize = 10 * 1024 * 1024; // 10MB

    public FileUploadValidator()
    {
        RuleFor(x => x.File)
            .NotNull()
            .Must(BeValidSize).WithMessage("File size must not exceed 10MB")
            .Must(BeValidExtension).WithMessage("Invalid file type. Allowed: .jpg, .jpeg, .png, .pdf");

        RuleFor(x => x.Description)
            .MaximumLength(500).When(x => !string.IsNullOrEmpty(x.Description));
    }

    private bool BeValidSize(IFormFile file) => file.Length <= MaxFileSize;

    private bool BeValidExtension(IFormFile file)
    {
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        return _allowedExtensions.Contains(extension);
    }
}

アンチパターン

検証の重複
// BAD: Validation in multiple places
if (string.IsNullOrEmpty(model.Email))
    ModelState.AddModelError("Email", "Required");

RuleFor(x => x.Email).NotEmpty(); // Duplicate!

// GOOD: Centralize validation in validators
サイレントな検証エラー
// BAD: Ignoring validation results
catch (ValidationException)
{
    return Page();
}

// GOOD: Always add errors to ModelState
catch (ValidationException ex)
{
    foreach (var error in ex.Errors)
    {
        ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
    }
    return Page();
}
クライアント側の検証を信用する
// BAD: Only client-side validation
public IActionResult OnPost(UserInput input)
{
    SaveToDatabase(input);
}

// GOOD: Server-side always validates
public IActionResult OnPost(UserInput input)
{
    if (!ModelState.IsValid)
        return Page();
    
    SaveToDatabase(input);
}

エージェントに関する注意点

  1. Validator.TryValidateObject には、必ず validateAllProperties: true を渡してください。
  2. オプションクラスでは { get; init; } ではなく { get; set; } を使用してください。設定バインダーがプロパティを変更する必要があります。
  3. IValidatableObject.Validate() が実行されるのは、すべての属性検証が通過した後です。主要な検証としてこれに頼らないでください。
  4. ValidationAttribute のコンストラクター経由でサービスをインジェクションしないでください。IsValid() 内で validationContext.GetService<T>() を使用してください。
  5. IValidateOptions<T> はシングルトンとして登録してください。オプション検証のインフラストラクチャは、バリデーターをシングルトンとして解決します。
  6. ValidateOnStart() を忘れないでください。これがないと、オプション検証は初回アクセス時にしか実行されません。

参考資料

コメント