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


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:

┌──────────────────────────────────────────────────────────────────┐
│                        BUILD TIME                                │
│                                                                  │
│  C# Rules ──► JavaScriptTranslator ──► JS Code Templates         │
│                                           │                      │
│                                           ▼                      │
│                                    GraphQL Endpoint               │
│                                    (dryvRule query)               │
│                                           │                      │
│                                           ▼                      │
│                                   yarn gen:rules                  │
│                                           │                      │
│                                           ▼                      │
│                              types/generated/validation/          │
│                              (TypeScript rule set files)          │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│                        RUNTIME                                   │
│                                                                  │
│  Browser: useDryv(model, ruleSet) ──► dryvjs validates fields    │
│                │                              │                  │
│                │ loadParameters()              │ callServer()    │
│                ▼                              ▼                  │
│         GraphQL: dryvRuleParameters     Dynamic Controllers      │
│         (fetches current param values)  (/_v/c{hash})            │
│                                         (server-side fallback)   │
└──────────────────────────────────────────────────────────────────┘

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 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(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.


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 yarn gen:rules (with the backend running) sends the dryvRule GraphQL query, receives all rule sets as JSON, and generates one TypeScript file per model type.

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 by yarn gen:rules.


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 provides zero-config 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 addResultHandler and removeResultHandler composables

For project-specific hooks (custom date parsing, parameter loading, formatting), the module exposes a dryv:options hook:

// plugins/dryv-extend.ts
export default defineNuxtPlugin((nuxtApp) => {
    nuxtApp.hook('dryv:options', (options) => {
        options.setup = () => ({
            format: useFormat(),
            loadParameters: useLoadParameters(),
        })

        options.parseDate = (date, locale, format) => {
            return new Date(date).valueOf()
        }
    })
})

Projects that previously maintained a custom modules/dryv/ directory can migrate by installing nuxt-dryv and moving their configuration to nuxt.config.ts.

Integration Hooks

Whether using nuxt-dryv or a custom module, three critical hooks connect dryvjs to the application:

callServer — Routes validation requests through the application’s API client, which automatically injects anti-forgery tokens, authentication headers, and handles error responses:

async callServer(url, method, data?) {
    return await apiClient(`${baseUrl ?? ''}${url}`, {
        method,
        headers: data ? { 'Content-Type': 'application/json' } : {},
        body: data ? JSON.stringify(data) : undefined,
    })
}

loadParameters — Fetches runtime parameter values via GraphQL and caches them per validation set name using Nuxt’s useState:

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
    }
}

parseDate — Handles multiple date format patterns (ISO, German formats with dots, time-inclusive formats), because the C# translator emits date parsing calls that the client must handle correctly.

The handleResult System

Validation results pass through a pluggable handler pipeline. Any part of the application can register a result handler:

import { addResultHandler, removeResultHandler } from
    '~~/modules/dryv/runtime/integration/useDryvOptions'

const handlerId = addResultHandler(async (session, model, path, rule, result) => {
    if (result === 'SPECIAL_CODE') {
        return 'User-friendly error message'
    }
    return undefined // let default handling proceed
})

// Later:
removeResultHandler(handlerId)

The built-in warning handler uses this system to implement “show once” behavior — if a warning was already shown for a field and the user submits again without changing the value, the warning is treated as success:

function useWarningResultHandler() {
    const state = useState<Record<string, Record<string, string>>>(
        'dryvWarnings', () => ({})
    )

    return { handleWarningResult }

    async function handleWarningResult(session, model, path, rule, result) {
        const prevHash = state.value[session.ruleSet.name]?.[path]
        if (prevHash === result?.warningHash) {
            // Same warning as before → treat as success
            return { success: true, hasWarnings: false, hasErrors: false }
        }
        // Track the new warning hash for next submission
        // ...
    }
}

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.text" class="error">
            {{ validatable.zipCode.text }}
        </div>

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

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

The key pattern: bind inputs to model. (the reactive proxy) and display errors from validatable. (the validation-aware proxy). The useDryv composable returns:

  • model — A reactive proxy for two-way binding
  • validatable — A proxy where each field has .text, .type, and .group for error display
  • validate() — Triggers full validation, returns a result object
  • valid — A computed boolean for overall form validity
  • dirty — Whether any field was modified
  • 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 returnedhandleResult processes it through the handler pipeline
  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)
yarn gen:rules

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, and the backend must be running during code generation. 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

Categories: