Cross-Stack Validation with Dryv — Write Rules Once in C#, Execute Everywhere


Table of Contents

The Duplication Problem

Every web application with forms in it runs into the same dilemma: validation has to happen in two places. The server needs it for security and the browser needs it for responsiveness. So in practice we developers write the same logic twice, once in C# and once in JavaScript, and then spend the rest of the project keeping the two of them in sync.

Dryv is my attempt to get rid of that duplication altogether. You define your validation rules once in C# and they get translated automatically into JavaScript for the client side. The very same expression that runs during ASP.NET model binding also runs in the browser, without a single line of hand-written JavaScript validation code.


Architecture Overview

The whole thing spans four libraries across two languages, all of them open-source on GitHub:

Layer Library Language Purpose
Server Dryv + Dryv.AspNetCore C# Define rules, translate to JS, expose via GraphQL
Client Core dryvjs TypeScript Execute translated rules in the browser
Vue Integration dryvue TypeScript Connect dryvjs to Vue 3 reactivity
Nuxt Module nuxt-dryv TypeScript Zero-config Nuxt 3 integration with SSR support

The dryvjs monorepo hosts the whole client-side side of things: the core validation engine (dryvjs), the Vue 3 bindings (dryvue) and the Nuxt module (nuxt-dryv). The Dryv .NET repository holds the server-side C# framework, including the expression-to-JavaScript translator.

The flow runs through two distinct phases:

flowchart TB subgraph BUILD["BUILD TIME"] direction TB Rules["C# Rules"] --> Translator["JavaScriptTranslator"] Translator --> Templates["JS Code Templates"] Templates --> GQL["GraphQL Endpoint\n(dryvRule query)"] GQL --> GenRules["Code generation script"] GenRules --> Files["types/generated/validation/\n(TypeScript rule set files)"] end subgraph RUNTIME["RUNTIME"] direction TB Browser["Browser:\nuseDryv(model, ruleSet)"] --> Validates["dryvjs validates fields"] Browser -->|"loadParameters()"| Params["GraphQL: dryvRuleParameters\n(fetches current param values)"] Validates -->|"callServer()"| Controllers["Dynamic Controllers\n(/_v/c{hash})\n(server-side fallback)"] end BUILD --> RUNTIME

So at build time, a code generation script asks the backend for the translated rules and writes out TypeScript files. At run time the browser executes those rules locally, only calling back to the server when a rule can’t be expressed in JavaScript at all.


Server Side: Defining Rules in C#

Basic Rules

Validation rules are static DryvRules<TModel> properties on your model classes. Each rule is a lambda expression which returns null for valid input, or an error message for invalid input:

public static class PaymentDetailsValidation
{
    public static DryvRules<PaymentDetailsViewModel> Rules { get; } =
        DryvRules.For<PaymentDetailsViewModel>()
            .Rule(
                m => m.AccountHolder,
                m => string.IsNullOrWhiteSpace(m.AccountHolder)
                    ? "Please enter the account holder's name."
                    : null)
            .Rule(
                m => m.Iban,
                m => string.IsNullOrWhiteSpace(m.Iban)
                    ? "Please provide your IBAN."
                    : null)
            .Rule(
                m => m.Iban,
                m => !string.IsNullOrWhiteSpace(m.Iban)
                    && m.Iban.Length < 15
                    ? "Your IBAN is too short. It must be at least 15 characters."
                    : null);
}

The JavaScriptTranslator then walks the C# expression tree and produces the equivalent JavaScript:

// C#:
m => string.IsNullOrWhiteSpace(m.AccountHolder) ? "Error" : null
// JS:
function($m, $ctx) {
    return !/\S/.test($m.accountHolder || "")
        ? { type: "error", text: "Error", group: null }
        : null
}

Simple expressions, so regex checks, string operations, comparisons and null checks, all translate directly. The generated JavaScript then executes instantly in the browser with no network overhead whatsoever.

Service-Injected Rules (Server Fallback)

Some rules depend on services that only exist on the server: an IBAN checksum validator, an MX record checker, a database lookup. Those obviously can’t be translated to JavaScript:

.Rule<IbanValidator>(
    m => m.Iban,
    (m, ibanValidator) => !string.IsNullOrWhiteSpace(m.Iban)
        && m.Iban.Length >= 15
        && IbanRegex.IsMatch(m.Iban)
        ? ibanValidator.ValidateIban(m.Iban, false)
        : null)

