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


Table of Contents

The Duplication Problem

Every web application with forms faces the same dilemma: validation must run in two places. The server needs it for security. The browser needs it for responsiveness. In practice, this means developers write the same logic twice — once in C# and once in JavaScript — and then spend the rest of the project keeping the two in sync.

The Dryv framework eliminates that duplication entirely. You define validation rules once in C#, and they are automatically translated to JavaScript for client-side execution. The 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 system spans four libraries across two languages, all open-source on GitHub:

LayerLibraryLanguagePurpose
ServerDryv + Dryv.AspNetCoreC#Define rules, translate to JS, expose via GraphQL
Client CoredryvjsTypeScriptExecute translated rules in the browser
Vue IntegrationdryvueTypeScriptConnect dryvjs to Vue 3 reactivity
Nuxt Modulenuxt-dryvTypeScriptZero-config Nuxt 3 integration with SSR support

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

The flow moves through two distinct phases:

%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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

At build time, a code generation script queries the backend for translated rules and writes TypeScript files. At runtime, the browser executes those rules locally, calling back to the server only when a rule cannot be expressed in JavaScript.


Server Side: Defining Rules in C#

Basic Rules

Validation rules are static DryvRules<TModel> properties on model classes. Each rule is a lambda expression that 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 walks the C# expression tree and produces 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 — regex checks, string operations, comparisons, null checks — translate directly. The generated JavaScript executes instantly in the browser with zero network overhead.

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. These cannot 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)

When Dryv encounters an untranslatable expression, it generates a dynamic ASP.NET controller at runtime 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);
    });
}

The URL /_v/cpk97zzro is deterministic — it is derived from the MD5 hash of the controller’s type name, which itself includes a hash of the expression. If the C# expression changes, the URL changes, which is why client rules must be regenerated after backend changes.

Parameters for Dynamic Values

A subtle problem: when a rule references DateTime.Today, the translator converts it to a JavaScript date literal at translation time. The “today” value is frozen forever.

The solution is .Parameter(), which registers values that are evaluated at runtime 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);
}

This C# code defines a validation rule that checks whether the user is at least 18 years old. The key insight is that the threshold date (“today minus 18 years”) changes every day — so it cannot be hard-coded into the generated JavaScript. Instead, it is declared as a parameter that the frontend fetches at runtime.

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

Parameters can also inject services from the DI container:

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

Disablers

Disablers determine whether a field’s validation should be skipped entirely:

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

If the disabler returns true, all validators for that field are bypassed. Disablers are also translated to JavaScript and included in the generated rule sets, so the client skips validation for irrelevant fields without a server round-trip.

Warnings

Dryv supports validation warnings — non-blocking messages that inform the user but don’t prevent submission:

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

Warnings are handled differently from errors on the client side. A custom result handler tracks warning hashes, and a warning shown once is suppressed on the next validation pass so it doesn’t permanently block the user.


Delivering Rules to the Client

How Client Integration Works

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

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

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

Getting Rules from Server to Client

There are two ways to produce the translated rule sets that useDryv consumes:

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

Either way, the output is the same: typed DryvValidationRuleSet objects that ship with the client bundle. Runtime parameters (values that change daily, like “today minus 18 years”) are still fetched at runtime via a lightweight HTTP call — REST or GraphQL, depending on what the backend exposes.

Why This Article Assumes GraphQL

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

The Maintenance Advantage

The real payoff is not in any single library but in the workflow they enable together:

  • One rule definition, two runtimes. A C# expression like m => m.Iban.Length < 15 ? "Too short" : null compiles to both server-side model validation and a client-side function — with no manual synchronization.
  • No hand-written client validation. Frontend developers consume generated rule sets. They never write regex checks or length validators in JavaScript; they bind models to useDryv and the rules execute.
  • Safe refactoring. When a business rule changes, the developer updates one C# expression, regenerates the client artifacts, and both sides are guaranteed to agree. Stale rules surface immediately as build errors or hash mismatches rather than silent divergence.
  • Separation of concerns preserved. Backend developers own validation logic. Frontend developers own UX. The generated rule set is the contract between them — readable, diffable, and versioned in source control.

The Code Generation Pipeline

GraphQL Endpoint

The ASP.NET backend exposes Dryv rules via GraphQL. Two queries power the system:

  1. dryvRule — Returns translated JavaScript for all model types. Used at build time.
  2. dryvRuleParameters — Returns current parameter values for a model type. Used at runtime.

