Skip to content

Validation — FluentValidation & Auto-Filters

Input validation is the first line of defense against bad data, but it is also one of the most repetitive tasks in API development. Without a shared approach, teams reinvent IBAN checks, phone number formats, and national ID validation in every project — with inconsistent error messages and no structured error codes for frontend consumption.

Granit.Validation provides a single place for validation logic: validators are auto-discovered, and validation is applied automatically to all Minimal API endpoints via MapGranitGroup(). When validation fails, the filter returns RFC 7807 problem details with stable, machine-readable error codes. Validation constraints are also exposed in the OpenAPI schema so frontend code generators can enforce the same rules client-side.

  • DirectoryGranit.Validation/ GranitValidator, auto-validation filter, OpenAPI enrichment, server validation registry
    • Granit.Validation.Endpoints Single-field & batch server-side validation endpoints
    • Granit.Validation.Europe France/Belgium regulatory validators
    • Granit.Validation.NorthAmerica US/Canada validators
    • Granit.Validation.UnitedKingdom UK validators
PackageRoleDepends on
Granit.ValidationGranitValidator<T>, auto-validation filter, OpenAPI enrichment, ServerValidatorRegistryGranit.Http.ExceptionHandling, Granit.Localization
Granit.Validation.EndpointsSingle-field & batch server-side validation REST endpointsGranit.Validation, Granit.Http.ApiDocumentation
Granit.Validation.EuropeFrance/Belgium-specific validators (NISS, NIR, SIREN, VAT)Granit.Validation, Granit.Localization
[DependsOn(typeof(GranitValidationModule))]
public class AppModule : GranitModule { }

The module auto-discovers all IValidator<T> implementations from loaded module assemblies. No manual registration is needed.

public record CreatePatientRequest(
string FirstName,
string LastName,
string Email,
string Phone,
string CountryCode,
string Iban);
public class CreatePatientRequestValidator : GranitValidator<CreatePatientRequest>
{
public CreatePatientRequestValidator()
{
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
RuleFor(x => x.Email).Email();
RuleFor(x => x.Phone).E164Phone();
RuleFor(x => x.CountryCode).Iso3166Alpha2CountryCode();
RuleFor(x => x.Iban).Iban();
}
}

Validation is applied automatically to all endpoints within a MapGranitGroup() route group. No per-endpoint wiring is needed.

Use MapGranitGroup() instead of MapGroup() when creating route groups:

RouteGroupBuilder group = endpoints
.MapGranitGroup(options.RoutePrefix) // ← auto-validation included
.RequireAuthorization()
.WithTags("Patients");
group.MapPost("/", CreatePatient); // validated automatically
group.MapPut("/{id}", UpdatePatient); // validated automatically
group.MapGet("/", ListPatients); // no body → skipped

MapGranitGroup() applies a FluentValidationAutoEndpointFilter to all endpoints in the group. For each request, the filter:

  1. Inspects all endpoint arguments
  2. Skips primitives, strings, Guid, CancellationToken, HttpContext, enums
  3. For each complex argument, resolves IValidator<T> from DI
  4. If a validator is found and validation fails → 422 Unprocessable Entity
  5. If no validator is registered → passes through silently

When validation fails, the filter returns 422 Unprocessable Entity with a HttpValidationProblemDetails body:

{
"status": 422,
"errors": {
"Email": ["Granit:Validation:InvalidEmail"],
"Phone": ["Granit:Validation:InvalidE164Phone"],
"Iban": ["Granit:Validation:InvalidIban"]
}
}

To skip auto-validation on a specific endpoint, use SkipAutoValidationAttribute:

group.MapPost("/special", HandleSpecial)
.WithMetadata(new SkipAutoValidationAttribute());

GranitValidationModule registers a FluentValidationSchemaTransformer that automatically enriches OpenAPI schemas with validation constraints extracted from your IValidator<T> implementations.

FluentValidation ruleOpenAPI property
NotEmpty() / NotNull()required
MaximumLength(n)maxLength: n
MinimumLength(n)minLength: n
Length(min, max)minLength + maxLength
GreaterThanOrEqualTo(n)minimum: n
GreaterThan(n)exclusiveMinimum: n
LessThanOrEqualTo(n)maximum: n
LessThan(n)exclusiveMaximum: n
InclusiveBetween(a, b)minimum + maximum
Matches(regex)pattern: regex
Matches(regex).WithPatternHint(key)pattern + x-granit-pattern-hint: key
EmailAddress()format: "email"
Custom (IBAN, E.164…)x-granit-validator: "Granit:Validation:InvalidIban"
MustAsync() / async rulesIgnored (no static OpenAPI equivalent)

