Elarion

Validation

Two-tier request validation — DataAnnotations on the request DTO enforced at runtime and exported to every contract surface, business rules in the handler.

Validation in Elarion is two-tier, and the dividing line is not "simple vs. complex" but what a wire contract can express. JSON Schema — and therefore the JSON-RPC schema, the OpenAPI document, MCP tool schemas, and the Zod client generated from them — can carry exactly one category of rule: static, single-property shape constraints (lengths, ranges, patterns, formats, requiredness). Everything else is server-side by nature.

  • Tier 1 — shape constraints are declared on the request DTO as standard System.ComponentModel.DataAnnotations attributes. One declaration feeds four surfaces: the runtime validator, rpc-schema.json (and MCP tool schemas), the OpenAPI document, and the generated Zod client.
  • Tier 2 — business rules — cross-field comparisons, conditional rules, and every async or database-backed check — live in the handler (or a domain [Service] it calls), returning AppError.Validation/AppError.Conflict through the normal Result<T> channel, inside the transaction where those checks are actually sound.

There is no standalone validator class layer: an exportable rule has exactly one legal home (the DTO), so what the client pre-validates is by construction what the server enforces.

Tier 1 — declare shape constraints on the request

Annotate the request's properties. Requiredness needs no attribute at all — it comes from nullability (ADR-0082): a non-nullable property is required, a nullable one is optional, and a request property with a default value is optional to send. The required modifier is still useful — the compiler enforces it at construction sites — but it no longer changes the exported schema:

src/Billing.Application/Modules/Clients/Handlers/CreateClient.cs
using System.ComponentModel.DataAnnotations;

[Handler("clients.create")]
public sealed class CreateClient(BillingDbContext db, ICurrentUser user)
    : IHandler<CreateClient.Command, Result<CreateClient.Response>> {
    public sealed record Command : ICommand {
        [Required, StringLength(200)]   // not blank: rejects "" and whitespace-only names
        public required string Name { get; init; }

        [EmailAddress, MaxLength(320)]
        public required string Email { get; init; }

        [Range(0, 365)]
        public int PaymentTermDays { get; init; } = 30;
    }
    // ...
}

The validator walks the whole request graph, so constraints on nested objects and collection elements are enforced (and reported) too. The supported vocabulary is the set every schema surface can carry:

AttributeEnforced asExported as (JSON Schema)
non-nullable (no default)member must be present (the canonical serializer rejects null and a missing constructor parameter)required
nullable, or a request member with a defaultmember may be omittedoptional
[Range(min, max)]numeric boundsminimum / maximum (exclusiveMinimum/exclusiveMaximum for exclusive bounds)
[MinLength] / [MaxLength] / [Length] / [StringLength]string/collection lengthminLength / maxLength (minItems/maxItems on arrays)
[RegularExpression]the whole value matchespattern, anchored as ^(?:…)$ unless already anchored
[Required] on a stringnot empty and not whitespace-onlya not-blank lookahead on pattern
[EmailAddress]email shapeformat: "email"
[Url]absolute URLformat: "uri"
[Base64String]base64 contentformat: "byte"
[AllowedValues(...)]value ∈ the declared setenum

[AllowedValues] pairs naturally with the generated variant registry: declare a switch's vocabulary once ([AllowedValues(ElarionVariants.EmailBackend.Smtp, ElarionVariants.EmailBackend.Office365)]) and the 400, the schema enum, the OpenAPI document, and the TypeScript client's z.enum(...) all follow — see Feature flags.

Not blank, and whole-value patterns

[StringLength(200, MinimumLength = 1)] admits " ". The DataAnnotations rule for "not blank" is [Required]: on a string it rejects "" and whitespace-only values. Elarion does not use it for requiredness (nullability is the source), but it exports it as what it enforces — a not-blank pattern:

"name": { "type": "string", "maxLength": 200, "pattern": "^(?=[\\s\\S]*\\S)(?:[\\s\\S]*)$" }

[Required(AllowEmptyStrings = true)] and [Required] on a non-string add nothing to the schema.

The server's RegularExpressionAttribute requires the whole value to match, while JSON Schema — and every client validator built on it, the generated Zod .regex(...) included — only searches for a match. A pattern is therefore exported anchored: [RegularExpression("[a-z]+")] becomes "pattern": "^(?:[a-z]+)$", so the client rejects "abc1" just as the server does. A pattern that already anchors both ends (^…$ without a top-level |) is exported unchanged. A member with both [Required] and [RegularExpression] exports one combined pattern (^(?=[\s\S]*\S)(?:…)$); the regular expression's explicit message wins the shared keyword. The OpenAPI document keeps ASP.NET's own mapping: the pattern as declared and [Required] as a required member.

Both rewrites apply to request schemas, which describe what the server validates. Result and event schemas keep the pattern as declared and get no not-blank lookahead: the server never validates what it sends, but the generated client parses it, and a stored value from before a constraint must not fail a read. Two edges differ between the anchored pattern and the server: RegularExpressionAttribute accepts an empty string (the client rejects it), and it checks only the first match, so a|ab rejects "ab" on the server while ^(?:a|ab)$ accepts it.

