Dryv Cross Stack Validation
Photo by Jan van der Wolf on Pexels
Cross-Stack Validation with Dryv — Write Rules Once in C#, Execute Everywhere
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 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:
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" : nullcompiles 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
useDryvand 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:
dryvRulereturns the translated JavaScript for all model types. Used at build time.dryvRuleParametersreturns 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 (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 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:
loadParametersis 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 usinguseState.callServerhas a working default in$fetch. Override it when you need custom headers, tokens or error handling.parseDateis only needed if your rules compare dates. The C# translator emits date parsing calls that the client has to evaluate.formatis 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 bindingvalidatable— a proxy where each field has.text,.typeand.groupfor error display, plus.valuefor getting and setting the model value (usable instead ofmodel.*for binding)validate()— triggers a full validation and returns a result objectvalid— a computed boolean for overall form validitydirty— whether any field has been modifiedrevert()— reverts the model to its last committed stateclear()— clears all validation errorscommit()— commits the current model state as the new baseline forrevert()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:
- 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 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
callServerURLs, 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”.
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
Category: Tools and Automation