← All writings ES

Form Architecture in High-Compliance Frontends

Forms in banking aren't UI components — they're compliance interfaces with a UI layer on top. Here's the architecture that handles it without coupling business rules to your components.

A loan application form in a regulated financial environment is not a form. It’s a compliance interface with a UI layer on top.

The validation rules come from legal. Field visibility depends on regulatory scope — different rules apply across Chile, Australia, Canada. Some fields are required only when the applicant is a legal entity. Others are conditionally required based on the value of a previous field, which itself might be conditionally visible.

And all of it needs to be auditable: every state the form passes through, including intermediate states, needs to be reconstructable.

When I started on the digital onboarding platform at Scotiabank CCAU, we had react-hook-form wired directly to API calls with inline validation logic scattered across dozens of field components. It worked. It did not scale.

The core problem with inline validation

The instinct when building forms is to co-locate validation with the field:

<Input
  name="taxId"
  validate={(value) => {
    if (!value) return 'Tax ID is required';
    if (!isValidRut(value)) return 'Invalid RUT format';
  }}
/>

This works fine for three fields. At thirty fields across four products with varying regulatory contexts, you have validation logic in JSX, in custom hooks, in API responses, and sometimes duplicated across products because someone didn’t realize the shared version existed.

The deeper problem: validation isn’t a UI concern. Whether a RUT is valid is a business rule. Whether a field is required given the applicant’s profile is a business rule. The UI should be asking “is this value valid for this context?” — not implementing the answer.

Schema-driven forms with Zod

The first structural change: centralize all validation schemas in a product-agnostic package.

// @onebank/schemas/src/loan/applicant.ts
import { z } from 'zod';

const PersonType = z.enum(['natural', 'legal']);

const BaseApplicantSchema = z.object({
  firstName: z.string().min(1, 'Required'),
  lastName: z.string().min(1, 'Required'),
  email: z.string().email('Invalid email'),
  personType: PersonType,
});

const NaturalPersonSchema = BaseApplicantSchema.extend({
  personType: z.literal('natural'),
  rut: z.string().regex(/^\d{7,8}-[\dkK]$/, 'Invalid RUT'),
  birthDate: z.string().refine(isAdult, 'Must be 18 or older'),
});

const LegalPersonSchema = BaseApplicantSchema.extend({
  personType: z.literal('legal'),
  taxId: z.string().min(9, 'Invalid tax ID'),
  legalRepresentativeName: z.string().min(1, 'Required'),
});

export const ApplicantSchema = z.discriminatedUnion('personType', [
  NaturalPersonSchema,
  LegalPersonSchema,
]);

export type Applicant = z.infer<typeof ApplicantSchema>;

z.discriminatedUnion is doing real work here. TypeScript narrows the type based on personType — you can’t access rut on a legal person applicant without a type error. This eliminates an entire class of runtime bugs that were showing up in QA.

Separating field visibility from validation

Field visibility (show/hide) and field validation (required/optional) are often coupled in the wrong place.

A field can be: visible and required, visible and optional, hidden (and therefore not validated), or conditionally visible based on another field’s value. We modeled this explicitly with a form context object:

interface FieldConfig {
  visible: boolean;
  required: boolean;
  disabled: boolean;
  label: string;
}

type FormConfig = Record<keyof ApplicantFormValues, FieldConfig>;

function resolveFormConfig(
  applicant: Partial<ApplicantFormValues>,
  regulatoryContext: RegulatoryContext,
): FormConfig {
  const isNatural = applicant.personType === 'natural';
  const isChile = regulatoryContext.jurisdiction === 'CL';

  return {
    rut: {
      visible: isNatural && isChile,
      required: isNatural && isChile,
      disabled: false,
      label: 'RUT',
    },
    taxId: {
      visible: !isNatural,
      required: !isNatural,
      disabled: false,
      label: 'Tax ID',
    },
    // ...
  };
}

resolveFormConfig is a pure function. It takes the current form state and the regulatory context, returns a config object. No React, no hooks, no side effects. It’s trivially unit tested:

test('hides RUT field for non-Chilean jurisdictions', () => {
  const config = resolveFormConfig(
    { personType: 'natural' },
    { jurisdiction: 'AU' },
  );
  expect(config.rut.visible).toBe(false);
});

The form components consume the config rather than making visibility decisions themselves:

function ApplicantForm() {
  const form = useForm<ApplicantFormValues>();
  const context = useRegulatoryContext();
  const config = resolveFormConfig(form.watch(), context);

  return (
    <form>
      {config.rut.visible && (
        <Controller
          name="rut"
          control={form.control}
          rules={{ required: config.rut.required }}
          render={({ field }) => (
            <Input {...field} label={config.rut.label} />
          )}
        />
      )}
    </form>
  );
}

The audit trail problem

Financial regulators require that application state is auditable: if a customer started an application, filled in ten fields, abandoned it, returned three days later, and submitted — you need to reconstruct every state that application passed through.

“State” here doesn’t mean Redux state. It means the values the customer entered, when they entered them, and what the form configuration was at that point — because regulatory rules might have changed between sessions.

We solved this by treating form state as an event log, not a snapshot:

interface FormEvent {
  sessionId: string;
  timestamp: string;
  eventType: 'field_changed' | 'step_completed' | 'validation_failed' | 'submitted';
  fieldName?: string;
  formVersion: string;
  regulatoryContext: RegulatoryContext;
}

formVersion is critical — it lets you replay the form against the validation rules that were in effect at the time, not today’s rules.

This added complexity. But it was non-negotiable from a compliance standpoint, and building it into the architecture early meant the teams consuming the component library got it for free. The alternative — retrofitting it later — would have been significantly more expensive.

What this architecture buys you

Onboarding velocity. A new developer joining the team doesn’t need to understand the regulatory context to build a form step. They consume the config object and render fields based on it. The business logic lives elsewhere.

Testability. resolveFormConfig is pure. ApplicantSchema is pure. Both are tested in isolation without a browser, without React, without API mocks. The test suites for business logic run in under two seconds.

Cross-product reuse. When the Insurance team needed a form with similar fields, they imported the shared schemas and the config resolver, added their product-specific fields, and had a working, compliant form in two days instead of two weeks.

Forms are infrastructure. Treat them accordingly.