Custom constraints

A reusable rule subclasses one of the mapped attributes, so it gets runtime enforcement and every schema surface for free — no registration, no exporter extension:

src/Billing.Application/Validation/SlugAttribute.cs
using System.ComponentModel.DataAnnotations;

/// <summary>Lowercase words separated by single hyphens, e.g. "spring-sale".</summary>
public sealed class SlugAttribute() : RegularExpressionAttribute("^[a-z0-9]+(-[a-z0-9]+)*$");
public sealed record Command : ICommand {
    [Slug, MaxLength(64)]
    public required string Slug { get; init; }
}

Because [Slug] is a RegularExpressionAttribute, the runtime validator enforces it and every schema exports it as a pattern — the subclass changes nothing about the plumbing.

Error messages

An attribute's explicit message travels with its constraint. ErrorMessage (or a resource message via ErrorMessageResourceType/ErrorMessageResourceName) is exported under the vendor keyword x-elarion-error-messages, keyed by the JSON Schema keyword the attribute produced:

public sealed record Command : ICommand {
    [RegularExpression("^[A-Z]{3}$", ErrorMessage = "Code must be three capital letters.")]
    [StringLength(20, ErrorMessage = "{0} must be at most {1} characters.")]
    public required string Code { get; init; }
}
"code": {
  "type": "string",
  "maxLength": 20,
  "pattern": "^[A-Z]{3}$",
  "x-elarion-error-messages": {
    "pattern": "Code must be three capital letters.",
    "maxLength": "Code must be at most 20 characters."
  }
}

The exporter formats the message exactly as the server's validator does (FormatErrorMessage with the [Display(Name)] or the member name, so {0}/{1}/{2} placeholders are filled in), so the generated client reports the same text before the round trip that the server would after it. An attribute that produces two keywords ([StringLength] with a minimum, [Length], [Range]) carries its message on both. Keywords stay last-wins: a later attribute without a message for the same keyword drops the earlier message.

  • Only explicit messages are exported. An attribute relying on the framework's default text adds nothing; the client then shows its own validation library's wording.
  • Resource messages are resolved at export time, in the exporting process's UI culture, so the schema carries one language. When the server localizes per request, the client's pre-flight text is that one language; the server's response still follows the request culture.

Enforcement

Reference the opt-in Elarion.Validation package and register it once in the host:

src/Billing.Api/Program.cs
builder.Services.AddElarionValidation();

AddElarionValidation([configure]) wires the Microsoft.Extensions.Validation-backed default behind the Elarion-owned IRequestValidator seam (Elarion.Abstractions.Validation) and also calls AddElarionJson(). Everything else is generated: a per-module resolver contributes each request type's validation metadata as constant-constructed attribute arrays (no runtime attribute reflection), registered through the module's gated ConfigureDefaultServices — a disabled module contributes no validation metadata.

The framework-owned ValidationDecorator<TRequest, TResponse> (Elarion.Pipeline) is auto-attached by the handler generator just inside the feature gate — the pipeline runs observability → audit → authorization → feature gate → validation → cache invalidation → resilience → idempotency → your [DefaultPipeline] decorators → handler (the full order is in Decorator pipelines) — for any handler whose request type graph carries validation attributes. Two consequences:

  • Validation runs pre-transaction. A bad request is rejected before caching, your pipeline, or a unit of work is ever touched.
  • Unannotated requests cost nothing. No decorator is attached, no resolver entry exists, no lookup happens.

Attaching is opt-in by construction, exactly like authorization and feature gates: the attributes on your DTO are the switch. There is nothing to add to a [DecoratorList].

On .NET 11

Behavior is the same on both target frameworks; the plumbing differs. Microsoft.Extensions.Validation 11 no longer exposes the walker that runs the attributes (its own generator compiles that walker into each application), so the net11.0 build of Elarion.Validation carries the same walker, and the generated resolvers bind to it. The .NET 11 walker also runs AsyncValidationAttributes (after every synchronous attribute of the member passed) and IAsyncValidatableObject. Both still run in the validation decorator, before the transaction — see the next section for what that rules out.

A property typed as a C# 15 union is validated as its active case: the attributes of the case type apply, and errors use the property's path (result.name, not result.value.name), matching the JSON, where a union is written as its active case.

Field-keyed errors

A violation fails with AppError.Validation carrying FieldErrors — messages keyed by wire-named field path using the canonical JSON naming policy, so error keys match the property names the client actually sent: "address.street", with collection indexers preserved as "deliveries[1].street". The empty-string key carries messages not specific to a single field.

Over HTTP the mapper surfaces them as the standard RFC 7807 errors extension (the HttpValidationProblemDetails shape):

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "detail": "Name is required",
  "errors": {
    "name": ["The Name field is required."],
    "deliveries[1].street": ["The Street field is required."]
  }
}

