Documentation

how-to/migrate-fluentvalidation.md


title: FluentValidation to DataAnnotations Migration Guide

This skill provides patterns and guidance for migrating validation code from FluentValidation to Microsoft's native DataAnnotations, following ASP.NET Core best practices.


Why Migrate?

Problems with FluentValidation

  • Additional third-party dependency
  • Validators often mix format validation with business logic
  • Database dependencies in validators create tight coupling
  • Hard to test—requires DB for validation
  • Inconsistent with ASP.NET Core conventions

Benefits of DataAnnotations

  • Microsoft-native solution with no third-party dependencies
  • Clear separation between format validation and business logic
  • Business logic stays in the API layer (explicit, testable)
  • Follows ASP.NET Core best practices
  • Better error messages with TypedResults.BadRequest()
  • Standard .NET pattern recognized by all developers

DataAnnotations Quick Reference

Attribute Purpose Example
[Required] Field is mandatory [Required(ErrorMessage = "Name is required")]
[StringLength] Text length constraints [StringLength(50, MinimumLength = 2)]
[Range] Numeric bounds [Range(1, 100)]
[RegularExpression] Pattern matching [RegularExpression(@"^\d{5}$")]
[EmailAddress] Email format [EmailAddress]
[Url] URL format [Url]

Validation Architecture

Two-Layer Validation Flow

Layer 1: Format Validation (DataAnnotations)

User submits Draft
    ↓
[Required], [StringLength], [Range] checked automatically
    ↓
Invalid? → Return BadRequest("Field X is required")
Valid? → Continue to business logic

Layer 2: Business Logic Validation (API Layer)

Draft passes format validation
    ↓
Check business rules:
  - Duplicate name?
  - Dependencies exist?
  - Valid state transition?
    ↓
Invalid? → Return BadRequest("Specific business rule violation")
Valid? → Execute operation

Migration Strategies

Choose your strategy based on the current state of the codebase.

Strategy A: Comment Out Legacy Validators

Use when:

  • Component has complex legacy service layer
  • Validators are mixed with outdated business logic
  • Code references obsolete columns or entities
  • You want to remove FluentValidation immediately without major refactoring

Approach:

  1. Create modern Draft models with DataAnnotations
  2. Comment out validator usage in service methods
  3. Add clear TODO: comments marking what needs future rewrites
  4. Delete validator files
  5. Verify component builds and works

Benefits:

  • FluentValidation dependency removed immediately
  • Component builds cleanly
  • Legacy code preserved for reference (not lost)
  • Clear TODOs mark what needs modern rewrites
  • No risk of breaking complex legacy logic
  • Can modernize incrementally when ready

Example:

// In service methods that use validators:

// TODO: Rewrite with {Entity}Draft + DataAnnotations validation + modern EF Core
// OLD CODE (commented out - uses FluentValidation):
// var validator = new Save{Entity}Validator(request, dataProvider);
// validator.ValidateAndThrow(request);

// Method continues to work without validation (for now)

Strategy B: Create Modern API Endpoints

Use when:

  • Component has a clean, modern API layer
  • No complex legacy service layer
  • Ready to implement the full modern pattern immediately

Approach:

  1. Create Draft models with DataAnnotations
  2. Replace FluentValidation with explicit validation in endpoints
  3. Move duplicate/dependency checks to API layer
  4. Use TypedResults for consistent responses

Pattern Templates

Draft Model

Create in {Component}.Abstractions/Drafts/{Entity}Draft.cs:

using System.ComponentModel.DataAnnotations;

namespace Acsis.Dynaplex.Engines.{Component}.Drafts;

/// <summary>
/// Data transfer object for creating or updating a {Entity}.
/// </summary>
public class {Entity}Draft
{
    /// <summary>
    /// The name of the {entity}.
    /// </summary>
    [Required(ErrorMessage = "Name is required")]
    [StringLength(Constants.MAX_{ENTITY}_NAME_LENGTH,
        MinimumLength = Constants.MIN_{ENTITY}_NAME_LENGTH,
        ErrorMessage = "Name must be between {2} and {1} characters")]
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// Optional description.
    /// </summary>
    [StringLength(500, ErrorMessage = "Description cannot exceed 500 characters")]
    public string? Description { get; set; }
}

API Endpoint Pattern

using System.ComponentModel.DataAnnotations;

private static async Task<Results<Ok<{Entity}>, BadRequest<string>, NotFound<string>>> Create{Entity}(
    [FromBody] {Entity}Draft draft,
    [FromServices] {Component}Db db)
{
    // 1. Format validation (DataAnnotations)
    var validationResults = new List<ValidationResult>();
    var validationContext = new ValidationContext(draft);
    if (!Validator.TryValidateObject(draft, validationContext, validationResults, validateAllProperties: true))
    {
        return TypedResults.BadRequest(string.Join("; ", validationResults.Select(v => v.ErrorMessage)));
    }

    // 2. Business logic: duplicate check
    var existing = await db.{Entities}.FirstOrDefaultAsync(e => e.Name == draft.Name);
    if (existing is not null)
    {
        return TypedResults.BadRequest($"{Entity} with name '{draft.Name}' already exists.");
    }

    // 3. Create entity and save
    var entity = new {Entity}
    {
        Name = draft.Name.Trim(),
        Description = draft.Description?.Trim()
    };

    db.{Entities}.Add(entity);
    await db.SaveChangesAsync();

    return TypedResults.Ok(entity);
}

Delete Endpoint with Dependency Check

private static async Task<Results<Ok, BadRequest<string>, NotFound<string>>> Delete{Entity}(
    int id,
    [FromServices] {Component}Db db)
{
    var entity = await db.{Entities}
        .Include(e => e.RelatedItems)
        .FirstOrDefaultAsync(e => e.Id == id);

    if (entity is null)
    {
        return TypedResults.NotFound($"{Entity} with ID {id} not found.");
    }

    // Business logic: dependency check
    if (entity.RelatedItems.Any())
    {
        return TypedResults.BadRequest(
            $"Cannot delete {entity.Name} because it is referenced by {entity.RelatedItems.Count} related item(s).");
    }

    db.{Entities}.Remove(entity);
    await db.SaveChangesAsync();

    return TypedResults.Ok();
}

Constants and Naming

Define Clear Constants

namespace Acsis.Dynaplex.Engines.{Component};

public static class Constants
{
    // Entity naming constraints
    public const int MIN_{ENTITY}_NAME_LENGTH = 2;
    public const int MAX_{ENTITY}_NAME_LENGTH = 50;

    // Legacy constants (mark as obsolete when renaming)
    [Obsolete("Use MIN_{ENTITY}_NAME_LENGTH instead. This constant was misnamed.")]
    public const int MIN_LOCATION_NAME_LENGTH = 2;
}

Use Constants in DataAnnotations

[StringLength(Constants.MAX_ITEM_NAME_LENGTH,
    MinimumLength = Constants.MIN_ITEM_NAME_LENGTH,
    ErrorMessage = "Item name must be between {2} and {1} characters")]
public string Name { get; set; } = string.Empty;

Migration Checklist

Phase 1: Audit & Decide Strategy

  • Find all FluentValidation validators in component
  • Document validation rules (separate format vs business logic)
  • Identify database dependencies in validators
  • Note naming inconsistencies
  • Decide: Strategy A (comment out) or Strategy B (new endpoints)?

Phase 2: Create Draft Models

  • Create {Entity}Draft.cs in .Abstractions/Drafts/
  • Add DataAnnotations for format validation
  • Use proper nullable reference types
  • Add XML documentation comments

Phase 3A: Comment Out Validators (Strategy A)

  • Comment out validator instantiations with TODO comments
  • Comment out FluentValidation using statements
  • Add clear TODOs explaining future work needed
  • Ensure methods still work (validation temporarily removed)

Phase 3B: Refactor APIs (Strategy B)

  • Add using System.ComponentModel.DataAnnotations;
  • Replace FluentValidation with DataAnnotations validation
  • Move duplicate checks to API layer
  • Move dependency checks to API layer
  • Update return types to Results<Ok<T>, BadRequest<string>, NotFound<string>>
  • Use TypedResults.BadRequest() with clear messages

Phase 4: Standardize

  • Update/add properly-named constants
  • Mark old constants as obsolete
  • Standardize error messages
  • Ensure consistent terminology throughout

Phase 5: Clean Up

  • Delete FluentValidation validator files
  • Remove empty /Validators directories
  • Note FluentValidation packages to remove from .csproj
  • Remove validator DI registrations from Program.cs (Strategy B)

Phase 6: Verify

  • Build succeeds
  • Obsolete warnings appear (expected)
  • Test endpoints with Scalar UI (Strategy B)
  • Verify error messages are clear
  • Document what still needs future work

FAQ

Q: Should I comment out validators or refactor immediately?

If the component has a complex legacy service layer (Dapper SQL, EntLib, DataProviders), use Strategy A (comment out). This removes FluentValidation immediately while preserving legacy code for reference. Modernize incrementally later. If the API layer is already clean, use Strategy B.

Q: Should I keep the service layer or move logic to the API?

For simple CRUD, move to API. For complex business logic, keep the service layer but remove validators. Strategy A preserves the service layer; Strategy B moves logic to the API.

Q: What about async validation?

Business logic checks are async (DB queries). DataAnnotations are synchronous and checked first—this is the correct order.

Q: Can I use IValidatableObject for complex validation?

Yes, for cross-field validation that can't be expressed with attributes. But prefer explicit checks in the API layer for clarity.

Q: Should I remove FluentValidation packages immediately?

No. Wait until all validators in the component are migrated. Then remove packages and rebuild.

Q: What about validators in other components that reference this one?

Migrate each component independently. Cross-component validation should use API calls, not shared validators.