Table of Contents
- The Duplication Problem
- Architecture Overview
- Server Side: Defining Rules in C#
- Delivering Rules to the Client
- The Code Generation Pipeline
- Client Side: Nuxt Integration
- Using Dryv in Vue Components
- The Complete Validation Lifecycle
- When to Regenerate Rules
- Summary
- Links
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:
| 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 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" : nullcompiles 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
useDryvand 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:
dryvRule— Returns translated JavaScript for all model types. Used at build time.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 (respectingserverBaseUrlfor SSR and relative paths for client) - Registers the
#dryvimport alias pointing to generated validation rule sets - Enables warning deduplication so repeated identical warnings are suppressed
- Auto-imports the
useDryvcomposable
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 usinguseState.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 bindingvalidatable— A proxy where each field has.text,.type,.groupfor error display, and.valuefor getting/setting the model value (usable instead ofmodel.*for binding)validate()— Triggers full validation, returns a result objectvalid— A computed boolean for overall form validitydirty— Whether any field was modifiedrevert()— Reverts the model to its last committed stateclear()— Clears all validation errorscommit()— Commits the current model state as the new baseline forrevert()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:
- User types “12345” — Vue reactivity updates
model.zipCode - User clicks submit —
validate()is called - dryvjs checks disablers — Skips fields where disablers return
true - Rules run in configuration order — The regex check
\d{5,5}passes instantly; sync rules execute after any async rules that appear before them - Async validators call the server —
callServer("/_v/cpk97zzro", ...)sends the zip code to the backend - Backend executes the C# expression — The same expression that would run during model binding
- Result is returned — The result is processed and mapped to validation state (this handling can be overridden)
validatable.zipCode.textupdates — Vue reactivity triggers the error displayvalidate()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
callServerURLs (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
- Dryv (.NET): github.com/mhusseini/dryv — C# validation rules with JavaScript translation
- dryvjs (monorepo): github.com/mhusseini/dryvjs — Client-side validation engine, Vue bindings, and Nuxt module
- dryvue: packages/dryvue — Vue 3 composables for reactive form validation
- nuxt-dryv: packages/nuxt-dryv — Official Nuxt 3 module with SSR,
$fetch, and auto-imports
Hero image credit: Photo by Jan van der Wolf on Pexels





