Skip to content
End-to-End Validation in .NET: Shared Contracts Between ASP.NET Core, TypeScript, and FluentValidation

End-to-End Validation in .NET: Shared Contracts Between ASP.NET Core, TypeScript, and FluentValidation

1 The Architecture of Single-Source Validation

Most senior developers have dealt with validation drift, even if they don’t call it that. The backend enforces one set of rules. The frontend enforces another. At first the differences are small and easy to ignore. Over time, they add up. A field that used to be optional becomes required. A length limit changes. A regex gets tightened. Each change is made in good faith, usually by different people, in different repos, at different times.

After a few releases, things start to break in subtle ways. The UI blocks valid input. Or worse, it allows invalid input that the backend later rejects. Users see confusing error messages. Support tickets pile up. Engineers patch symptoms instead of fixing the root cause. None of this happens because teams are careless. It happens because validation logic is duplicated across layers with no single owner.

End-to-end validation is about fixing that ownership problem. The idea is straightforward: define validation rules once and enforce them everywhere. In the .NET ecosystem, the most reliable way to do this today is to treat FluentValidation as the source of truth, expose those rules through OpenAPI, and generate TypeScript + Zod schemas that the frontend uses directly. When validation flows from one authoritative definition, drift stops being a recurring problem.

1.1 The “Validation Tax” in Modern Distributed Systems

As systems grow, validation logic spreads out. Even modest applications often validate the same data in many places:

  • C# data annotations such as [Required] or [Range]
  • FluentValidation rules for more expressive checks
  • TypeScript schemas using zod or yup
  • Inline rules inside React Hook Form components
  • Hand-written regex checks in UI code
  • API gateway or BFF validation
  • Domain-level invariants inside aggregates

Each layer adds validation to protect itself, which makes sense on its own. The problem is cumulative cost. Every new rule has to be implemented multiple times, often in different languages and frameworks. Teams rarely update all layers at once. Over time, the system drifts.

A simple example shows how easy this is to miss.

Backend:

public class RegisterUserRequest
{
    [Required]
    public string Email { get; set; }

    [Required]
    [MinLength(12)]
    public string Password { get; set; }
}

Frontend:

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8), // drift: backend requires 12
});

Someone tightened the password rule on the server. The frontend wasn’t updated. The UI accepts an 8-character password, the API rejects it, and the user sees a vague error. No one did anything “wrong.” The system simply didn’t have a way to keep these rules in sync.

Now multiply that scenario across dozens of endpoints and hundreds of fields. The effort spent keeping validations aligned becomes a hidden tax that grows every sprint.

Inconsistent Logic Risk

Structural mismatches are frustrating, but logical mismatches are worse. Consider rules that involve more than one field. The backend might enforce a relationship like this:

RuleFor(x => x.EndDate)
    .GreaterThan(x => x.StartDate);

If the frontend only validates formats and required fields, it has no awareness of this relationship. Users fill out the form, submit it, and wait for the server to respond with an error that could have been caught instantly. The rule already exists, but it only runs after a network round trip.

These problems all point to the same root cause. Validation logic is duplicated. As long as rules live in multiple places, inconsistencies are inevitable. The only durable fix is to define the rules once and make every other layer consume them automatically.

1.2 The Solution: Contract-First Validation

Contract-first validation flips the usual approach. Instead of each layer defining its own rules, the backend owns validation and publishes it as a contract. Frontends, gateways, and other consumers don’t re-implement rules. They generate code from the contract.

In a modern .NET application, the flow looks like this:

FluentValidation Rules

Enhanced OpenAPI / JSON Schema

Generated TypeScript Types + Zod Schemas

React Hook Form Binding and UI Feedback

FluentValidation expresses the rules in C#, close to the business logic that actually enforces them. Those rules are exported into OpenAPI with accurate constraints and metadata. The frontend then generates TypeScript types and Zod schemas directly from that OpenAPI document.

The key benefit is that changes propagate automatically. If a rule changes in C#, the OpenAPI output changes. When the frontend regenerates its code, the UI validation updates with no manual edits. There is no interpretation step where mistakes creep in. Documentation, client behavior, and server enforcement all reflect the same model.

Why the Domain/Application Layer Should Own the Rules

Validation rules belong in the Application layer, not in the UI or transport layer.

First, these rules express business meaning. Whether a password must be 12 characters or whether an end date must follow a start date is not a UI concern. It’s part of how the system defines valid input.

Second, domain invariants are often stricter than what the UI shows. Some rules only make sense when data interacts with other aggregates or existing state.

Third, the application layer already has versioning, review, and governance processes. Validation rules benefit from the same discipline as the rest of the business logic.

Finally, FluentValidation integrates naturally with MediatR, Minimal APIs, and dependency injection. Validators execute in production, where correctness matters most.

TypeScript runs in the browser. It can improve user experience, but it is not authoritative. The system of record lives on the server, and that’s where validation rules must originate.

1.3 Technology Stack and Prerequisites

A practical contract-first validation setup in 2025 relies on a small number of well-established tools.

Backend

  • .NET 9 provides a modern runtime, streamlined Minimal APIs, and better JSON support.
  • FluentValidation defines expressive, testable validation rules.
  • MicroElements.Swashbuckle.FluentValidation converts FluentValidation rules into OpenAPI constraints.
  • Swashbuckle or NSwag generates the OpenAPI document.

Bridge Layer

  • OpenAPI 3.1, which aligns with JSON Schema draft 2020-12.
  • Schema and operation filters for rules that don’t map cleanly to standard constraints.

Frontend

  • React 18+
  • TypeScript 5.x
  • Zod or Valibot for runtime schema validation.
  • React Hook Form for form state and error handling.
  • Orval or Kubb for generating API clients and Zod schemas from OpenAPI.

Together, these tools form a pipeline where validation rules move cleanly from backend to frontend without manual duplication.

1.4 Architectural Trade-offs

There are two main ways to apply these generated rules in the frontend.

Strategy A: Strict Schema Generation at Build Time

  • The frontend generates TypeScript and Zod schemas during the build.
  • Validation logic ships with the application bundle.
  • Users get instant feedback with no network calls.
  • Latency is minimal and the UX is predictable.

The trade-off is that backend validation changes require a frontend rebuild. This is usually acceptable, as CI can enforce regeneration and catch drift early.

Strategy B: Runtime Rule Fetching via a BFF

  • The UI fetches validation rules at runtime from a backend-for-frontend.
  • Rules can change without redeploying the UI.
  • Forms can be rendered dynamically based on schema data.

The trade-off is added complexity and runtime latency. This approach makes sense for admin tools or highly dynamic systems, but it’s overkill for most applications.

For the majority of enterprise systems, build-time schema generation is the better choice. It is simpler to reason about, easier to test, and far less fragile.


2 The Source of Truth: Implementing Advanced FluentValidation

Once you commit to single-source validation, the backend becomes the place where rules are defined and owned. In a .NET application, FluentValidation is the most practical tool for this job. It is expressive, composable, and integrates naturally with dependency injection, Minimal APIs, and MediatR in .NET 9. But using FluentValidation effectively is less about writing individual rules and more about how you structure them. If the rules are not cleanly layered and predictable, exporting them to OpenAPI and consuming them on the frontend becomes fragile.

The goal in this section is simple: write validation logic that is correct, reusable, and easy to expose as a contract.

2.1 Structuring Validation in Clean Architecture

One of the most common sources of confusion is mixing different kinds of validation together. Input validation and domain validation are related, but they are not the same thing. Treating them as separate concerns makes the system easier to reason about and avoids leaking internal rules into public contracts.

Application Validation (DTOs)

Application-level validation focuses on incoming requests. These rules answer questions like: Is the payload complete? Are the values shaped correctly? Do related fields make sense together? This is the validation that should be shared with the frontend.

For example, consider a request to create an event:

public sealed record CreateEventRequest(
    string Name,
    DateTime StartDate,
    DateTime EndDate);

The validator expresses exactly what the API expects:

public sealed class CreateEventRequestValidator 
    : AbstractValidator<CreateEventRequest>
{
    public CreateEventRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.StartDate)
            .NotEmpty();

        RuleFor(x => x.EndDate)
            .GreaterThan(x => x.StartDate);
    }
}

These rules describe the shape and meaning of valid input. They are stable, easy to document, and safe to expose through OpenAPI. When the frontend generates its Zod schema, this is exactly the kind of validation it should receive.

Domain Validation (Invariants)

Domain validation serves a different purpose. It protects business rules inside the domain model itself, regardless of how data enters the system. These rules should not be exported to OpenAPI because they are not part of the public contract.

Using the same event example:

public class Event
{
    public void UpdateSchedule(DateTime start, DateTime end)
    {
        if (end <= start)
            throw new DomainException("ERR_INVALID_EVENT_RANGE");

        StartDate = start;
        EndDate = end;
    }
}

This logic ensures the aggregate is never in an invalid state, even if data comes from a background job or another service. It complements application validation, but it does not replace it.

The distinction is important: DTO validators are for clients; domain invariants are for the model. Only the former should be part of your shared validation contract.

Automatic Validator Registration

To make this work consistently, validators must be applied automatically. In .NET 9, this is straightforward:

builder.Services
    .AddValidatorsFromAssemblyContaining<CreateEventRequestValidator>();

Once registered, validators are picked up by Minimal APIs, endpoint filters, or MediatR pipelines without additional wiring. This keeps validation predictable and ensures the same rules run in production that were exported to OpenAPI.

2.2 Complex Rule Definitions

Real systems rarely stop at simple required fields. Relationships between values, conditional rules, and context-specific constraints are common. FluentValidation handles these well, but you need to write them in a way that can later be reflected into a schema.

Cross-Property Validation

Cross-property rules are a good example. They express intent clearly in C#:

RuleFor(x => x.EndDate)
    .GreaterThan(x => x.StartDate)
    .WithErrorCode("ERR_END_BEFORE_START");

This rule is readable, testable, and explicit. It also becomes a candidate for schema enrichment, where metadata can describe the relationship so the frontend can enforce it too. The key is consistency: write the rule once, attach a stable error code, and let tooling carry it forward.

Using RuleSets for Context

Not every operation uses the same rules. Creating an entity is different from patching it. RuleSets allow you to express that difference without duplicating validators.

public sealed class UserDtoValidator : AbstractValidator<UserDto>
{
    public UserDtoValidator()
    {
        RuleSet("Create", () =>
        {
            RuleFor(x => x.Email)
                .NotEmpty()
                .EmailAddress();

            RuleFor(x => x.Password)
                .NotEmpty()
                .MinimumLength(12);
        });

        RuleSet("Patch", () =>
        {
            RuleFor(x => x.Email)
                .EmailAddress()
                .When(x => x.Email != null);
        });
    }
}

Each RuleSet represents a clear validation context. When exposed intentionally, they allow different API endpoints to share the same underlying model while enforcing different constraints. This avoids the common anti-pattern of maintaining separate DTOs for every operation just to change validation behavior.

2.3 Custom Property Validators

