Course outline

ASP.NET Core / Intermediate: Data, Identity, and Asynchronous Work / Lesson 8

Validation and Problem Details

Classify syntax, shape, domain, conflict, and unexpected failure while returning stable nonsecret error contracts.

About 5 min readContent reviewed
On this page

Lesson goals

  • Validate request DTOs before effects.
  • Distinguish MVC and Minimal API validation setup.
  • Describe structured errors without leaking internals.

Not every client error is the same

Malformed JSON, missing required input, an out-of-range quantity, an unauthorized action, and a concurrency conflict are different failures. Define which layer detects each and which status/body contract it returns. A DataAnnotations attribute can express a field rule, but cannot by itself prove current ownership, database uniqueness, or a transaction invariant.

Validate before committing effects and keep the previous valid state on rejection where the API promises that. Avoid using exceptions for every ordinary invalid value when a clear result fits. Do not catch every failure as 400, because that can hide server defects and mislead clients into retrying or editing input unnecessarily.

Test one DTO rule independently

The first program invokes DataAnnotations validation directly on a synthetic DTO. It rejects quantity 0 under a 1..10 rule and counts one validation error. It does not prove that MVC or Minimal API middleware invoked the rule for a public request. Testing the rule and testing framework integration are complementary pieces of evidence.

Choose annotation and cross-field rules deliberately. Nullable reference annotations express static intent; required presence may need a runtime rule or a presence-aware model. A missing numeric field can deserialize as zero, so zero and absence may need separate representations if their meanings differ.

csharp Validate a synthetic DTO directly
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

var input = new OrderInput { Quantity = 0 };
var errors = new List<ValidationResult>();
bool valid = Validator.TryValidateObject(input, new ValidationContext(input), errors, validateAllProperties: true);
Console.WriteLine($"valid={valid} errors={errors.Count}");

internal sealed class OrderInput
{
    [Range(1, 10)] public int Quantity { get; set; }
}
Expected output
valid=False errors=1

The field rule rejects 0 and produces one error; no HTTP binding or automatic 400 path is executed.

Automatic behavior depends on application setup

ApiController conventions can reject invalid ModelState before a controller action. Minimal API validation in .NET 10 has its own opt-in setup and filter/validation pipeline. Do not transfer one behavior to the other merely because both use a DTO. Versioned docs and registration determine what actually runs.

Endpoint filters, action filters, middleware, and application validators operate at different boundaries. A directly called service or action does not automatically execute them. Keep repeated validation purposeful rather than implementing inconsistent copies at every layer. Integration tests should prove the public rejection shape and that no unintended effect occurred.

A structured error object is an inspectable contract

ProblemDetails and ValidationProblemDetails can carry standard error fields and field-level information. The second program constructs validation details and inspects status/error keys without executing an HTTP result. A real API must configure its error-writing path and preserve a stable public schema across expected failures.

Use a safe trace or correlation identifier to connect public failures with controlled internal diagnostics. Do not put stack traces, tokens, connection strings, or raw SQL into details. A generic title can be stable while internal logs retain useful cause and context. Keep localization and machine-readable categories separate when clients branch on errors.

csharp Inspect structured validation details
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;

var problem = new ValidationProblemDetails(new Dictionary<string, string[]>
{
    ["quantity"] = new[] { "must be between 1 and 10" }
}) { Status = 400, Title = "Invalid order input" };
Console.WriteLine($"status={problem.Status} fields={problem.Errors.Count} title={problem.Title}");
Expected output
status=400 fields=1 title=Invalid order input

The object contains 400 and one field error; response negotiation/serialization is not exercised.

Verify both the response and the rejected effect

A validation test should check status/body, error category, relevant field names, and unchanged committed state. Include absent, null, malformed, boundary, oversize, cross-field, forbidden, and conflict cases as appropriate. A 200 with an error string or a 500 with internal details is not repaired merely by changing the visible message.

Keep error translation at the owner of the boundary, avoid duplicated logging, and preserve cancellation semantics. Unexpected failures need controlled diagnostics and an honest server-failure response when possible; expected conflicts need a documented reconciliation policy. Local object checks are useful but do not validate the whole error pipeline.

Common errors and fixes

A required field is assumed enforced by nullable syntax alone.

Why it happens
Static annotations were confused with runtime presence validation.
How to fix it
Define and test the actual boundary rule.
Prevent it next time
Test absent and explicit-null input separately.

Every exception becomes 400.

Why it happens
Unexpected server failures are mislabeled as client mistakes.
How to fix it
Translate documented categories at the proper boundary.
Prevent it next time
Test internal failure and cancellation separately.

Problem details expose internal SQL or secrets.

Why it happens
Diagnostic context crosses the public trust boundary.
How to fix it
Return safe categories/trace IDs and keep restricted logs.
Prevent it next time
Review serialization of error paths.

Check your understanding

1. Why should a request model distinguish absent quantity from explicit zero when the domain treats them differently?

Show worked solution

A simple int property can hold zero after an absent field is deserialized, losing presence information. Use a suitable nullable/presence-aware model or explicit required-member/binding contract, then validate zero under its domain rule. Test absent, null, zero, and valid values through the public integration path. Do not infer that successful object creation proves the client supplied every required value.

2. What must be checked besides a 400 status in a validation test?

Show worked solution

Verify the stable error schema/category, intended field errors, safe diagnostic exposure, and that the rejected operation did not commit unintended state. Include the same case through the relevant binding/filter pipeline, because direct Validator calls test only a rule. A status-only assertion can miss a wrong cause, leaked details, or an effect performed before rejection.

Key takeaways

  • Representation, domain, authorization, conflict, and server failure need distinct handling.
  • Automatic validation requires the correct framework setup.
  • Structured errors must remain useful, stable, and nonsecret.

Official references