So when Dryv runs into an untranslatable expression, it generates a dynamic ASP.NET controller at run time and replaces the client-side rule with a callServer() call:

function($m, $ctx) {
    return $ctx.dryv.callServer("/_v/cpk97zzro", "POST", {
        iban: $m.iban
    }).then(function($r) {
        return $ctx.dryv.handleResult($ctx, $m, "iban", null, $r);
    });
}

That URL /_v/cpk97zzro is deterministic: it’s derived from the MD5 hash of the controller’s type name, which itself includes a hash of the expression. So if the C# expression changes then the URL changes too, and that’s precisely why client rules have to be regenerated after any backend change.

Parameters for Dynamic Values

Here’s a subtle problem. When a rule references DateTime.Today, the translator converts that into a JavaScript date literal at translation time. Which means the “today” value is frozen forever.

The solution is .Parameter(), which registers values that get evaluated at run time on the server and passed to the client separately:

public static class PersonalDetailsValidation
{
    private const string MaxBirthDate = nameof(MaxBirthDate);
    private const string MinBirthDate = nameof(MinBirthDate);

    public static DryvRules<PersonalDetailsViewModel> Rules { get; } =
        DryvRules.For<PersonalDetailsViewModel>()
            .Parameter(MaxBirthDate, () => DateTime.Today.AddYears(-18))
            .Parameter(MinBirthDate, () => DateTime.Today.AddYears(-120))

            .Rule<DryvParameters>(
                m => m.DateOfBirth,
                (m, p) => m.DateOfBirth != null
                    && m.DateOfBirth > p.Get<DateTime>(MaxBirthDate)
                    ? "You must be at least 18 years old."
                    : null);
}

That C# code defines a validation rule checking whether the user is at least 18 years old. The important bit is that the threshold date, “today minus 18 years”, changes every single day, so it can’t possibly be hard-coded into the generated JavaScript. Instead we declare it as a parameter which the frontend fetches at run time.

During translation, p.Get<DateTime>(MaxBirthDate) becomes a placeholder in the JavaScript template. Then at run time the client fetches the current parameter values via a separate GraphQL query. The parameter factory (() => DateTime.Today.AddYears(-18)) gets executed on each request, so the cutoff date is always fresh.

Parameters can inject services from the DI container as well:

.Parameter<IOptions<ConfigOptions>, DateTime>(
    MaxMoveDateName,
    o => DateTime.Today.AddMonths(o.Value.MaxScheduleMonths))

Disablers

Disablers decide whether a field’s validation should be skipped altogether:

.Disable(m => m.SecondaryOption,
    m => m.PlanType != "Combined" || m.IsQueryModal == true)

If the disabler returns true then every validator for that field gets bypassed. Disablers are translated to JavaScript too and included in the generated rule sets, so the client skips validation for irrelevant fields without any server round-trip at all.

Warnings

Dryv also supports validation warnings, which are non-blocking messages that tell the user something without stopping them submitting:

.Rule(m => m.IsTermsAccepted,
    m => m.IsTermsAccepted
        ? null
        : DryvValidationResult.Warning("Please accept the terms and conditions."));

Warnings get handled rather differently from errors on the client side. A custom result handler keeps track of warning hashes, and a warning that’s already been shown once gets suppressed on the next validation pass, so it doesn’t end up permanently blocking the user.


Delivering Rules to the Client

How Client Integration Works

Once the rules are defined in C# and translated to JavaScript, the client needs two things: the translated rule functions themselves, and, for any rules with dynamic values, their runtime parameters.

On the client side, the dryvue library gives you a single composable, useDryv, which wires the whole lot together. You register a Vue plugin, pass a reactive model and a rule set to useDryv, and get back reactive validation state that plugs straight into your template with v-model. There’s no manual wiring of error messages and no imperative validation calls scattered across your event handlers, because the composable handles triggering, result tracking and re-validation automatically through Vue’s reactivity system.

The crucial point here is that useDryv is completely agnostic about how those rule sets arrive. It accepts any object conforming to the DryvValidationRuleSet interface, whether that object was hand-written, bundled out of a JSON file, or code-generated from a backend endpoint.

Getting Rules from Server to Client