As systems grow, some rules appear repeatedly. Password strength, tax identifiers, or internal reference formats are typical examples. Custom property validators help centralize these rules so they are defined once and reused everywhere.

Strong Password Example

A custom validator encapsulates both logic and intent:

public sealed class StrongPasswordValidator 
    : PropertyValidator<string>
{
    protected override bool IsValid(
        PropertyValidatorContext context)
    {
        var password = context.PropertyValue as string;
        if (string.IsNullOrWhiteSpace(password))
            return false;

        return Regex.IsMatch(
            password, 
            @"^(?=.*[A-Z])(?=.*\d).{12,}$");
    }

    public override string Name => "StrongPassword";
}

It can then be applied like any other rule:

RuleFor(x => x.Password)
    .SetValidator(new StrongPasswordValidator())
    .WithErrorCode("ERR_WEAK_PASSWORD");

This keeps validators small and focused, and it avoids scattering regex patterns across the codebase.

Exposing Metadata for Schema Generation

For end-to-end validation to work, custom validators must expose enough metadata for schema generation tools to understand them. This metadata doesn’t execute logic; it describes intent.

public IDictionary<string, object> GetMetadata() =>
    new Dictionary<string, object>
    {
        ["x-strong-password"] = true,
        ["minLength"] = 12,
        ["requiresUpper"] = true,
        ["requiresDigit"] = true
    };

Libraries like MicroElements can attach this data to the OpenAPI schema as extensions. Frontend generators can then translate it into Zod refinements or UI hints without guessing what the rule means.

2.4 Leveraging Severity Levels and Error Codes

Not all validation rules are equal. Some should block submission. Others should warn but allow progress. FluentValidation supports this distinction through severity levels.

RuleFor(x => x.Password)
    .MinimumLength(12)
    .WithSeverity(Severity.Warning);

Severity makes intent explicit. The backend defines whether a rule is advisory or mandatory. The frontend can then reflect that distinction in the UI without hardcoding behavior.

Error codes complete the picture:

.WithErrorCode("ERR_PASSWORD_TOO_SHORT");

Codes are stable identifiers. They survive refactoring, localization, and UI redesigns. Instead of coupling validation to text, the system relies on codes that map cleanly to translated messages on the client.

Together, severity levels and error codes turn FluentValidation rules into a durable contract. They carry meaning across layers without leaking implementation details, which is exactly what end-to-end validation requires.


3 Bridging the Gap: Exposing Rules via OpenAPI

Once FluentValidation becomes the single source of truth, the next challenge is getting those rules out of the backend in a form other systems can consume. That role belongs to OpenAPI. In this architecture, OpenAPI is not just documentation; it is the transport layer for validation rules. It carries constraints, relationships, and metadata from C# into a machine-readable format that frontend tools can generate code from.

On its own, OpenAPI only describes types and basic constraints. What makes this approach work is MicroElements.Swashbuckle.FluentValidation, which understands FluentValidation and enriches the OpenAPI schema with the same rules that execute at runtime.

3.1 Configuring MicroElements.Swashbuckle.FluentValidation

The setup is intentionally minimal. You add one package and one line of configuration.

Install the package:

dotnet add package MicroElements.Swashbuckle.FluentValidation

Then enable it during application startup:

builder.Services.AddFluentValidationRulesToSwagger();

That’s it. From this point on, every validator registered in the application contributes metadata to the generated OpenAPI document.

How It Works

At runtime, the library reflects over each AbstractValidator<T>. For every rule it finds, it determines whether there is a corresponding JSON Schema constraint and applies it to the OpenAPI model. Simple rules map cleanly:

FluentValidation RuleOpenAPI Representation
NotNull()nullable: false
NotEmpty()minLength: 1
Length(5, 10)minLength: 5, maxLength: 10
Matches(regex)pattern: "<regex>"
Cross-field rulesschema extensions

Using the CreateEventRequest example from earlier, the resulting schema looks like this:

properties:
  name:
    type: string
    minLength: 1
    maxLength: 200
  startDate:
    type: string
    format: date-time
  endDate:
    type: string
    format: date-time
    x-cross-field:
      greaterThan: "startDate"

The standard constraints (minLength, maxLength, format) come directly from FluentValidation rules. The x-cross-field entry is a custom extension that describes a relationship OpenAPI cannot express natively. That extension is what allows frontend tooling to reconstruct the same rule in Zod later.

The important point is that nothing here is duplicated. The OpenAPI document is simply a projection of the existing FluentValidation rules.

3.2 Handling Polymorphism and Inheritance

Many real-world APIs use inheritance or polymorphism in their DTOs. When this happens, validation rules often live on both base and derived types. OpenAPI needs to express these relationships clearly so that generated TypeScript models behave correctly.

Consider a simple hierarchy:

public abstract record AnimalDto;

public record DogDto(string Breed) : AnimalDto;
public record CatDto(int Lives) : AnimalDto;

Each derived type may have its own validator, while the base type might define shared rules. MicroElements handles this by merging validators appropriately and emitting the correct OpenAPI constructs.

The generated schema uses oneOf to represent the polymorphic relationship:

oneOf:
  - $ref: '#/components/schemas/DogDto'
  - $ref: '#/components/schemas/CatDto'

This matters for frontend generation. Tools like Orval or Kubb translate this into TypeScript discriminated unions instead of flat interfaces. As a result, validation and type narrowing work the same way on the client as they do on the server.

Without this step, polymorphic models often degrade into loosely typed objects on the frontend, which undermines the entire idea of shared contracts.

3.3 Customizing the Schema Output

Not every FluentValidation rule has a direct equivalent in JSON Schema. Cross-field rules, severity levels, and custom validators all need extra metadata to remain meaningful outside the backend. OpenAPI supports this through schema extensions, which are prefixed with x-.

A schema filter is the simplest way to add this metadata.

public class CrossFieldSchemaFilter : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        if (context.MemberInfo?.Name == "EndDate")
        {
            schema.Extensions["x-cross-field"] = new OpenApiObject
            {
                ["rule"] = new OpenApiString("greaterThan"),
                ["other"] = new OpenApiString("startDate")
            };
        }
    }
}

You register the filter during Swagger configuration:

services.AddSwaggerGen(c =>
{
    c.SchemaFilter<CrossFieldSchemaFilter>();
});

This filter doesn’t enforce behavior. It documents intent. The backend already enforces the rule at runtime through FluentValidation. The schema extension simply makes that rule visible to code generation tools.

Later in the pipeline, frontend generators can read this extension and turn it into a Zod refine call. This is how cross-field logic flows from C# into TypeScript without rewriting it by hand.

3.4 Verifying the Contract

Once OpenAPI becomes part of the validation pipeline, it should be treated like production code. If the schema drifts from the validators, the entire chain breaks. Automated tests help prevent that.

A simple test can verify that critical validation metadata is present in the generated document.

[TestMethod]
public void OpenApi_Should_Contain_EndDate_GreaterThan_StartDate()
{
    var schemaJson = File.ReadAllText("openapi.json");
    var doc = JsonDocument.Parse(schemaJson);

    var endDateNode = doc.RootElement
        .GetProperty("components")
        .GetProperty("schemas")
        .GetProperty("CreateEventRequest")
        .GetProperty("properties")
        .GetProperty("endDate");

    Assert.AreEqual(
        "startDate",
        endDateNode
            .GetProperty("x-cross-field")
            .GetProperty("other")
            .GetString()
    );
}

This test does not care how the rule is implemented internally. It only checks that the contract exposed to consumers still reflects the intended validation logic.

By treating OpenAPI as a first-class artifact and validating it alongside application code, you ensure that frontend generation remains reliable. When validators change, the schema changes with them, and downstream tooling stays in sync.

At this point, FluentValidation rules have successfully crossed the boundary from backend logic to shared contract. The next step is consuming that contract on the frontend and turning it into executable validation logic.


4 Frontend Consumption: Automated Schema Generation

At this point, FluentValidation rules are defined on the backend and exposed through an enriched OpenAPI document. The missing piece is turning that document into something the frontend can actually use. This is where automated schema generation comes in. Instead of hand-writing TypeScript interfaces, validation logic, and API clients, the frontend generates them directly from OpenAPI.

The goal is not convenience. It is consistency. When the frontend code is generated from the same contract the backend enforces, validation stops drifting. Changes become mechanical: update a rule in C#, regenerate, and move on. Over time, this turns validation into a predictable build step instead of an ongoing coordination problem between teams.

4.1 The Modern Tooling Landscape

The TypeScript ecosystem offers several OpenAPI generators, but not all of them are suited for end-to-end validation. The key difference is whether a tool can generate runtime validation schemas, not just static types.

NSwag is widely used in .NET-heavy environments and works well for generating API clients and TypeScript interfaces. However, interfaces only describe shape. They cannot enforce constraints at runtime. As a result, teams using NSwag often add Zod or Yup schemas manually on top of the generated types. That extra step reintroduces duplication and undermines the idea of a shared contract.

Orval is designed with frontend workflows in mind. It generates API clients, integrates with Fetch, Axios, and React Query, and—most importantly—can generate Zod schemas directly from OpenAPI. Its configuration is explicit and readable, which makes it easy to understand what code is being produced and why. For many teams, Orval strikes a good balance between flexibility and simplicity.

Kubb takes a more modular approach. It is plugin-driven and treats OpenAPI as the center of the system. Kubb is especially strong when you need to work with OpenAPI 3.1 features or custom schema extensions. Because it exposes hooks during generation, it works well with custom metadata like the cross-field rules added earlier in the pipeline.

What all modern solutions have in common is this: plain TypeScript interfaces are not enough. Interfaces disappear at runtime. They cannot enforce minLength, regex patterns, or cross-field rules. Zod or Valibot fills that gap by turning schema information into executable validation logic.

When combined with the enriched OpenAPI output, tools like Orval and Kubb allow the frontend to execute the same validation rules as the backend—without rewriting them.

4.2 Generating Zod Schemas from OpenAPI

In practice, schema generation runs as part of the frontend build process. The generator reads the OpenAPI file and produces a set of TypeScript artifacts: API clients, types, and Zod schemas. The most important part for validation is how OpenAPI constraints are translated into Zod chains.

Typical mappings include:

  • minLength.min()
  • maxLength.max()
  • pattern.regex()
  • Custom metadata → .refine()

Here is a minimal setup using Orval. First, a script entry:

{
  "scripts": {
    "generate": "orval --config ./orval.config.ts"
  }
}

Then the Orval configuration:

// orval.config.ts
import { defineConfig } from 'orval';

export default defineConfig({
  api: {
    input: './openapi.json',
    output: {
      target: 'src/generated/api.ts',
      schemas: 'src/generated/zod',
      client: 'react-query',
      mock: false
    },
    hooks: {
      afterAllFilesWrite: 'prettier --write'
    }
  }
});

Given the CreateEventRequest validator defined earlier, Orval generates a Zod schema like this:

export const CreateEventRequestSchema = z.object({
  name: z.string().min(1).max(200),
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
});

Kubb follows the same pattern, with a slightly different configuration style:

// kubb.config.ts
import { defineConfig } from '@kubb/core';
import { zod } from '@kubb/zod';

export default defineConfig({
  input: {
    path: './openapi.json'
  },
  plugins: [
    zod({
      output: './src/generated/zod'
    })
  ]
});

And produces equivalent output:

export const CreateEventRequestSchema = z.object({
  name: z.string().min(1).max(200),
  startDate: z.string(),
  endDate: z.string(),
});

The critical point is not which tool you choose. What matters is that the frontend schema is derived, not authored. When a backend rule changes, regeneration updates the frontend automatically. No one needs to remember to adjust a second copy of the same constraint.

4.3 Handling Custom Extensions

Basic constraints map cleanly from OpenAPI to Zod. More advanced rules—such as cross-field relationships or custom validators—require additional interpretation. That is why the earlier sections emphasized adding structured metadata to the OpenAPI schema.

Recall the custom extension added for the end date rule:

x-cross-field:
  rule: "greaterThan"
  other: "startDate"

A generator plugin can read this extension and translate it into a Zod refinement:

CreateEventRequestSchema.refine(
  data => new Date(data.endDate) > new Date(data.startDate),
  {
    message: 'ERR_END_BEFORE_START',
    path: ['endDate'],
  }
);

With Kubb, this logic lives in a custom plugin:

export function crossFieldExtension() {
  return {
    name: 'crossFieldExtension',
    beforeWriteSchema(schema, context) {
      const ext = schema.extensions?.['x-cross-field'];
      if (!ext) return;

      if (ext.rule === 'greaterThan') {
        context.addPostProcess((zodVar) => {
          return `${zodVar}.refine(data => data.endDate > data.${ext.other}, {
            message: 'ERR_END_BEFORE_START',
            path: ['endDate']
          })`;
        });
      }
    }
  };
}

Orval supports a similar transformation approach:

export const customTransformer = (schema) => {
  const cross = schema['x-cross-field'];
  if (!cross) return null;

  return {
    transform: (zod) =>
      `${zod}.refine(data => new Date(data.endDate) > new Date(data.${cross.other}), {
        message: "ERR_END_BEFORE_START",
        path: ["endDate"]
      })`
  };
};

The frontend does not need to understand FluentValidation. It only needs to understand the schema. This keeps responsibilities clean and ensures that even complex backend rules can be enforced consistently in the UI.

4.4 CI/CD Integration: The “Break on Drift” Check

The last piece is enforcing discipline. End-to-end validation only works if schema generation is not optional. CI is the right place to enforce that.

A simple rule works well: if code generation produces changes, the build fails unless those changes are committed.

Here is a minimal GitHub Actions job:

jobs:
  validation-drift-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install frontend dependencies
        run: npm install

      - name: Generate schemas and clients
        run: npm run generate

      - name: Ensure generated code is committed
        run: git diff --exit-code || exit 1

Now consider a backend change:

RuleFor(x => x.Name).MaximumLength(200);

becomes:

RuleFor(x => x.Name).MaximumLength(100);

That single change updates the OpenAPI schema. Regeneration updates the Zod schema. If the frontend forgets to commit the regenerated files, CI fails immediately. There is no opportunity for drift to slip into production.

With this check in place, the shared contract becomes enforceable. Validation rules flow from backend to frontend automatically, and the system stays aligned by default rather than by convention.


5 User Interface Integration: React Hook Form & Zod

At this stage, the frontend already has generated Zod schemas that mirror the backend’s FluentValidation rules. The remaining question is how those schemas actually drive user experience. Validation only delivers value if users see fast, clear feedback while filling out forms. This is where React Hook Form (RHF) fits naturally into the pipeline.

React Hook Form focuses on performance and minimal re-renders. Zod provides executable validation logic. The resolver connects the two. When wired correctly, the UI stops defining validation rules altogether. Instead, it consumes them. That shift is important: the UI becomes a thin layer that renders inputs and displays feedback, not a place where business rules are rewritten.

5.1 The Resolver Pattern

The resolver pattern allows React Hook Form to delegate all validation to an external schema. In this case, that schema is generated from OpenAPI, which ultimately came from FluentValidation. This keeps the validation flow consistent from backend to browser.

Using the CreateEventRequestSchema generated earlier, a form setup looks like this:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateEventRequestSchema } from '../generated/zod/CreateEventRequestSchema';

export function CreateEventForm() {
  const form = useForm({
    resolver: zodResolver(CreateEventRequestSchema),
    mode: 'onChange'
  });

  const onSubmit = form.handleSubmit((data) => {
    // submit via generated API client
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register('name')} />
      <input type="datetime-local" {...form.register('startDate')} />
      <input type="datetime-local" {...form.register('endDate')} />
      <button type="submit">Create</button>
    </form>
  );
}

There are no inline validation rules here. No required, no maxLength, no custom logic. Everything flows through the resolver. If the backend changes the maximum length of name, the OpenAPI schema changes, regeneration updates the Zod schema, and the UI behavior changes automatically. The form code itself stays untouched.

This replaces patterns like:

<input {...register('name', { required: true, maxLength: 200 })} />

Those rules are redundant once the schema is authoritative. Removing them reduces duplication and makes forms easier to reason about.

5.2 Dynamic Form Rendering (Optional but Powerful)

Some applications go one step further and generate form layouts directly from schemas. This is especially useful in admin tools, internal dashboards, or systems where forms change frequently. Because Zod schemas are regular JavaScript objects, they can be inspected at runtime.

A simple renderer might look like this:

import { ZodObject, ZodString } from 'zod';

export function renderForm(schema: ZodObject<any>, form) {
  return Object.entries(schema.shape).map(([key, value]) => {
    if (value instanceof ZodString) {
      return (
        <div key={key}>
          <label>{key}</label>
          <input {...form.register(key)} />
        </div>
      );
    }

    // handle other types as needed
  });
}

With this approach, a backend change—such as adding a new required field—flows all the way to the UI without writing a single new input by hand. This is not appropriate for every product, but when used intentionally it removes large amounts of repetitive code and enforces consistent form behavior across the application.

Dynamic rendering becomes more practical when the schema includes UI hints:

"x-ui-hint": {
  "control": "datePicker",
  "format": "yyyy-MM-dd"
}

These hints can be attached through schema filters on the backend and interpreted by the frontend renderer. The backend still owns the rules; the frontend decides how best to present them.

5.3 Displaying Consistent Error Messages

Validation errors are only useful if users understand them. Zod returns errors with a path and a message, but in a shared-contract system that message is usually an error code, not user-facing text. This keeps validation logic independent of language and presentation.

If a FluentValidation rule specifies:

.WithErrorCode("ERR_END_BEFORE_START");

and that code is preserved during schema generation:

.refine(data => data.endDate > data.startDate, {
  message: 'ERR_END_BEFORE_START',
  path: ['endDate']
})

the UI can translate it cleanly:

const translations = {
  ERR_END_BEFORE_START: 'End date must be after start date',
  ERR_NAME_REQUIRED: 'A name is required',
};

React Hook Form exposes errors directly:

{form.formState.errors.endDate && (
  <p>{translations[form.formState.errors.endDate.message]}</p>
)}

This keeps error handling predictable. Backend and frontend share the same error identifiers, and localization lives in one place. When validation rules change, error handling logic does not need to be updated manually.

5.4 Handling “Warning” Levels

Not every validation rule should block submission. Some rules exist to guide users rather than stop them. FluentValidation supports this through severity levels, and that intent can be carried through the schema.

For example, the backend might mark a rule as a warning:

x-validation-severity: "warning"

The generated Zod schema preserves the distinction:

.refine(data => strongPassword(data.password), {
  message: 'WARN_PASSWORD_WEAK',
  path: ['password']
})

React Hook Form treats all validation issues as errors by default, but the UI can interpret warning codes differently:

const warning =
  form.formState.errors.password?.message?.startsWith('WARN');

return (
  <>
    <input {...form.register('password')} />
    {warning && (
      <p className="warning">
        {translations[form.formState.errors.password.message]}
      </p>
    )}
  </>
);

Submission can still proceed:

<form onSubmit={form.handleSubmit(onSubmit)}>

This mirrors how many enterprise systems handle advisory validation. Users receive guidance without being blocked unnecessarily. The key point is that the backend defines severity, not the UI. The frontend simply reflects that intent.

At this point, the validation chain is complete. Rules are defined in FluentValidation, exposed through OpenAPI, generated into Zod schemas, and enforced consistently in the UI. The remaining sections build on this foundation by handling asynchronous rules, localization, and long-term maintenance.


6 Handling Complexity: Async Validation and Edge Cases

Up to this point, validation has been fast, deterministic, and local. Generated Zod schemas handle things like required fields, lengths, patterns, and even cross-field relationships. This works because those rules can be evaluated immediately, without external dependencies. As soon as validation depends on system state—data in a database, the current user, or another service—the client can no longer decide on its own.

This is not a limitation of Zod or React Hook Form. It is a fundamental boundary. Client-side validation must stay synchronous and predictable. Anything that requires I/O, coordination, or authorization belongs on the server. The challenge is not choosing one over the other, but combining both layers so they work together without duplicating logic or confusing users.

6.1 The “Email Already Exists” Problem

Uniqueness checks are the clearest example of server-only validation. From the UI, “is this email already taken?” sounds like a simple question. From the backend’s perspective, answering it may involve database lookups, cache checks, soft-deleted records, or even calls to other services. That work is inherently asynchronous and stateful.

Because OpenAPI is static, it cannot describe uniqueness rules. And because Zod schemas are generated from OpenAPI, they intentionally avoid encoding anything that depends on live data. This is by design. Generated schemas describe shape, not state.

FluentValidation handles this cleanly with asynchronous rules:

public sealed class RegisterUserValidator 
    : AbstractValidator<RegisterUserRequest>
{
    public RegisterUserValidator(IUserRepository users)
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(async (email, _) =>
            {
                return await users.IsEmailAvailable(email);
            })
            .WithErrorCode("ERR_EMAIL_TAKEN");
    }
}

The validator receives its dependencies through DI, just like any other application service. The rule reads clearly: the email must be available. There is no concern about how that availability is checked. That detail stays inside the repository.

A minimal API endpoint can execute this validator automatically:

app.MapPost("/users", async (
    RegisterUserRequest request,
    IValidator<RegisterUserRequest> validator) =>
{
    var result = await validator.ValidateAsync(request);

    if (!result.IsValid)
        return Results.ValidationProblem(result.ToDictionary());

    // create user
});

Because the rule is asynchronous, the backend is the final authority. The frontend may optionally perform a live check—for example, when the user leaves the email field—but that check is only a hint. The server always re-validates before accepting the request.

This separation prevents a common failure mode: users bypassing client checks and submitting invalid data directly. The UI improves experience, but the backend protects correctness.

6.2 Hybrid Validation Strategy

Once asynchronous rules are involved, the validation model naturally becomes layered. This is not a compromise; it is the intended design.

