The frontend form has no business knowledge. No hardcoded rules. No conditional logic for what makes a date valid or a reference number acceptable. It receives a JSON description from the backend and wires itself up accordingly.

That simplicity took some deliberate design to reach. This is the consumer side of the recipe pattern. If you missed the first post in this series, it covers the why: the problem of duplication, drift, and what happens when frontend state goes stale.

Getting the Recipe

Before the form renders, the frontend makes a GET request to a recipe endpoint. The endpoint returns the validation rules alongside any data the form needs. For a new record, that means defaults and dropdown options. For an edit scenario, it means the existing entity values plus any fields that are now read-only.

The response looks something like this:

{
  "succeeded": true,
  "data": {
    "entity": { ... },
    "validationRules": {
	"fields": {
  	  "JournalEntry.EffectiveDate": {
	    "required": {},
		"limitedTo": [
		{
	  	  "fieldName": "JournalEntry.EffectiveDate",
		  "operator": ">=",
		  "fieldValue": "2026-06-01"
		},
		{
		  "fieldName": "JournalEntry.EffectiveDate",
		  "operator": "<=",
		  "fieldValue": "2026-12-31"
		}]
		},
		"JournalEntry.Subject": {
		  "required": {},
		  "limitedLength": {
		    "min": 1,
		    "max": 250
	          }
	  }
	}
    }
  }
}

That is the recipe that helps the user accomplish a task.

recipe-required-fields-and-lengthy.png

Parsing: Translating Names

The first thing the frontend does with the recipe is translate field names. The backend uses dotted property paths tied to its command structure: Invoice.ReferenceNumber. The Angular form model uses camelCase without the prefix: referenceNumber. A parser strips the prefix and transforms the key.

// Backend key:    "Invoice.ReferenceNumber"
// Frontend key:   "referenceNumber"

This translation is explicit and centralized. If the backend renames a field, the parser catches the mismatch. It’s a narrow, testable seam between two naming conventions.

The same parser rewrites field names inside conditional required rules, so cross-field comparisons reference the correct form control names after translation.

Generating Validators

Once parsed, the frontend generates Angular ValidatorFn arrays from the rule objects. Each rule type maps to a specific validator or combination:

Recipe rule Angular validator
required: {} Validators.required
limitedLength: { min: n, max: n } Validators.minLength(n) + Validators.maxLength(n)
pattern: { regex: "..." } Validators.pattern(...)
email: {} Validators.email
limitedTo: [...] custom comparison validators

The form controls receive these validator arrays and the user sees immediate feedback while typing. The form made no business decision to get there. It was handed constraints and told to apply them.

Conditional Required: The Group Validator Case

One rule type doesn’t produce a field validator at all.

"Schedule.EndDate": {
  "required": {
    "when": [
      { 
        "fieldName": "Schedule.IsPermanent", 
        "operator": "=", 
        "fieldValue": false 
      }
    ]
  }
}

“End date is required when the schedule is not permanent” is a cross-field relationship. Angular handles cross-field validation at the FormGroup level, not the FormControl level. So the frontend generates a group validator and attaches it to the parent form group instead of the field.

This was one of the design insights that came from building the consumer side: not all recipe rules produce field validators. The code has to distinguish rule shapes and route them accordingly.

Two Kinds of Errors

So far I’ve described the pre-validation path: recipe arrives, validators are generated, the user gets feedback while typing. But there’s a second error path that activates after submit.

When the form is sent, the backend runs its full validation. If something fails, it returns a structured error response:

{
  "succeeded": false,
  "message": "Journal entry not recorded.",
  "errors": [
    {
      "propertyName": "JournalEntry.Date",
      "errorMessage": "Date falls in a hard-closed period.",
      "attemptedValue": "2026-05-26"
    }
  ]
}

These are different from pre-validation errors. They describe conditions the frontend couldn’t have known: a closed period, a reference that no longer exists, a state that changed while the page was open. The frontend maps them back to form controls by propertyName and sets field-level errors — the same inline style the user already knows, but now the message comes from the backend.

recipe-validation-errors.png

When the Backend Sends Rich Context

Sometimes a rejection carries more than a message. An approval might fail because a payment exceeds an authorized threshold, and the user needs to understand exactly why. The backend can include structured context alongside the error:

{
  "propertyName": "Payment.Amount",
  "errorMessage": "Amount exceeds authorization limit.",
  "customState": {
    "remainingPayableBalance": 4250.00,
    "approvalEligibility": {
      "isEligible": false,
      "reasons": ["Requires two-approver sign-off above $5,000"]
    }
  }
}

The frontend parses customState and displays a richer explanation. It didn’t decide anything. The backend gave it enough context to be genuinely helpful.

The Shape of This in Code

The wiring in practice is a short pipeline:

  1. GET the recipe endpoint, unwrap the response
  2. Parse field names: strip prefix, camelCase transform
  3. Generate per-field validators from rule objects
  4. Generate group validators for conditional required rules
  5. Apply both to the form controls and form group
  6. Apply read-only metadata and disable controls where indicated

Each step is a pure transformation. The recipe JSON goes in; a configured form comes out.

What’s Next

Post 3 goes into the backend side: how the recipe is built, what rule types it can express, and how FluentValidation ended up in the picture. That last part is worth a separate explanation — it wasn’t part of the original design.

Leave a Reply

Trending

Discover more from Claudio Lassala's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading