The contract was defined first. The frontend was built first. By the time I got to the backend producer, I had a clear specification: a JSON shape that needed to be produced, a set of rule types that needed to be expressible, and an existing frontend that was already waiting to consume it.
If you’re joining the series here, Part 1 covers the why behind this pattern, and Part 2 covers the frontend consumer side.
The first backend implementation was straightforward: a custom builder that let you declare rules programmatically and serialize them to JSON. It worked. But it didn’t enforce anything. The recipe endpoint produced rules; submit-time validation was a separate, unsolved problem.
That’s when FluentValidation entered the picture.
The Problem FluentValidation Solved
FluentValidation is a .NET library for building strongly-typed validation rules. The key thing it offered wasn’t just validation logic — it was a structured, introspectable rule set (ok, I did reach beyond its public API, into its internals, to get what I needed, and changes to the library only broke my code once in several years). You define a validator with explicit rules. At submit time, you run it. If it fails, you get back a list of failures with property names and messages in a consistent format.
That consistent structure turned out to be the bridge. If the same rules that enforce correctness at submit time also describe what the frontend should pre-validate, then there’s a single source of truth. One set of rules. Two uses. Or maybe one use (validation) in two different places (backend and frontend).
One Validator, Two Jobs
The core insight is that a FluentValidation validator does double duty.
Job 1: Enforcement. When a command arrives via POST, the validator runs against it. Any failure produces a structured error response that the frontend can map back to form fields.
Job 2: Recipe production. When the frontend requests the recipe via GET, the same validator is introspected. A subset of its rules is extracted and serialized into the validationRules JSON the frontend already knows how to consume.
public class RecordInvoiceValidator : AbstractValidator
{
public RecordInvoiceValidator()
{
RuleFor(x => x.InvoiceDate)
.NotEmpty()
.WithMessage("Invoice date is required");
RuleFor(x => x.ReferenceNumber)
.NotEmpty()
.MaximumLength(50);
}
}
This same validator enforces rules at submit time and contributes to the recipe that tells the frontend Invoice.Date is required and Invoice.ReferenceNumber has a max length of 50.
Auto-Extracted vs. Manual Rules
Not every rule type can be automatically extracted from FluentValidation’s rule set. The extraction layer handles four rule types automatically:
| FluentValidation rule | Recipe rule |
|---|---|
NotEmpty() |
required: {} |
MinimumLength(n) |
limitedLength.min |
MaximumLength(n) |
limitedLength.max |
Matches(regex) |
pattern.regex |
Everything else — options, limitedTo, email, verification, conditional required variants — must be added manually in a GetValidationsForRecipe() method:
protected override void GetValidationsForRecipe(RulesBuilder rules)
{
rules.For("Invoice.TypeId")
.Options(invoiceTypes);
rules.For("Invoice.Amount")
.LimitedTo(new Condition("Invoice.Amount", ">", 0));
}
The split is deliberate. Auto-extraction handles the simple structural rules. Manual additions handle domain-specific metadata that FluentValidation has no native concept for.
The Rule Catalog
The recipe supports a small, well-defined set of rule types:
-
required— value must be present; unconditional, conditional, or “one of these fields” -
limitedLength— minimum and/or maximum character length -
limitedTo— numeric or date comparison constraints (>,>=,<,<=) -
pattern— regex format with optional human-readable reason -
options— key/value metadata for dropdowns and selects -
email— email format -
verification— endpoint the frontend calls to verify a value asynchronously
Each has a specific JSON shape the frontend parser already knows how to consume. Adding a new rule type means implementing both sides: backend serialization and frontend validator generation.
Conditional Required
Conditional required rules are the most expressive part of the recipe. Two variants:
required.when — the field is required when all listed conditions are true:
"CreditReason.PostingAccountId": {
"required": {
"when": [
{ "fieldName": "type", "operator": "=", "fieldValue": 2 },
{ "fieldName": "useAutomaticAccount", "operator": "=", "fieldValue": false }
]
}
}
required.either — at least one of a named group of fields must be present:
"Contact.FirstName": {
"required": { "either": ["firstName", "lastName"] }
}
Both are built using PropertyRulesBuilder on the backend and produce group-level validators on the frontend, as Part 2 described.
Sub-Validator Reuse
FluentValidation makes it straightforward to delegate specialized validation to reusable validators. A date might need to be checked against the current fiscal year. A code field might have a domain-specific format requirement. These become standalone validators that multiple command validators can reference:
RuleFor(x => x.EffectiveDate)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.WithMessage("Effective date is required")
.SetValidator(new FiscalPeriodDateValidator(fiscalYears));
This keeps each command validator focused on what makes that specific command valid, and delegates domain checks to reusable components. The recipe extraction works through sub-validators as well: if the sub-validator contributes auto-extractable rules, they flow through to the recipe automatically.
The Trade-Off Worth Knowing
FluentValidation is a library, and libraries change. When the team released a major version with API changes, the extraction layer needed updates. The validator logic itself didn’t change; the introspection code that reads the rule set did.
The tests caught it. Validator unit tests and recipe output tests both flagged the breakage immediately. We updated the extraction layer, the tests went green, and the change was contained.
The trade-off is real: using FluentValidation for both enforcement and extraction means depending on its internal structure to some degree. The payoff — one place to define rules that serve both purposes — has been worth it. But knowing the dependency exists matters, and having tests that cover the extraction output is what makes the trade-off safe.
What’s Next
Part 4 closes the series with testing: how to cover a pattern that spans two layers, and why the test suite ended up being as important as the pattern itself.
When I designed and implemented this pattern, the “one validator, two jobs” design wasn’t the starting point. It was arrived at by working outside-in: define the need, then the contract to satisfy it, build the consumer, then figure out the producer. FluentValidation became the right fit at exactly the moment when the enforcement and extraction problems converged into the same place. That sequence mattered. The design followed the need.





Leave a Reply