There are two ways of producing the translated rule sets that useDryv consumes:

  • Query a running backend. The ASP.NET application exposes its translated rules through an HTTP endpoint, either REST or GraphQL. A build-time script queries that endpoint and writes the results out as TypeScript files. This is the most common approach and it’s the one I cover in detail here.
  • Generate from a console app. Dryv’s translator can also run inside a standalone console application, though that does limit your access to the injectable services the backend would normally provide.

Either way the output is identical: typed DryvValidationRuleSet objects that ship with your client bundle. Runtime parameters, meaning the values that change daily like “today minus 18 years”, still get fetched at run time via a lightweight HTTP call, either REST or GraphQL depending on what the backend exposes.

Why This Article Assumes GraphQL

For the rest of this article I’ll use GraphQL as the transport, both for rule generation and for runtime parameter loading. GraphQL’s typed schema, its introspection capabilities and its self-documenting nature make it a natural fit for querying structured validation metadata. If your project exposes a REST API instead, the concepts are all identical and only the fetch calls differ.

The Maintenance Advantage

The real payoff isn’t in any single one of these libraries. It’s in the workflow they make possible together:

  • One rule definition, two runtimes. A C# expression like m => m.Iban.Length < 15 ? "Too short" : null compiles into both server-side model validation and a client-side function, with no manual synchronization anywhere.
  • No hand-written client validation. Frontend developers just consume the generated rule sets. They never write regex checks or length validators in JavaScript at all; they bind models to useDryv and the rules run.
  • Safe refactoring. When a business rule changes, you update one C# expression, regenerate the client artifacts, and both sides are guaranteed to agree. Stale rules show up immediately as build errors or hash mismatches instead of drifting apart silently.
  • Separation of concerns preserved. Backend developers own the validation logic and frontend developers own the UX. The generated rule set is the contract between the two: readable, diffable and versioned in source control.

The Code Generation Pipeline

GraphQL Endpoint

The ASP.NET backend exposes the Dryv rules via GraphQL, and two queries drive the whole thing:

  1. dryvRule returns the translated JavaScript for all model types. Used at build time.
  2. dryvRuleParameters returns the current parameter values for a model type. Used at run time.

The dryvRule query is deliberately disabled in production, because production deployments use pre-generated rule files.

The gen:rules Script

Rule generation gets configured through a GraphQL codegen plugin:

// codegen-rules.config.ts
const config: CodegenConfig = {
    schema: process.env.GQL_URL_HTTP,
    documents: './queries/dryv-rules.graphql',
    generates: {
        'types/generated/validation/_.ts': {
            plugins: ['tools/codegen/gen-validation-rules'],
            config: {
                server: process.env.GQL_URL_HTTP,
                output: 'types/generated/validation/',
            },
        },
    },
}

Running the rule generation script, with the backend up, sends the dryvRule GraphQL query, receives all the rule sets back as JSON, and generates one TypeScript file per model type. In practice this is a project-defined script, something like yarn gen:rules or npm run gen:rules, which invokes a custom codegen plugin.

Generated Output

Each generated file exports a typed DryvValidationRuleSet holding validators, disablers and parameter placeholders:

// types/generated/validation/ProductQueryModel.ts (auto-generated)
export const ProductQueryModelValidationSet = {
    name: "ProductQueryModel",
    validators: {
        zipCode: [
            {
                validate: function ($m, $ctx) {
                    return /\d{5,5}/.test($m.zipCode) ? null : {
                        type: "error",
                        text: "Your zip code must consist of 5 digits.",
                        group: null
                    }
                }
            },
            {
                async: true,
                validate: function ($m, $ctx) {
                    return $ctx.dryv.callServer("/_v/cpk97zzro", "POST", {
                        zipCode: $m.zipCode
                    }).then(function ($r) {
                        return $ctx.dryv.handleResult(
                            $ctx, $m, "zipCode", null, $r
                        );
                    })
                }
            }
        ],
        // ... more fields
    },
    disablers: {
        secondaryOption: [{
            validate: function ($m, $ctx) {
                return ($m.planType != "Combined") || ($m.isQueryModal === true)
            }
        }],
        // ... more disablers
    },
    parameters: {}
} as DryvValidationRuleSet<ProductQueryModelInput>;

