Skip to content
Kumar Chandrachooda
.NET

Validation Theatre

The platform bundled a client-side validation framework - one file of it twice - and wired none of it, hand-stitching every real check inside modal callbacks with manual error classes and keyup character counters.

By Kumar Chandrachooda 11 Jul 2025 6 min read
A stage-prop shield standing in front of a hand-stitched check

jquery.validate.js appears twice in the same inputFiles array of the attendance platform's bundle manifest. Two lines, one file, no comment explaining either. The bundler did what concatenators do and stitched it in twice, so every page that loaded the core bundle registered the validation plugin and then registered it again over the top of itself. And after all of that, the framework validated nothing. Not one application form was wired to it.

That manifest is the one part one pulled apart — the concat-only bundles wearing .min.js extensions. This chapter is about what rode inside them: a complete client-side validation stack, shipped to every user on every page, that the application walked past on its way to hand-rolling every check it actually performed. I have come to think of the pattern as validation theatre — the props of a validation strategy on stage, the real work happening somewhere unlit.

A framework shipped twice and wired never

The core bundle's inputs, reconstructed with invented paths:

{
  "outputFileName": "wwwroot/bundles/attendance-core.min.js",
  "inputFiles": [
    "wwwroot/vendor/jquery/jquery-3.5.1.js",
    "wwwroot/vendor/validation/jquery.validate.js",
    "wwwroot/vendor/validation/jquery.validate.unobtrusive.js",
    "wwwroot/vendor/bootstrap/bootstrap.bundle.js",
    "wwwroot/vendor/validation/jquery.validate.js"
  ],
  "minify": { "enabled": false }
}

The duplicate is almost certainly a merge artefact — two people adding “the validation stuff” to the bundle from two branches — and it survived because nothing ever broke visibly. Re-running a jQuery plugin file mostly re-assigns the same functions, so the page worked, and a bundle nobody minified was a bundle nobody read.

The deeper finding is what a search of the views turns up. Unobtrusive validation works by attributes: the server emits data-val="true", data-val-required="..." and friends onto inputs, and the client library picks them up. Grep the platform's Razor views for data-val and every hit is inside the vendored libraries themselves — the plugin's own source and demos. No application view emits a single validation attribute. There are no validation tag helpers, no validation summaries, no ModelState-shaped error round-trip. And counting the second LibMan copy of the same plugins from part one, the platform shipped three copies of a validation library and used none of them.

The parent series visited the dependency graveyard on the server — AutoMapper with no maps, Hangfire with no jobs. This is the same grave dug client-side, with one difference that makes it worse: a NuGet package nobody calls costs build time and comprehension; a bundled script nobody calls is shipped to every browser on every page, twice, unminified.

Where validation actually lived

The platform did validate — users could not submit an empty reason for an office visit, could not overrun the length limit. The checks just lived nowhere near the framework. They lived inside modal callbacks. The platform's forms were mostly modals (a habit part three will examine from the styling side), and the modal library's preConfirm hook became the de facto validation engine. A fresh illustration, faithful to the idiom:

function promptVisitReason() {
    return Swal.fire({
        title: 'Reason for office visit',
        html: '<textarea id="visitReason" class="swal2-textarea"></textarea>' +
              '<span id="reasonCount" class="char-count">250 characters remaining</span>',
        showCancelButton: true,
        preConfirm: function () {
            var reason = $('#visitReason').val().trim();
            if (reason.length === 0) {
                $('#visitReason').addClass('input-error');
                Swal.showValidationMessage('Please enter a reason');
                return false;
            }
            if (reason.length > 250) {
                Swal.showValidationMessage('Reason must be 250 characters or fewer');
                return false;
            }
            return reason;
        }
    });
}

