DEV Community

Tummala Krishna Kishore
Tummala Krishna Kishore

Posted on

Don't add a new rule kind until you've written the same check twice

Someone asked me last week to stop users typing backticks into a product-name field. Fine. Then someone asked for the same thing on a host-address field, then a description field. Three tickets, same shape, three weeks apart. That's the moment a form-validation ask stops being a bug fix and starts being a design question: do I bolt a fourth one-off regex onto a fourth page, or do I finally admit this is a pattern and give it a name?

We'd already answered that question once, for a different reason, and built a generic field-validation engine instead of scattering regex checks across pages. It lives in fieldValidation.ts as a rule registry, plus a useFieldValidation hook every form imports. Each field gets a small config object (required, minLength, pattern, restrictedChars, whatever it needs), and the hook hands back live sanitization, blur-time errors, and a submit-time gate. No new regex logic per page. That part isn't the interesting decision. The interesting decision is the rule we set for adding to the registry, and it's the same rule that governs a much bigger call: when do you carve a new bounded context out of an existing service, versus keep extending the one you have?

Here's the rule, stated plainly, because plain rules are falsifiable and vague ones aren't: don't add a new named preset to the registry until the same logic is needed in two or more places. Before that threshold, you route the requirement through custom, an escape hatch that takes a function and returns an error string or null. One-off logic goes through custom. Only once you've written functionally the same custom function twice do you graduate it into a first-class rule kind with its own key in the table.

This sounds obvious written down. It is not obvious in practice, because the pressure runs the other way. The moment you build one generic engine, every future request looks like it deserves to live inside it. That's the whole point of building the thing. Someone will ask for phone-number formatting and you'll be tempted to add a phoneNumber preset on request one, because you already have the registry open in your editor and adding a case feels cheap. It isn't cheap. Every named preset is a permanent addition to the table other developers now have to read, understand, and choose correctly between, forever. A registry with nine well-chosen rule kinds is a tool. A registry with thirty barely-distinguished rule kinds, half of them used exactly once, is a maze. The custom escape hatch exists specifically to absorb that pressure: it lets you say yes to the requirement today without saying yes to permanent API surface today.

Restricted characters were the exception that proves the rule, and it's worth being honest about why. restrictedChars shipped as a named preset from day one, before any second request existed, because we already knew, with high confidence, that every field touching a name or free-text description in this app was one incident away from someone pasting a backtick or a quote into it. That's not premature abstraction. That's abstraction based on a claim you can actually defend: "every text field in this category needs this," not "this one field might need this again someday." The distinction that matters isn't first-use-versus-second-use as a mechanical counter. It's whether you can name the population of future callers with a straight face. If you can, build the preset now. If you're guessing, wait for the second ticket.

That's the exact same test that decides whether a new feature deserves its own bounded context or belongs inside an existing one. Nobody splits out a reporting service the first time someone asks for a report. You extend the orders service, ship it, move on. The split becomes obviously correct the moment reporting's actual shape (its scaling profile, its data-access pattern, its release cadence) diverges from the service it's living inside, and that divergence is almost never visible from request one. It shows up on the second or third time you're fighting the host service's deploy schedule to ship a reporting-only change. Same test, wildly different stakes: a validation preset used wrong costs you a slightly messier table. A bounded context extracted wrong, or extracted too late, costs you months of migration and a service boundary drawn around the wrong seam.

The part people get backwards is thinking restraint is the safe default and formalizing is the risky move. It's the opposite, in both directions, depending which side you're guessing on. Refusing to name a pattern you can already see clearly (insisting on three more custom callbacks that are secretly the same function with different variable names) is its own tax, paid in duplicated logic and inconsistent error messages across pages. Naming a pattern before you can see it is a different tax, paid in an API surface nobody asked for and a table entry that quietly becomes dead weight. Both failure modes look identical from the outside: an engineer added complexity. Only the timing tells you which one happened.

One thing the custom hook made easier than I expected: cross-field validation. A confirmPassword check needs ctx.allValues.password, and because custom receives the whole values object, not just its own field's value, that fell out of the escape hatch for free — no separate cross-field validation path bolted on later. That's a small tell that the escape hatch was designed with enough headroom to actually defer decisions, rather than just deferring them until you're forced to special-case them anyway. An escape hatch that can't handle the requirements you're deferring isn't an escape hatch, it's a delay.

So the actual discipline isn't "prefer simplicity" or "prefer generality," which is the kind of answer that fits on a poster and helps nobody at 4pm on a Thursday. It's: know which side of the two-or-more-instances line you're standing on, for the thing in front of you, right now. If you're not sure, that uncertainty is itself the answer. Route it through the escape hatch and find out.

Top comments (0)