Validation Best Practices — Layered Validation and Domain Rules

Good validation is layered — each layer validates what it knows. The HTTP layer checks format (is this a valid email?). The application layer checks business rules (is this email already taken?). The domain layer enforces invariants (can a Post be published without a title?). Putting all validation in one layer either duplicates checks or forces cross-cutting dependencies into layers that should not have them. Understanding which layer owns which rules is the key to a maintainable, testable validation architecture.

Layered Validation Architecture

// ── Layer 1: HTTP / API — format and presence ──────────────────────────────
// FluentValidation or DataAnnotations on request DTOs
// Runs: before action body, in model binding pipeline
// Validates: required fields, lengths, regex, format (email, URL)
// Does NOT: access database, call services

public class CreatePostRequestValidator : AbstractValidator<CreatePostRequest>
{
    public CreatePostRequestValidator()
    {
        RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Slug).NotEmpty().Matches(@"^[a-z0-9-]+$");
        // Format validated HERE, not in domain or service
    }
}

// ── Layer 2: Application Service — business rules ─────────────────────────
// Runs: inside action body, after format validation passes
// Validates: uniqueness, referential integrity, business policies
// Does: access database, call other services, return domain errors

public class PostService(IPostRepository repo) : IPostService
{
    public async Task<PostDto> CreateAsync(CreatePostRequest request, string userId, CancellationToken ct)
    {
        // Business rule checks — these require DB access
        if (await repo.SlugExistsAsync(request.Slug, ct))
            throw new ConflictException("Slug", "This slug is already in use.");

        if (!await repo.UserCanPostAsync(userId, ct))
            throw new ForbiddenException("Post limit reached for this account.");

        // Domain layer enforces invariants in the constructor/factory
        var post = Post.Create(request.Title, request.Slug, request.Body, userId);
        await repo.AddAsync(post, ct);
        return post.ToDto();
    }
}

// ── Layer 3: Domain — invariants ──────────────────────────────────────────
// Enforced in: entity constructors, factory methods, domain methods
// Rules that MUST always hold regardless of which operation creates the entity
public class Post
{
    private Post(string title, string slug, string body, string authorId)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new ArgumentException("Title cannot be empty.", nameof(title));
        if (string.IsNullOrWhiteSpace(slug))
            throw new ArgumentException("Slug cannot be empty.", nameof(slug));
        // etc.
        Title    = title;
        Slug     = slug;
        Body     = body;
        AuthorId = authorId;
    }

    public static Post Create(string title, string slug, string body, string authorId)
        => new(title, slug, body, authorId);
}
Note: Domain invariants (in entity constructors) and API validation (in FluentValidation/DataAnnotations) may seem to duplicate each other — both check that Title is not empty, for example. This apparent duplication is intentional and correct. The domain entity’s constructor is the absolute last line of defence that guarantees the invariant can never be violated, regardless of who calls it. The API validation is the user-facing feedback layer that returns a 400 with a descriptive error before the domain even sees the request. Each layer serves a distinct purpose.
Tip: Map domain exceptions to HTTP responses in a global exception handler or exception filter rather than in every controller action. Define domain exception types (ConflictException, NotFoundException, ForbiddenException) and a mapping table: ConflictException → 409, NotFoundException → 404. The controller action body has no try/catch; it calls the service and returns the result. Exception mapping happens once in the exception handler (Chapter 32 pattern, applied to Web API in Chapter 41).
Warning: Avoid “validation everywhere” — validating the same rule in multiple layers without clear ownership. If a maximum tag count of 10 is checked in FluentValidation, in the service layer, and in the domain entity, then changing the limit requires updating three places — and they can get out of sync. Decide which layer owns each rule: format rules belong in FluentValidation, business limits that require DB context belong in the service layer, structural domain invariants belong in the entity. One owner per rule.

Collecting All Errors (Not Just First Failure)

// ── FluentValidation collects ALL errors by default ───────────────────────
// A request with Title="" and Slug="INVALID SLUG" gets both errors at once:
// errors: { "title": ["Required"], "slug": ["Must be lowercase..."] }

// DataAnnotations also collects all errors (one per property):
// each property's annotations all run, not just the first failing one

// ── Cascading stop on first property error (optional) ─────────────────────
// If you want property-level cascade (stop after first rule per property):
RuleFor(x => x.Title)
    .Cascade(CascadeMode.Stop)     // stop at first title error
    .NotEmpty()
    .MinimumLength(5)
    .MaximumLength(200);
// Without Cascade.Stop: all three rules run even if NotEmpty fails
// → user sees "required", "too short", "ok on length" at once
// With Cascade.Stop: only "required" appears when field is empty

// ── Global cascade mode ────────────────────────────────────────────────────
// builder.Services.AddFluentValidation(fv =>
//     fv.ValidatorOptions.DefaultClassLevelCascadeMode = CascadeMode.Stop);

Common Mistakes

Mistake 1 — Putting all validation in the service layer (poor error UX)

❌ Wrong — service layer validates format rules with async DB calls; every request hits the database just to check basic format.

✅ Correct — format validation in FluentValidation (no DB); business rules in service layer (DB when needed).

Mistake 2 — Validating business rules (uniqueness) in the controller directly

❌ Wrong — uniqueness check in the controller; unit tests of the controller must mock the repository.

✅ Correct — business rules in the service layer; controller calls service, service throws domain exceptions, exception filter maps to HTTP status.

🧠 Test Yourself

A user submits a post with an empty title, a slug with uppercase letters, and 15 tags. With FluentValidation collecting all errors, how many errors does the 400 response contain?