Read it slowly, because every line is a small tax that the shipped-but-unwired framework was designed to collect once.

  • Every rule is imperative. Required-ness and length are if statements, written by hand, per field, per modal. The framework's whole premise — declare the rule once, let the plumbing enforce it — is re-implemented as control flow, in dozens of callbacks, each slightly differently.
  • Error presentation is manual. The input-error class is added by hand here — and in the estate's style, removed by hand somewhere else, or not: several of these callbacks add the class and no code path ever takes it off, so the field stays red after the user fixes it, until the modal is destroyed.
  • The 250 is a magic number in triplicate. It must agree with an nvarchar(250) column and with whatever the server checks (where the server checks anything), and the only mechanism keeping the three in agreement is that nobody has changed one of them yet. Unobtrusive validation's best trick was making the server's declared rule generate the client's — one declaration, two enforcement points, messages included.
  • The rules are unreachable by tests. A validation rule inside an anonymous preConfirm closure inside a page script has no seam. You cannot unit-test it, list it, or reuse it; you can only click at it.

Counting characters by keyup

The character counter under that textarea was, of course, also hand-rolled:

$(document).on('keyup', '#visitReason', function () {
    var remaining = 250 - $(this).val().length;
    $('#reasonCount').text(remaining + ' characters remaining');
    $('#reasonCount').toggleClass('char-limit-near', remaining < 20);
});

It mostly works. It misses paste-by-mouse and cut events (keyup fires on neither), so the counter and the content could disagree until the next keystroke — a bug class the input event retired years earlier, to say nothing of the humble maxlength attribute, which enforces the limit with zero JavaScript. This is the theatre pattern in miniature: the platform-provided mechanism goes unused not because it was evaluated and rejected, but because hand-rolling was the habit and nobody was checking the props cupboard.

Server-side failures got the same treatment. AJAX error callbacks piped the raw response into the toast library — toastr.error(xhr.responseText) — which meant the “validation message” a user saw on a bad day was whatever the server happened to emit: sometimes a friendly string, sometimes a JSON fragment, once (per the error pages) a slab of HTML rendered as text inside a toast. There was no error contract, so the toast displayed whatever fell out of the pipe.

What unobtrusive validation was supposed to buy

It is worth stating plainly what the shipped-and-ignored stack would have given this exact application, because the gap is the lesson:

public class VisitReasonModel
{
    [Required(ErrorMessage = "Please enter a reason")]
    [StringLength(250, ErrorMessage = "Reason must be 250 characters or fewer")]
    public string Reason { get; set; }
}

One declaration. The tag helpers render it as data-val-* attributes; the bundled-twice client library enforces it before the request; model binding enforces it again on the server via ModelState, immune to a user with dev tools open; and the error message lives in exactly one place in exactly one language. The length limit changes once, where the model is defined, and both enforcement points hear about it. That is the entire pitch, and this codebase carried every component of it — attributes available, helpers available, scripts bundled — with none of the wiring.

Why didn't it happen? My honest reconstruction is architectural, not ignorant: the platform's forms were not Razor forms. They were HTML strings assembled inside JavaScript and injected into modals — and unobtrusive validation assumes markup the framework rendered, inside a form element it can adopt. Retrofitting it into string-built modal bodies is genuinely awkward. So validation grew where the forms actually were, imperative and duplicated, and the framework stayed in the bundle “in case”, forever. The forms' location decided the validation architecture, and nobody noticed a decision had been made. Validation theatre is rarely a choice; it is the default outcome of building forms somewhere your validation stack cannot see.

The ledger

  • The duplicate bundle line — remove it; a plugin concatenated twice is a merge scar that re-registers itself and doubles the dead weight.
  • Three copies, zero calls — adopt the framework or delete all three copies; the worst state is the one shipped, where the dependency list claims a validation strategy the app does not have.
  • Rules as closures — every hand-rolled preConfirm check is untestable, unlistable, and duplicated; even without the framework, rules deserve named functions in one file.
  • The magic 250 — a limit that must agree across JS, server and schema, kept in sync by luck.
  • toastr.error(xhr.responseText) — an error channel with no contract; decide what errors look like before deciding how to toast them.
  • keyup countersmaxlength first, input event second, hand-rolled counters a distant third.

The validation was hand-stitched because the forms lived in strings — and the forms lived in strings because of how this platform did CSS and markup composition, which is its own archaeology. Next: the stylesheet that got a sequel, CSS by Accretion.