The client layer, powered by generated Zod schemas, handles:

  • Required fields
  • String lengths
  • Regex patterns
  • Simple cross-field rules
  • Type coercion and parsing
  • Advisory warnings

These checks run instantly and prevent obvious mistakes before a request is sent.

The server layer, powered by FluentValidation and domain logic, handles:

  • Uniqueness checks
  • Referential integrity
  • Authorization-dependent rules
  • State-based constraints (for example, user status)
  • Domain invariants across aggregates

From the UI’s perspective, submission only happens after client validation passes:

const onSubmit = form.handleSubmit(async (data) => {
  try {
    await api.users.create({ data });
  } catch (error) {
    handleServerErrors(error, form);
  }
});

This keeps the interface responsive. Users see immediate feedback for simple issues, while complex checks happen only when necessary. More importantly, the rules are not duplicated. Each layer enforces what it is best suited for.

6.3 Unified Error Handling Response

Once validation runs in multiple places, error handling must feel unified. A user should not care whether a rule failed in Zod or FluentValidation. The feedback should look the same.

This is where a standardized error format matters. RFC 7807 (Problem Details) provides a consistent structure for HTTP validation errors. FluentValidation results can be mapped into this format without losing information.

A simple adapter might look like this:

public class ValidationProblemDetails : ProblemDetails
{
    public Dictionary<string, string[]> Errors { get; }

    public ValidationProblemDetails(ValidationResult result)
    {
        Title = "Validation Failed";
        Status = StatusCodes.Status400BadRequest;
        Type = "https://example.com/validation-error";

        Errors = result.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(e => e.ErrorCode).ToArray()
            );
    }
}

An endpoint can return this directly:

return Results.Json(
    new ValidationProblemDetails(result),
    statusCode: StatusCodes.Status400BadRequest);

Notice that the response contains error codes, not user-facing text. This keeps validation independent of language and presentation.

On the frontend, those errors can be fed back into React Hook Form using a small helper:

import { UseFormReturn } from 'react-hook-form';

export function handleServerErrors(
  error: any,
  form: UseFormReturn<any>
) {
  if (error.status !== 400 || !error.data?.errors) return;

  Object.entries(error.data.errors).forEach(([field, codes]) => {
    const [code] = codes as string[];
    form.setError(field, { message: code });
  });
}

From this point on, server-side errors behave exactly like client-side ones. They appear in the same error object, use the same translation pipeline, and render through the same UI components.

This is the key outcome of end-to-end validation: rules may execute in different places, but they surface in one consistent way. The next section builds on this foundation by showing how those error codes become localized, user-friendly messages across languages and regions.


7 Localization and Internationalization (i18n)

Once validation flows cleanly from backend to frontend, the next concern is how those validation results are presented to users. In most real systems, users are not all in the same locale. They expect validation messages to be clear, polite, and written in their own language. Achieving that consistently across backend and frontend layers requires discipline.

The key idea is simple: validation logic should never contain user-facing text. Instead, validation produces codes. Those codes are then translated at the edge of the system—usually in the UI. This keeps business rules stable while allowing messages to evolve independently. It also ensures that backend and frontend validation failures look identical to the user, regardless of where the rule executed.

7.1 Decoupling Messages from Logic

It’s common to start with inline messages in validators:

.WithMessage("Email must not be empty");

This works early on, but it quickly becomes a liability. The message is hardcoded in English, tightly coupled to the rule, and difficult to change without touching business logic. As soon as localization enters the picture, this approach starts to break down.

A better pattern is to attach an error code instead:

.WithErrorCode("ERR_EMAIL_REQUIRED");

That one change makes a big difference. The validator now expresses what went wrong, not how it should be explained. Error codes are stable identifiers. They survive refactoring, schema evolution, and the addition of new languages. They also show up cleanly in logs and monitoring tools, which makes production issues easier to track.

FluentValidation allows you to rely entirely on error codes and ignore message strings altogether. This keeps validators focused on rules, not presentation, and makes them safe to expose through OpenAPI as part of a shared contract.

7.2 Client-Side Translation Strategy

The frontend is the natural place to turn error codes into readable messages. UI frameworks already have access to user preferences, browser language settings, and localization libraries. Tools like i18next or react-intl fit neatly into this model.

A translation file maps error codes to messages:

{
  "ERR_EMAIL_REQUIRED": "Email is required",
  "ERR_EMAIL_TAKEN": "This email is already registered",
  "ERR_END_BEFORE_START": "End date must be after start date",
  "WARN_PASSWORD_WEAK": "Your password is weak but acceptable"
}

A small helper resolves codes to text:

export function translateError(code: string) {
  return i18next.t(code);
}

Because both Zod and server-side validation surface the same error codes, rendering errors becomes trivial:

{errors.email && (
  <p>{translateError(errors.email.message)}</p>
)}

There is no branching logic here. The UI does not need to know whether an error came from client-side validation or a server response. It simply receives a code and translates it. This is one of the main benefits of a shared validation contract: the presentation layer stays simple.

7.3 Server-Side Fallback

In some systems, the backend still needs localized messages. This is common for audit logs, batch jobs, or integrations where errors are consumed outside of a browser context. In those cases, the backend can respect the Accept-Language header and adjust culture settings accordingly.

A simple middleware example:

app.Use(async (context, next) =>
{
    var lang = context.Request.Headers["Accept-Language"].ToString();
    if (!string.IsNullOrWhiteSpace(lang))
    {
        var culture = new CultureInfo(lang);
        CultureInfo.CurrentCulture = culture;
        CultureInfo.CurrentUICulture = culture;
    }

    await next();
});