In plain terms, that generated file contains executable validation functions which the browser runs directly, compiled from C# expressions into JavaScript by the Dryv framework. Each field, zipCode here, can have several validators that run in sequence. The first is a simple client-side pattern check (“must be exactly 5 digits”) giving instant feedback. The second calls the server for the deeper validation that can’t run in the browser, such as checking whether the zip code is actually in the delivery area. And the disablers section controls when validation should be skipped altogether, so here secondaryOption only gets validated when the user picks a “Combined” plan.

Do notice those two zip code validators: the first is a pure client-side regex check and therefore instant, while the second calls the server for deeper validation, for instance checking whether the zip code exists in the service area. The async: true flag is what tells dryvjs to handle the Promise.

And these files must never be edited by hand, because they get overwritten every single time the rule generation script runs.


Client Side: Nuxt Integration

nuxt-dryv — The Official Nuxt Module

The way I’d recommend integrating Dryv into a Nuxt application is nuxt-dryv, a published Nuxt module available on npm. It takes care of plugin registration, Nuxt-native server communication via $fetch, and an extensible result handler pipeline, all with full SSR support.

Installation:

npm install nuxt-dryv dryvue

Minimal configuration in nuxt.config.ts:

export default defineNuxtConfig({
    modules: ['nuxt-dryv'],
    dryv: {
        serverBaseUrl: 'http://localhost:5000/api/validation',
        validationPath: 'types/generated/validation/index',
        handleWarnings: true,
    },
})

With that configuration in place, nuxt-dryv automatically:

  • Registers the dryvue plugin with Vue’s reactive() wrapper
  • Sets up $fetch-based server communication (respecting serverBaseUrl for SSR and relative paths for client)
  • Registers the #dryv import alias pointing to generated validation rule sets
  • Enables warning deduplication so repeated identical warnings are suppressed
  • Auto-imports the useDryv composable

For project-specific configuration the module exposes a dryv:options hook. The setup function returns a Partial<DryvOptions>, so you can override any hook in one place. The module already ships working defaults for callServer (using Nuxt’s $fetch) and for handleResult, so the only hook you must implement is loadParameters, and only then if your rule sets actually use parameters:

// plugins/dryv-extend.ts
export default defineNuxtPlugin((nuxtApp) => {
    nuxtApp.hook('dryv:options', (options) => {
        options.setup = () => ({
            loadParameters: useLoadParameters(),
            // Optional overrides:
            // callServer: async (url, method, data?) => { ... },
            // parseDate: (date, locale, format) => { ... },
            // format: useFormat(),
        })
    })
})

Integration Hooks

That setup function is a factory which dryvue calls lazily at validation time, inside the component’s lifecycle, and that guarantees Nuxt composables like useState or a GraphQL client are actually available. It returns any subset of DryvOptions:

  • loadParameters is the only hook you have to provide, and only when your rules use parameters. It fetches the runtime parameter values and typically caches them per validation set name using useState.
  • callServer has a working default in $fetch. Override it when you need custom headers, tokens or error handling.
  • parseDate is only needed if your rules compare dates. The C# translator emits date parsing calls that the client has to evaluate.
  • format is only needed if your rules format values for display inside validation messages.

Here’s an example useLoadParameters composable that queries a GraphQL endpoint:

export function useLoadParameters() {
    const { queryDryvRuleParameters } = useDryvRuleParametersClient()
    const parameters = useState<Record<string, unknown>>(
        'dryv-parameters', () => ({})
    )

    return async function <T = object>(validationSetName: string): Promise<T> {
        if (parameters.value[validationSetName]) {
            return parameters.value[validationSetName] as T
        }
        const result =
            (await queryDryvRuleParameters(validationSetName))?.parameters ?? {}
        parameters.value[validationSetName] = result
        return result as T
    }
}

Using Dryv in Vue Components

Using useDryv in Components

The useDryv composable is what connects a reactive model to a generated rule set:

<script setup lang="ts">
import { useDryv, type DryvValidationResult } from 'dryvue'
import { ProductQueryModelValidationSet } from '#dryv'
import type { ProductQueryModelInput } from '#types'

const localQuery = ref<ProductQueryModelInput>({
    zipCode: '',
    consumption: '',
    planType: 'Basic',
})

const { validatable, model, validate } = useDryv<ProductQueryModelInput>(
    localQuery.value,
    ProductQueryModelValidationSet,
)

async function submit() {
    const result = await validate()
    if (result.hasErrors || result.hasNewWarnings) return
    // proceed with submission
}
</script>