Over JSON-RPC the same structure rides in error.data.data under the stable code validation (numeric code -32602, the spec's "Invalid params"):

{
  "error": {
    "code": -32602,
    "message": "The Name field is required.",
    "data": {
      "code": "validation",
      "data": {
        "errors": ["The Name field is required.", "The Street field is required."],
        "fieldErrors": {
          "name": ["The Name field is required."],
          "deliveries[1].street": ["The Street field is required."]
        }
      }
    }
  }
}

Because a client-side Zod pre-flight failure and a server 400 key violations by the same wire paths, a form binds either one to the same input field: the generated client's RpcParamsValidationError exposes fieldErrors in exactly this notation (see Params validation).

One attribute, four surfaces

The point of tier 1 is that a constraint cannot drift from the contract — every surface reads the same declaration:

SurfaceHow the constraint gets there
Runtime enforcementThe generated per-module resolver + the auto-attached ValidationDecorator.
JSON-RPC schema & MCP toolsJsonRpcSchemaExporter injects the JSON Schema keywords and explicit messages (x-elarion-error-messages); MCP tool input schemas share the same builder.
OpenAPI documentMicrosoft.AspNetCore.OpenApi maps DataAnnotations natively (reflection-off); Elarion.AspNetCore.OpenApi adds format: "email" parity.
Generated Zod clientThe Zod emitter maps the keywords (.min(), .max(), .regex(), .gte(), .email(), …) with their explicit messages, and the client pre-validates params before the wire.

Tier 2 — business rules in the handler

Cross-field comparisons, conditional rules, and anything that touches the database belong in the handler, where they run inside the transaction. An async pre-handler check would be unsound anyway: the validation decorator runs before the transaction opens, so a pre-flight uniqueness query is a time-of-check/time-of-use race — the database constraint is the real fence, and the handler must handle the conflict regardless.

.NET 11 adds AsyncValidationAttribute and IAsyncValidatableObject, and Microsoft's documentation shows a uniqueness check as the example. On an Elarion request DTO they run in the validation decorator, before the transaction — the race described above, which ELVAL003 reports at build time. Keep database and service lookups in the handler.

public async ValueTask<Result<Response>> HandleAsync(Command command, CancellationToken ct) {
    // Cross-field rule: field-keyed so the client can bind it to the input.
    if (command.ValidFrom > command.ValidTo) {
        return AppError.Validation("Invalid validity range.", new Dictionary<string, string[]> {
            ["validTo"] = ["Must be on or after validFrom."],
        });
    }

    // Database-backed rule: checked inside the transaction, backed by a unique constraint.
    var exists = await db.Clients.AnyAsync(c => c.OwnerId == user.UserId && c.Email == command.Email, ct);
    if (exists) {
        return AppError.Conflict($"A client with email {command.Email} already exists.");
    }

    // ...
}

The AppError.Validation(message, fieldErrors) overload produces the same field-keyed error shape as tier 1, so tier-2 failures render identically on every transport. A rule shared by several handlers moves to a domain [Service] those handlers call — not to a validator layer.

What stays server-only

Be honest about the boundary: the client pre-validates shape, never truth. These never reach a schema, by design:

  • Uniqueness and existence checks — only the database knows, and only inside the transaction.
  • Cross-field rules — the schema subset the surfaces share cannot express "ValidTo ≥ ValidFrom"; the client discovers them as field-keyed 400s.
  • Conditional requiredness — "B is required when A is set" is a tier-2 branch.
  • Anything reading ICurrentUser, settings, or feature flags — ambient state is not contract.

Nothing pretends a uniqueness check can run in a browser; the contract simply guarantees that what can be expressed is expressed everywhere.

Diagnostics

IdSeverityMeaning
ELVAL001ErrorA handler's request carries validation attributes but the response type cannot represent failure (no IResultFailureFactory<T>, i.e. not Result<T>/Result) — the decorator could not short-circuit and would be silently skipped. Mirrors ELAUTH001.
ELVAL002WarningA request carries validation attributes but the compilation does not reference Elarion.Validation — the constraints are documented in every schema but unenforced at runtime. Reference the package and call AddElarionValidation(), or remove the attributes; the gap must be a visible choice, never a silent one.
ELVAL003Warning(.NET 11) The request graph has an AsyncValidationAttribute or an IAsyncValidatableObject. It is enforced, but it runs in the validation decorator before the transaction, so a database or service lookup there is a time-of-check/time-of-use race. Move the check into the handler and return AppError.Validation/AppError.Conflict, or suppress the warning when the check reads no shared state.

Coming from FluentValidation

Elarion's earlier FluentValidation integration ([GenerateModuleValidators] and per-module IValidator<T> registration) is removed: imperative rule lambdas are invisible to every contract surface, which guaranteed exportable rules hiding in unexportable places. Migrate shape rules to attributes and business rules into the handler. If you want FluentValidation for its combinator style, wire it as an app-owned decorator — which is all it ever was.

See also

On this page