When a Matches(regex) rule is used, the frontend only sees the raw regex pattern — not helpful for end users. Chain .WithPatternHint() to provide an i18n key that the frontend can resolve to a human-readable hint:

RuleFor(x => x.CountryCode)
.Matches(@"^[A-Z]{2}$")
.WithPatternHint("Granit:Validation:Hints:Alpha2Code");

The transformer emits both pattern and x-granit-pattern-hint in the OpenAPI schema:

{
"pattern": "^[A-Z]{2}$",
"x-granit-pattern-hint": "Granit:Validation:Hints:Alpha2Code"
}

The frontend uses the hint key to display a proactive helper text (e.g. “2 uppercase letters (e.g. FR, BE)”) and enriches the error message when validation fails.

Built-in hint keys:

Keyenfr
Hints:Alpha2Code2 uppercase letters (e.g. FR, BE)2 lettres majuscules (ex. FR, BE)
Hints:Alpha3Code3 uppercase letters (e.g. FRA, BEL)3 lettres majuscules (ex. FRA, BEL)
Hints:NumericCode3 digits (e.g. 250, 056)3 chiffres (ex. 250, 056)

All hint keys are prefixed with Granit:Validation: (omitted in the table). Add custom hints in Localization/Validation/*.json files.

TypeScript code generators (openapi-typescript, orval) automatically include these constraints in generated types. The frontend can:

  • Display maxLength on <input> elements
  • Validate client-side with the same rules before sending the request
  • Use x-granit-validator extensions for custom validations (IBAN, E.164)
  • Use x-granit-pattern-hint to show helpful format descriptions

Extension methods on IRuleBuilder<T, string?> for common international formats:

CategoryValidatorFormatError code
Party.Email()RFC 5321 practical subsetInvalidEmail
Party.E164Phone()+ followed by 7-15 digitsInvalidE164Phone
Payment.Iban()ISO 13616 (MOD-97 check)InvalidIban
Payment.BicSwift()ISO 9362 (8 or 11 chars)InvalidBicSwift
Payment.SepaCreditorIdentifier()EPC262-08 (MOD 97-10)InvalidSepaCreditorIdentifier
Locale.Iso3166Alpha2CountryCode()2 uppercase lettersInvalidIso3166Alpha2
Locale.Bcp47LanguageTag()fr, fr-BE, zh-Hans-CNInvalidBcp47LanguageTag

All error codes are prefixed with Granit:Validation: (omitted in the table for brevity).

Use .WithErrorCodeAndMessage() to set both error code and message to the same value, preventing them from silently diverging:

RuleFor(x => x.AppointmentDate)
.GreaterThan(DateTimeOffset.UtcNow)
.WithErrorCodeAndMessage("Appointments:DateMustBeFuture");

EU-specific regulatory validators for France and Belgium. Separate package to avoid pulling regulatory dependencies into applications that do not need them.

[DependsOn(typeof(GranitValidationEuropeModule))]
public class AppModule : GranitModule { }
public class RegisterDoctorRequestValidator : GranitValidator<RegisterDoctorRequest>
{
public RegisterDoctorRequestValidator()
{
RuleFor(x => x.Niss).BelgianNiss();
RuleFor(x => x.InamiNumber).BelgianInami();
RuleFor(x => x.Vat).EuropeanVat();
RuleFor(x => x.PostalCode).BelgianPostalCode();
}
}
public class RegisterClinicRequestValidator : GranitValidator<RegisterClinicRequest>
{
public RegisterClinicRequestValidator()
{
RuleFor(x => x.Siret).FrenchSiret();
RuleFor(x => x.Vat).FrenchVat();
RuleFor(x => x.Finess).FrenchFiness();
RuleFor(x => x.PostalCode).FrenchPostalCode();
}
}

Personal identifiers:

ValidatorCountryFormatCheck algorithm
.BelgianNiss()BE11 digits (NISS/INSZ/SSIN)MOD 97 (pre-2000 and post-2000)
.FrenchNir()FR15 chars (NIR / Securite Sociale)MOD 97, Corse support (2A/2B)
.BelgianEid()BE12 digits (eID card number)MOD 97 check pair

Company identifiers:

ValidatorCountryFormatCheck algorithm
.FrenchSiren()FR9 digitsLuhn
.FrenchSiret()FR14 digits (SIREN + NIC)Luhn over all 14 digits
.BelgianBce()BE10 digits (BCE/KBO)MOD 97 check pair
.FrenchNafCode()FR4 digits + 1 letter (NAF/APE)Format only

Tax identifiers:

ValidatorCountryFormatCheck algorithm
.BelgianVat()BEBE + 10 digitsBCE algorithm
.FrenchVat()FRFR + 2-digit key + 9-digit SIRENKey = (12 + 3 * (SIREN % 97)) % 97
.EuropeanVat()EUCountry prefix + national formatDispatches per country (algorithmic for FR/BE, format for others)

Payment identifiers:

ValidatorCountryFormatCheck algorithm
.FrenchRib()FR23 chars (bank + branch + account + key)97 - (89*bank + 15*branch + 3*account) % 97
.BelgianAccountNumber()BENNN-NNNNNNN-NN (legacy pre-IBAN)first 10 digits % 97

Professional registries (healthcare):

ValidatorCountryFormatCheck algorithm
.FrenchRpps()FR11 digits (RPPS)Luhn
.FrenchAdeli()FR9 digits (ADELI)Format + length
.FrenchFiness()FR9 digits (FINESS)Luhn
.BelgianInami()BE11 digits (INAMI/RIZIV)MOD 97 check pair

Postal addresses:

ValidatorCountryFormatNotes
.FrenchPostalCode()FR5 digits (01000-99999)Includes Corse (20xxx) and DOM-TOM (97xxx, 98xxx)
.BelgianPostalCode()BE4 digits (1000-9999)
.FrenchInseeCode()FR5 chars (dept + commune)Supports 2A/2B (Corse), DOM 971-976

Some validation rules (IBAN checksum, SIREN Luhn, European VAT) cannot be translated into standard OpenAPI constraints. They appear in the spec as x-granit-validator extensions. Until now, these were only validated at form submit (422 response).

Granit.Validation.Endpoints adds REST endpoints for real-time field validation (debounced) without submitting the entire form.

// Program.cs — map the validation endpoints
app.MapGranitValidation()
.RequireGranitRateLimiting("validation"); // recommended for public forms

The endpoint group has no authentication by default — suitable for public forms (registration, contact). Chain .RequireAuthorization() for authenticated applications:

app.MapGranitValidation()
.RequireAuthorization()
.RequireGranitRateLimiting("validation");
MethodRouteDescription
POST/validation/validateValidate a single field
POST/validation/validate-batchValidate up to 20 fields in one call
GET/validation/validatorsList all registered validator error codes
// POST /validation/validate
{ "errorCode": "Granit:Validation:InvalidIban", "value": "BE68539007547034" }
// → { "errorCode": "Granit:Validation:InvalidIban", "status": "Valid" }

Unknown error codes return 404 Not Found (RFC 7807).

// POST /validation/validate-batch
{
"fields": [
{ "errorCode": "Granit:Validation:InvalidIban", "value": "BE68539007547034" },
{ "errorCode": "Granit:Validation:InvalidEmail", "value": "bad" },
{ "errorCode": "Unknown:Code", "value": "x" }
]
}
// → { "results": [
// { "errorCode": "Granit:Validation:InvalidIban", "status": "Valid" },
// { "errorCode": "Granit:Validation:InvalidEmail", "status": "Invalid" },
// { "errorCode": "Unknown:Code", "status": "ValidatorNotFound" }
// ] }

Unknown error codes return ValidatorNotFound status instead of failing the batch.

Framework packages (Granit.Validation, .Europe, .NorthAmerica, .UnitedKingdom) register their validators via IServerValidatorContributor. Application projects can add custom validators the same way — they are auto-discovered from all module assemblies:

// In your application module assembly — auto-discovered, no registration needed
internal sealed class AppServerValidatorContributor : IServerValidatorContributor
{
public IEnumerable<IServerValidator> GetValidators()
{
yield return new DelegatingServerValidator(
"Acme:Validation:InvalidLicenseKey",
LicenseKeyAlgorithm.IsValid);
}
}

Validators that handle personally identifiable information (SSN, national IDs, credit cards, tax references) are marked as isSensitive: true. These validators are hidden from unauthenticated callers:

  • GET /validators only lists non-sensitive validators for anonymous users
  • POST /validate and /validate-batch return 404 Not Found for sensitive validators when the caller is unauthenticated — as if the validator does not exist

Authenticated users see and use all validators normally.

// Marking a custom PII validator as sensitive
yield return new DelegatingServerValidator(
"Acme:Validation:InvalidSocialSecurityNumber",
SsnAlgorithm.IsValid,
isSensitive: true); // hidden from anonymous callers

The following framework validators are marked as sensitive:

RegionSensitive validators
CoreCredit card
North AmericaSSN, SIN, EIN
EuropeNISS, NIR, eID, BSN, Codice Fiscale, NIF, NIE, Matricule, Steuer-ID
United KingdomNI Number, NHS Number, UTR

The package has no dependency on Granit.RateLimiting or Granit.Http.Bulkhead. Protection is applied by the application via the returned RouteGroupBuilder:

LayerMechanismApplied by
Rate limitingRequireGranitRateLimiting()Application
Input constraintsErrorCode ≤ 128 chars, Value ≤ 500 chars, batch ≤ 20Package (FluentValidation)
BulkheadRequireGranitBulkhead()Application
WAF / CDNCloudFlare, nginx L7Infrastructure

Two architecture tests in Granit.ArchitectureTests enforce validation conventions:

  1. Request_types_in_Endpoints_should_have_validators — every *Request type in .Endpoints assemblies must have a corresponding IValidator<T> implementation
  2. Top_level_route_groups_should_use_MapGranitGroup — top-level route groups must use MapGranitGroup() instead of MapGroup() to ensure auto-validation is active
CategoryKey typesPackage
ModulesGranitValidationModule, GranitValidationEuropeModule
Auto-validationMapGranitGroup(), FluentValidationAutoEndpointFilter, SkipAutoValidationAttributeGranit.Validation
OpenAPIFluentValidationSchemaTransformerGranit.Validation
ValidationGranitValidator<T>, .WithErrorCodeAndMessage()Granit.Validation
International validators.Email(), .E164Phone(), .Iban(), .BicSwift(), .SepaCreditorIdentifier(), .Iso3166Alpha2CountryCode(), .Bcp47LanguageTag()Granit.Validation
European validators.BelgianNiss(), .FrenchNir(), .BelgianEid(), .FrenchSiren(), .FrenchSiret(), .BelgianBce(), .FrenchNafCode(), .BelgianVat(), .FrenchVat(), .EuropeanVat(), .FrenchRib(), .BelgianAccountNumber(), .FrenchRpps(), .FrenchAdeli(), .FrenchFiness(), .BelgianInami(), .FrenchPostalCode(), .BelgianPostalCode(), .FrenchInseeCode()Granit.Validation.Europe
Server validationIServerValidator, DelegatingServerValidator, IServerValidatorContributor, ServerValidatorRegistryGranit.Validation
Server validation endpointsMapGranitValidation()Granit.Validation.Endpoints
ExtensionsAddGranitValidation()

Use Granit.Validation when:

  • You need structured error codes that frontends can map to localized messages
  • Validation rules are complex (cross-field, conditional, async database lookups)
  • You validate international formats (IBAN, NISS, VAT, E.164) that require checksum algorithms
  • You want validation wired into Minimal API endpoints or Wolverine handlers automatically

Skip it when:

  • You only need simple required/length checks — [Required] and [MaxLength] DataAnnotations on a record are faster to write for trivial cases
  • The validation is domain invariant logic (e.g., “order total must be positive”) — this belongs in the entity/value object constructor, not in a FluentValidation rule

Test validators in isolation — no DI container needed:

[Fact]
public void Should_reject_invalid_IBAN()
{
// Arrange
var validator = new CreatePatientRequestValidator();
var request = new CreatePatientRequest(
FirstName: "Jane", LastName: "Doe",
Email: "[email protected]", Phone: "+32470123456",
CountryCode: "BE", Iban: "INVALID");
// Act
var result = validator.Validate(request);
// Assert
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.PropertyName == "Iban");
}

For async validators that query the database, inject a mock DbContext or use InlineValidator<T> to test the rule logic in isolation.