<template>
    <form @submit.prevent="submit">
        <input v-model="model.zipCode" />
        <div v-if="validatable.zipCode.hasError" class="error">
            {{ validatable.zipCode.text }}
        </div>

        <input v-model="model.consumption" />
        <div v-if="validatable.consumption.hasError" class="error">
            {{ validatable.consumption.text }}
        </div>

        <button type="submit">Submit</button>
    </form>
</template>

The key pattern here is to bind your inputs to model.*, which is the reactive proxy, and display errors from validatable.*, which is the validation-aware proxy. Alternatively you can bind directly through the validation-aware proxy using validatable.zipCode.value, which works identically to model.zipCode for two-way binding but keeps all your access on one object:

<input v-model="validatable.zipCode.value" />
<div v-if="validatable.zipCode.hasError" class="error">
    {{ validatable.zipCode.text }}
</div>

Here’s what useDryv gives you back:

  • model — a reactive proxy for two-way binding
  • validatable — a proxy where each field has .text, .type and .group for error display, plus .value for getting and setting the model value (usable instead of model.* for binding)
  • validate() — triggers a full validation and returns a result object
  • valid — a computed boolean for overall form validity
  • dirty — whether any field has been modified
  • revert() — reverts the model to its last committed state
  • clear() — clears all validation errors
  • commit() — commits the current model state as the new baseline for revert()
  • parameters — the parameter values loaded from the server

The Complete Validation Lifecycle

So let’s walk through what actually happens when a user types in a zip code and clicks submit:

  1. User types “12345” — Vue reactivity updates model.zipCode
  2. User clicks submit — validate() is called
  3. dryvjs checks disablers — Skips fields where disablers return true
  4. Rules run in configuration order — The regex check \d{5,5} passes instantly; sync rules execute after any async rules that appear before them
  5. Async validators call the server — callServer("/_v/cpk97zzro", ...) sends the zip code to the backend
  6. Backend executes the C# expression — The same expression that would run during model binding
  7. Result is returned — The result is processed and mapped to validation state (this handling can be overridden)
  8. validatable.zipCode.text updates — Vue reactivity triggers the error display
  9. validate() resolves — Returns { hasErrors: false } if everything passed

If the rule set has parameters then step 2 also triggers loadParameters(), which fetches the current values via GraphQL before validation begins. Those parameters get cached in useState, so later validations within the same session don’t repeat the fetch.


When to Regenerate Rules

Client-side rules have to be regenerated whenever the C# validation changes:

  • Expression changes → a different JavaScript translation
  • Service-injected rules added or removed → different callServer URLs, because the MD5 hash changes
  • Parameter names change → different parameter keys in the rule set
  • New [DryvValidation] model added → a new rule set file is needed
# Backend must be running (it serves the GraphQL endpoint)
# This is a project-defined script that queries the dryvRule endpoint and regenerates TypeScript rule files.
npm run gen:rules

Alternatively, because Dryv’s JavaScript translator works on C# expression trees without any ASP.NET runtime dependency at all, you can run rule generation from a standalone console application instead. That removes the requirement to have the backend running during builds, which is handy for CI/CD pipelines or monorepo setups where the frontend builds independently of the backend.

The most common symptom of stale rules, by the way, is a 404 on a /_v/c{hash} URL: the C# expression changed and produced a new hash, but the client is still referencing the old one.


Summary

So Dryv solves the duplication of validation logic across server and client. C# expression trees act as the single source of truth and get translated into JavaScript at build time, which guarantees that both sides enforce identical rules.

The client-side side of it, so dryvjs for the core engine, dryvue for the Vue 3 bindings and nuxt-dryv for the Nuxt integration, makes all of that painless for Vue and Nuxt developers. The useDryv composable gives you reactive validation with automatic parameter loading and a server fallback for the untranslatable rules. And with nuxt-dryv the entire setup is one module registration with no boilerplate at all.

The trade-off, of course, is a build-time dependency: rules have to be regenerated whenever the C# changes. And with the GraphQL approach the backend has to be running during code generation, though as I noted above, a console-based generator gets rid of that constraint entirely. For any project where the server and the client have to agree on validation, I think that’s a small price for eliminating a whole class of bugs. Specifically the kind where the client says “valid” and the server says “rejected”.


Hero image credit: Photo by Jan van der Wolf on Pexels