Validation — FluentValidation & Auto-Filters
Why a validation framework?
Section titled “Why a validation framework?”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.
Package structure
Section titled “Package structure”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
| Package | Role | Depends on |
|---|---|---|
Granit.Validation | GranitValidator<T>, auto-validation filter, OpenAPI enrichment, ServerValidatorRegistry | Granit.Http.ExceptionHandling, Granit.Localization |
Granit.Validation.Endpoints | Single-field & batch server-side validation REST endpoints | Granit.Validation, Granit.Http.ApiDocumentation |
Granit.Validation.Europe | France/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.
Writing validators
Section titled “Writing validators”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(); }}Automatic endpoint validation
Section titled “Automatic endpoint validation”Validation is applied automatically to all endpoints within a MapGranitGroup()
route group. No per-endpoint wiring is needed.
How it works
Section titled “How it works”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 automaticallygroup.MapPut("/{id}", UpdatePatient); // validated automaticallygroup.MapGet("/", ListPatients); // no body → skippedMapGranitGroup() applies a FluentValidationAutoEndpointFilter to all endpoints in
the group. For each request, the filter:
- Inspects all endpoint arguments
- Skips primitives, strings,
Guid,CancellationToken,HttpContext, enums - For each complex argument, resolves
IValidator<T>from DI - If a validator is found and validation fails → 422 Unprocessable Entity
- If no validator is registered → passes through silently
Validation response
Section titled “Validation response”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"] }}Opting out
Section titled “Opting out”To skip auto-validation on a specific endpoint, use SkipAutoValidationAttribute:
group.MapPost("/special", HandleSpecial) .WithMetadata(new SkipAutoValidationAttribute());OpenAPI schema enrichment
Section titled “OpenAPI schema enrichment”GranitValidationModule registers a FluentValidationSchemaTransformer that
automatically enriches OpenAPI schemas with validation constraints extracted from
your IValidator<T> implementations.
What gets exposed
Section titled “What gets exposed”| FluentValidation rule | OpenAPI 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 rules | Ignored (no static OpenAPI equivalent) |
Pattern hints
Section titled “Pattern hints”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:
| Key | en | fr |
|---|---|---|
Hints:Alpha2Code | 2 uppercase letters (e.g. FR, BE) | 2 lettres majuscules (ex. FR, BE) |
Hints:Alpha3Code | 3 uppercase letters (e.g. FRA, BEL) | 3 lettres majuscules (ex. FRA, BEL) |
Hints:NumericCode | 3 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.
Frontend benefit
Section titled “Frontend benefit”TypeScript code generators (openapi-typescript, orval) automatically include these constraints in generated types. The frontend can:
- Display
maxLengthon<input>elements - Validate client-side with the same rules before sending the request
- Use
x-granit-validatorextensions for custom validations (IBAN, E.164) - Use
x-granit-pattern-hintto show helpful format descriptions
International validators
Section titled “International validators”Extension methods on IRuleBuilder<T, string?> for common international formats:
| Category | Validator | Format | Error code |
|---|---|---|---|
| Party | .Email() | RFC 5321 practical subset | InvalidEmail |
| Party | .E164Phone() | + followed by 7-15 digits | InvalidE164Phone |
| 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 letters | InvalidIso3166Alpha2 |
| Locale | .Bcp47LanguageTag() | fr, fr-BE, zh-Hans-CN | InvalidBcp47LanguageTag |
All error codes are prefixed with Granit:Validation: (omitted in the table for brevity).
Custom error codes
Section titled “Custom error codes”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");Granit.Validation.Europe
Section titled “Granit.Validation.Europe”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(); }}Validator reference
Section titled “Validator reference”Personal identifiers:
| Validator | Country | Format | Check algorithm |
|---|---|---|---|
.BelgianNiss() | BE | 11 digits (NISS/INSZ/SSIN) | MOD 97 (pre-2000 and post-2000) |
.FrenchNir() | FR | 15 chars (NIR / Securite Sociale) | MOD 97, Corse support (2A/2B) |
.BelgianEid() | BE | 12 digits (eID card number) | MOD 97 check pair |
Company identifiers:
| Validator | Country | Format | Check algorithm |
|---|---|---|---|
.FrenchSiren() | FR | 9 digits | Luhn |
.FrenchSiret() | FR | 14 digits (SIREN + NIC) | Luhn over all 14 digits |
.BelgianBce() | BE | 10 digits (BCE/KBO) | MOD 97 check pair |
.FrenchNafCode() | FR | 4 digits + 1 letter (NAF/APE) | Format only |
Tax identifiers:
| Validator | Country | Format | Check algorithm |
|---|---|---|---|
.BelgianVat() | BE | BE + 10 digits | BCE algorithm |
.FrenchVat() | FR | FR + 2-digit key + 9-digit SIREN | Key = (12 + 3 * (SIREN % 97)) % 97 |
.EuropeanVat() | EU | Country prefix + national format | Dispatches per country (algorithmic for FR/BE, format for others) |
Payment identifiers:
| Validator | Country | Format | Check algorithm |
|---|---|---|---|
.FrenchRib() | FR | 23 chars (bank + branch + account + key) | 97 - (89*bank + 15*branch + 3*account) % 97 |
.BelgianAccountNumber() | BE | NNN-NNNNNNN-NN (legacy pre-IBAN) | first 10 digits % 97 |
Professional registries (healthcare):
| Validator | Country | Format | Check algorithm |
|---|---|---|---|
.FrenchRpps() | FR | 11 digits (RPPS) | Luhn |
.FrenchAdeli() | FR | 9 digits (ADELI) | Format + length |
.FrenchFiness() | FR | 9 digits (FINESS) | Luhn |
.BelgianInami() | BE | 11 digits (INAMI/RIZIV) | MOD 97 check pair |
Postal addresses:
| Validator | Country | Format | Notes |
|---|---|---|---|
.FrenchPostalCode() | FR | 5 digits (01000-99999) | Includes Corse (20xxx) and DOM-TOM (97xxx, 98xxx) |
.BelgianPostalCode() | BE | 4 digits (1000-9999) | — |
.FrenchInseeCode() | FR | 5 chars (dept + commune) | Supports 2A/2B (Corse), DOM 971-976 |
Server-side single-field validation
Section titled “Server-side single-field validation”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 endpointsapp.MapGranitValidation() .RequireGranitRateLimiting("validation"); // recommended for public formsThe endpoint group has no authentication by default — suitable for public forms
(registration, contact). Chain .RequireAuthorization() for authenticated applications:
app.MapGranitValidation() .RequireAuthorization() .RequireGranitRateLimiting("validation");Endpoints
Section titled “Endpoints”| Method | Route | Description |
|---|---|---|
POST | /validation/validate | Validate a single field |
POST | /validation/validate-batch | Validate up to 20 fields in one call |
GET | /validation/validators | List all registered validator error codes |
Single-field validation
Section titled “Single-field validation”// POST /validation/validate{ "errorCode": "Granit:Validation:InvalidIban", "value": "BE68539007547034" }// → { "errorCode": "Granit:Validation:InvalidIban", "status": "Valid" }Unknown error codes return 404 Not Found (RFC 7807).
Batch validation
Section titled “Batch validation”// 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.
How validators are discovered
Section titled “How validators are discovered”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 neededinternal sealed class AppServerValidatorContributor : IServerValidatorContributor{ public IEnumerable<IServerValidator> GetValidators() { yield return new DelegatingServerValidator( "Acme:Validation:InvalidLicenseKey", LicenseKeyAlgorithm.IsValid); }}Sensitive validators (PII protection)
Section titled “Sensitive validators (PII protection)”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 /validatorsonly lists non-sensitive validators for anonymous usersPOST /validateand/validate-batchreturn 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 sensitiveyield return new DelegatingServerValidator( "Acme:Validation:InvalidSocialSecurityNumber", SsnAlgorithm.IsValid, isSensitive: true); // hidden from anonymous callersThe following framework validators are marked as sensitive:
| Region | Sensitive validators |
|---|---|
| Core | Credit card |
| North America | SSN, SIN, EIN |
| Europe | NISS, NIR, eID, BSN, Codice Fiscale, NIF, NIE, Matricule, Steuer-ID |
| United Kingdom | NI Number, NHS Number, UTR |
Anti-DDoS protection
Section titled “Anti-DDoS protection”The package has no dependency on Granit.RateLimiting or Granit.Http.Bulkhead.
Protection is applied by the application via the returned RouteGroupBuilder:
| Layer | Mechanism | Applied by |
|---|---|---|
| Rate limiting | RequireGranitRateLimiting() | Application |
| Input constraints | ErrorCode ≤ 128 chars, Value ≤ 500 chars, batch ≤ 20 | Package (FluentValidation) |
| Bulkhead | RequireGranitBulkhead() | Application |
| WAF / CDN | CloudFlare, nginx L7 | Infrastructure |
Architecture tests
Section titled “Architecture tests”Two architecture tests in Granit.ArchitectureTests enforce validation conventions:
Request_types_in_Endpoints_should_have_validators— every*Requesttype in.Endpointsassemblies must have a correspondingIValidator<T>implementationTop_level_route_groups_should_use_MapGranitGroup— top-level route groups must useMapGranitGroup()instead ofMapGroup()to ensure auto-validation is active
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Modules | GranitValidationModule, GranitValidationEuropeModule | — |
| Auto-validation | MapGranitGroup(), FluentValidationAutoEndpointFilter, SkipAutoValidationAttribute | Granit.Validation |
| OpenAPI | FluentValidationSchemaTransformer | Granit.Validation |
| Validation | GranitValidator<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 validation | IServerValidator, DelegatingServerValidator, IServerValidatorContributor, ServerValidatorRegistry | Granit.Validation |
| Server validation endpoints | MapGranitValidation() | Granit.Validation.Endpoints |
| Extensions | AddGranitValidation() | — |
When to use — and when not to
Section titled “When to use — and when not to”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
Common pitfalls
Section titled “Common pitfalls”Testing validators
Section titled “Testing validators”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", 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.
See also
Section titled “See also”- Add an endpoint guide — includes validation setup walkthrough
- ADR-006: FluentValidation — Why FluentValidation was chosen over DataAnnotations
- Exception Handling —
IExceptionStatusCodeMapperfor validation exceptions - Core module — Exception hierarchy,
IHasValidationErrors - Persistence module —
AuditedEntityInterceptorusesIClockfor timestamps