With culture set, FluentValidation can resolve localized messages through resource files if needed. Importantly, this does not change the contract with the frontend. The API can still return error codes, while logs or internal consumers receive localized text.

This dual approach keeps responsibilities clear. The backend owns validation rules and error codes. The frontend owns user-facing language. Localization becomes an implementation detail rather than a cross-cutting concern that leaks into validation logic.

At this point, validation rules are defined once, executed in the right place, surfaced consistently, and presented in the user’s language. The final section focuses on what it takes to keep this architecture healthy over time as teams, codebases, and deployment strategies evolve.


8 The DevOps Lifecycle: Maintenance and Performance

End-to-end validation only pays off if it holds up over time. Once the initial setup is in place, validation rules will keep changing as the product evolves. New fields are added. Existing constraints tighten or loosen. Entire workflows get replaced. The real test of this architecture is not whether it works today, but whether it remains predictable six months from now.

That predictability comes from treating validation as part of the delivery pipeline, not as a one-off design choice. Schema generation, contract verification, CI checks, and performance considerations all need to be intentional. When they are, validation becomes boring in the best possible way.

8.1 Performance Considerations

As applications grow, so do their schemas. Large OpenAPI documents can span thousands of lines, and frontend code generation can produce hundreds of Zod schema files. If left unchecked, this can affect build times and client bundle sizes.

There are a few practical ways to keep things under control:

  • Split large OpenAPI definitions by bounded context or domain
  • Run code generation only when backend schemas change
  • Prefer incremental generation when the tool supports it
  • Generate schemas as small, modular files instead of one large bundle
  • Avoid wildcard imports that pull in unused validators

Tree-shaking is especially important on the frontend. Zod schemas are plain JavaScript, which means modern bundlers can remove anything that is not referenced.

For example:

export const UserSchema = z.object({ ... });
export const EventSchema = z.object({ ... });

If a page only imports UserSchema, the event-related validation code never makes it into the final bundle. Tools like Vite and Webpack handle this automatically as long as the generated code is modular. Both Kubb and Orval support this pattern out of the box.

On the backend, performance is rarely an issue. MicroElements augments the OpenAPI schema in memory, and the cost of generating the document is negligible compared to compilation or startup. Most performance tuning effort should focus on the frontend build and bundle size.

8.2 Versioning Validation Logic

Validation rules rarely change in isolation. They change alongside APIs, UI behavior, and business expectations. In environments that use Blue/Green deployments or rolling updates, there is often a window where different versions of the system are live at the same time.

To avoid inconsistent behavior, schema versioning becomes important. A simple approach is to version OpenAPI endpoints explicitly:

/openapi/v1/openapi.json
/openapi/v2/openapi.json

If the frontend is still built against version 1, it consumes the v1 schema. Once the frontend updates, it moves to v2. This avoids a common failure mode where the backend enforces new validation rules that the UI does not yet understand.

On the backend, FluentValidation supports this kind of evolution through rule sets:

RuleSet("v1", () =>
{
    // original rules
});

RuleSet("v2", () =>
{
    // updated rules
});

Minimal APIs or endpoint filters can select the appropriate rule set based on the route:

app.MapPost("/v2/events", ...)
   .WithValidatorVersion("v2");

This approach adds some complexity, but it gives teams control. Validation changes can be rolled out deliberately instead of all at once, which is especially important in distributed systems.

8.3 Next Steps for the Architect

Moving an existing application to this model rarely happens in one sweep. The safest approach is incremental. A clear migration plan helps teams avoid half-finished states where validation logic is split across old and new patterns.

A practical checklist looks like this:

  1. Inventory validation logic across the system: UI forms, API controllers, gateways, and domain code.
  2. Move structural input validation into FluentValidation validators.
  3. Separate domain invariants from input validation and keep them inside the domain model.
  4. Enable MicroElements to export FluentValidation rules into OpenAPI.
  5. Introduce frontend code generation using Orval or Kubb.
  6. Replace inline UI validation with generated Zod schemas.
  7. Standardize error codes across backend and frontend.
  8. Add CI checks that fail on schema drift.
  9. Document the workflow so new team members follow the same patterns.

Teams often start with new endpoints and migrate existing ones gradually. Even partial adoption delivers value, but the biggest gains appear once FluentValidation becomes the undisputed source of truth.

8.3.1 Evangelizing the Workflow

This architecture only works if people trust it. Developers stop duplicating validation logic when they see that changing one rule updates everything else automatically. Demonstrating that feedback loop is usually more convincing than any design document.

A useful exercise is to walk the team through a small change: update a FluentValidation rule, regenerate the OpenAPI schema, regenerate the frontend code, and show how the UI behavior changes without touching JSX. Once developers experience that flow, they rarely want to go back to manual validation.

Over time, ad-hoc checks disappear. Validation becomes centralized, reviewable, and predictable. That consistency is what reduces bugs and support costs in the long run.

8.4 Conclusion and Future Proofing

Validation drift is a symptom of duplicated logic. Contract-first validation removes that duplication by design. FluentValidation defines the rules. MicroElements projects them into OpenAPI. Orval and Kubb turn them into executable Zod schemas. React Hook Form enforces them in the UI. Each layer has a clear role, and none of them need to guess what the others are doing.

The ecosystem is moving in this direction. Better support for JSON Schema 2020-12 in .NET and improved OpenAPI tooling continue to reduce the gap between server-side and client-side validation. As these standards mature, fewer custom extensions will be needed, and the pipeline will become even simpler.

This approach scales because it treats validation as a shared asset rather than a repeated task. When rules change, they change once. Everything else follows. That level of predictability is what allows teams to move fast without losing confidence in their systems.

Advertisement