The dryvRule query is intentionally disabled in production. Production deployments use pre-generated rule files.

The gen:rules Script

Rule generation is 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 running) sends the dryvRule GraphQL query, receives all rule sets as JSON, and generates one TypeScript file per model type. In practice, this is a project-defined script (e.g., yarn gen:rules or npm run gen:rules) that invokes a custom codegen plugin.

Generated Output

Each generated file exports a typed DryvValidationRuleSet containing 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: this generated file contains executable validation functions that the browser runs directly — they were compiled from C# expressions into JavaScript by the Dryv framework. Each field (here zipCode) can have multiple validators that run in sequence. The first is a simple client-side pattern check (“must be exactly 5 digits”), which gives instant feedback. The second calls the server for deeper validation that cannot run in the browser (for example, checking whether the zip code is in the delivery area). The disablers section controls when validation should be skipped entirely — here, secondaryOption is only validated when the user selects a “Combined” plan.

Notice the two zip code validators: the first is a pure client-side regex check (instant), and the second calls the server for deeper validation (e.g., checking if the zip code exists in the service area). The async: true flag tells dryvjs to handle the Promise.

These files must never be edited manually — they are overwritten every time the rule generation script runs.


Client Side: Nuxt Integration

nuxt-dryv — The Official Nuxt Module

The recommended way to integrate Dryv into a Nuxt application is nuxt-dryv, a published Nuxt module available on npm. It handles 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 this configuration, 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 any hook can be overridden in one place. The module already provides working defaults for callServer (using Nuxt’s $fetch) and handleResult — the only hook you must implement is loadParameters, and only if your rule sets 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

The setup function is a factory that dryvue calls lazily at validation time — within the component’s lifecycle — guaranteeing that Nuxt composables (like useState or a GraphQL client) are available. It returns any subset of DryvOptions:

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

Here is 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 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: bind inputs to model.<em> (the reactive proxy) and display errors from validatable.</em> (the validation-aware proxy). Alternatively, you can bind directly through the validation-aware proxy using validatable.zipCode.value — this works identically to model.zipCode for two-way binding, but keeps all access on a single object:

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

The useDryv composable returns:

  • model — A reactive proxy for two-way binding
  • validatable — A proxy where each field has .text, .type, .group for error display, and .value for getting/setting the model value (usable instead of model.* for binding)
  • validate() — Triggers full validation, returns a result object
  • valid — A computed boolean for overall form validity
  • dirty — Whether any field was 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 — Loaded parameter values from the server

The Complete Validation Lifecycle

Walking through what happens when a user types a zip code and clicks submit:

  1. User types “12345” — Vue reactivity updates model.zipCode
  2. User clicks submitvalidate() 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 servercallServer("/_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, step 2 also triggers loadParameters(), which fetches current values via GraphQL before validation begins. Parameters are cached in useState so subsequent validations within the same session don’t repeat the fetch.


When to Regenerate Rules

Client-side rules must be regenerated whenever C# validation changes:

  • Expression changes → different JavaScript translation
  • Service-injected rules added/removed → different callServer URLs (MD5 hash changes)
  • Parameter names change → different parameter keys in the rule set
  • New [DryvValidation] model added → new rule set file 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 operates on C# expression trees without any ASP.NET runtime dependency, rule generation can also be performed from a standalone console application. This removes the requirement to have the backend running during builds — useful for CI/CD pipelines or monorepo setups where the frontend builds independently of the backend.

The most common symptom of stale rules is a 404 on a /_v/c{hash} URL — the C# expression changed, producing a new hash, but the client still references the old one.


Summary

The Dryv framework solves the duplication of validation logic across server and client. C# expression trees serve as the single source of truth and are translated to JavaScript at build time, guaranteeing both sides enforce identical rules.

The client-side ecosystem — dryvjs for the core engine, dryvue for Vue 3 bindings, and nuxt-dryv for Nuxt integration — makes this seamless for Vue and Nuxt developers. The useDryv composable provides reactive validation with automatic parameter loading and server fallback for untranslatable rules. With nuxt-dryv, the entire setup is a single module registration with zero boilerplate.

The trade-off is a build-time dependency: rules must be regenerated when C# changes. When using the GraphQL approach, the backend must be running during code generation — though as noted above, a console-based generator can eliminate that constraint entirely. For any project where server and client must agree on validation, this is a small price for eliminating an entire class of bugs — the kind where the client says “valid” and the server says “rejected.”


Links

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

Categories: