Form Validation Engine
Composable validation chains with Builder construction, Composite form groups for recursive validation, and Strategy for fail-fast vs collect-all modes. A beginner-friendly structural pattern showcase.
Key Abstractions
Chain of Responsibility : each validator checks one rule and delegates to next
Concrete validators : each encapsulates a single validation rule
Composite : contains fields and sub-groups, validates recursively
Builder : fluent API: field("email").required().email().maxLength(255).build()
Strategy : FailFast stops at first error, CollectAll gathers every error
Class Diagram
How It Works
A form validation engine answers one question for every field: does this value satisfy every rule? The trick is making rules composable, nestable, and configurable without turning the validator into a monolithic if/else block.
Each field gets a validator chain (Chain of Responsibility). The required check runs first. If it passes, the format check runs next. Then the length check. Each validator is a self-contained unit that knows one rule and nothing about the others. Adding a new rule means writing a new Validator subclass and linking it into the chain. Zero edits to existing code.
Forms have structure. A registration form contains a username, email, password, and an address group that itself contains street, city, and zip. The Composite pattern models this nesting. Validating the top-level form recursively validates every sub-group. You can nest groups arbitrarily deep (billing address inside order inside checkout) and the recursive validation handles it.
The Builder makes chain construction readable. Instead of manually instantiating five validators and linking them with set_next, you write field("email").required().email().maxLength(255).build(). The builder wires the chain internally and returns an immutable FormField.
Finally, ValidationMode (Strategy) determines how errors are collected. FailFastMode stops at the first error. That's ideal for server-side validation where you want fast rejection. CollectAllMode gathers every error, which is what you want for form UIs that highlight all problems at once with inline messages.
Requirements
Functional
- Define validators for common rules: required, email format, min/max length, regex pattern
- Chain validators so a field can have multiple rules checked in sequence
- Support nested form groups (address inside registration form) with recursive validation
- Fluent builder API for constructing field validation chains
- Two validation modes: fail-fast (stop on first error) and collect-all (gather every error)
- Return a result object with a boolean flag and a list of error messages
Non-Functional
- Validator chains should be immutable after construction, making them safe to share across requests
- Adding a new validation rule should require only a new class, no modification of existing validators
- The engine should handle arbitrarily deep form group nesting without special cases
Design Decisions
Couldn't we just iterate over a flat list of rules?
A flat list works for simple cases but does not give individual validators the power to short-circuit. With Chain of Responsibility, the RequiredValidator can stop the chain immediately when a field is empty. there is no point checking email format on an empty string. Each validator decides independently whether to continue, which keeps the logic local and the chain flexible.
Is the builder just syntactic sugar?
Without a builder, constructing a chain looks like: create RequiredValidator, create EmailValidator, create MaxLengthValidator, call setNext on each, wrap in FormField. Five lines of plumbing for three rules. The fluent builder compresses this into one readable line and guarantees the chain is properly linked. It also prevents mistakes like forgetting to link a validator or accidentally creating a circular chain.
What if we treat all fields as a flat list?
Real forms are hierarchical. A checkout form has a payment section and a shipping section, each with their own fields. Treating the whole form as a flat list of fields loses that structure and makes it impossible to validate or display errors for a section independently. Composite lets you validate any subtree. just the address group, or the entire form. with the same interface.
Could we use a boolean flag for fail-fast vs collect-all?
Fail-fast and collect-all are fundamentally different traversal strategies over the same data structure. Encoding this as a boolean flag (failFast: true) buried inside the form group would tangle the traversal logic with the data model. Strategy keeps the mode as a first-class object that you pass in at validation time, making it trivial to add new modes (e.g., validate-only-dirty-fields) without touching FormGroup.
Interview Follow-ups
- "How would you add async validation (e.g., checking email uniqueness against a database)?" Introduce an AsyncValidator subclass whose validate method returns a Future/Promise. The chain runner awaits each step. For performance, run independent async validators in parallel and merge results.
- "How would you handle cross-field validation (e.g., password must match confirmPassword)?" Add a CrossFieldValidator that receives the entire form data map instead of a single value. Attach it at the FormGroup level rather than on an individual field, so it runs after all field-level validators pass.
- "How would you sync validation rules between client and server?" Define rules in a shared schema format (JSON). Both client-side JavaScript and server-side code parse the same schema to build their respective validator chains. The schema becomes the single source of truth.
- "How would you support i18n error messages?" Replace hardcoded error strings with message keys (e.g.,
validation.email.invalid). Pass a locale-aware message resolver to each validator that maps keys to localized strings at runtime.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import re
# ── Result ──────────────────────────────────────────────────────────
@dataclass
class ValidationResult:
is_valid: bool = True
errors: list[str] = field(default_factory=list)
def merge(self, other: ValidationResult) -> ValidationResult:
return ValidationResult(
is_valid=self.is_valid and other.is_valid,
errors=self.errors + other.errors,
)
# ── Chain of Responsibility - Validators ────────────────────────────
class Validator(ABC):
def __init__(self) -> None:
self._next: Validator | None = None
def set_next(self, validator: Validator) -> Validator:
self._next = validator
return validator
def validate(self, field_name: str, value: str) -> ValidationResult:
result = self._check(field_name, value)
if not result.is_valid:
return result
if self._next:
return result.merge(self._next.validate(field_name, value))
return result
@abstractmethod
def _check(self, field_name: str, value: str) -> ValidationResult: ...
class RequiredValidator(Validator):
def _check(self, field_name: str, value: str) -> ValidationResult:
if not value or not value.strip():
return ValidationResult(False, [f"{field_name}: field is required"])
return ValidationResult()
class EmailValidator(Validator):
_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
def _check(self, field_name: str, value: str) -> ValidationResult:
if value and not self._PATTERN.match(value):
return ValidationResult(False, [f"{field_name}: invalid email format"])
return ValidationResult()
class MinLengthValidator(Validator):
def __init__(self, min_len: int) -> None:
super().__init__()
self._min_len = min_len
def _check(self, field_name: str, value: str) -> ValidationResult:
if value and len(value) < self._min_len:
return ValidationResult(
False, [f"{field_name}: must be at least {self._min_len} characters"]
)
return ValidationResult()
class MaxLengthValidator(Validator):
def __init__(self, max_len: int) -> None:
super().__init__()
self._max_len = max_len
def _check(self, field_name: str, value: str) -> ValidationResult:
if value and len(value) > self._max_len:
return ValidationResult(
False, [f"{field_name}: must be at most {self._max_len} characters"]
)
return ValidationResult()
class RegexValidator(Validator):
def __init__(self, pattern: str, message: str) -> None:
super().__init__()
self._pattern = re.compile(pattern)
self._message = message
def _check(self, field_name: str, value: str) -> ValidationResult:
if value and not self._pattern.match(value):
return ValidationResult(False, [f"{field_name}: {self._message}"])
return ValidationResult()
# ── FormField ───────────────────────────────────────────────────────
class FormField:
def __init__(self, name: str, chain: Validator) -> None:
self.name = name
self._chain = chain
def validate(self, value: str) -> ValidationResult:
return self._chain.validate(self.name, value)
# ── Composite - FormGroup ───────────────────────────────────────────
class FormGroup:
def __init__(self, name: str) -> None:
self.name = name
self._fields: dict[str, FormField] = {}
self._sub_groups: list[FormGroup] = []
def add_field(self, f: FormField) -> None:
self._fields[f.name] = f
def add_sub_group(self, group: FormGroup) -> None:
self._sub_groups.append(group)
def validate(self, data: dict, mode: ValidationMode) -> ValidationResult:
return mode.execute(self._fields, self._sub_groups, data)
# ── Builder - ValidationBuilder ─────────────────────────────────────
class ValidationBuilder:
def __init__(self) -> None:
self._name: str = ""
self._validators: list[Validator] = []
def field(self, name: str) -> ValidationBuilder:
self._name = name
return self
def required(self) -> ValidationBuilder:
self._validators.append(RequiredValidator())
return self
def email(self) -> ValidationBuilder:
self._validators.append(EmailValidator())
return self
def min_length(self, n: int) -> ValidationBuilder:
self._validators.append(MinLengthValidator(n))
return self
def max_length(self, n: int) -> ValidationBuilder:
self._validators.append(MaxLengthValidator(n))
return self
def pattern(self, regex: str, message: str) -> ValidationBuilder:
self._validators.append(RegexValidator(regex, message))
return self
def build(self) -> FormField:
# Link validators into a chain
for i in range(len(self._validators) - 1):
self._validators[i].set_next(self._validators[i + 1])
chain = self._validators[0] if self._validators else RequiredValidator()
ff = FormField(self._name, chain)
self._validators = []
return ff
# ── Strategy - ValidationMode ───────────────────────────────────────
class ValidationMode(ABC):
@abstractmethod
def execute(
self,
fields: dict[str, FormField],
sub_groups: list[FormGroup],
data: dict,
) -> ValidationResult: ...
class FailFastMode(ValidationMode):
def execute(self, fields, sub_groups, data) -> ValidationResult:
for name, ff in fields.items():
result = ff.validate(data.get(name, ""))
if not result.is_valid:
return result
for group in sub_groups:
group_data = data.get(group.name, {})
result = group.validate(group_data, self)
if not result.is_valid:
return result
return ValidationResult()
class CollectAllMode(ValidationMode):
def execute(self, fields, sub_groups, data) -> ValidationResult:
combined = ValidationResult()
for name, ff in fields.items():
combined = combined.merge(ff.validate(data.get(name, "")))
for group in sub_groups:
group_data = data.get(group.name, {})
combined = combined.merge(group.validate(group_data, self))
return combined
# ── Demo ────────────────────────────────────────────────────────────
if __name__ == "__main__":
b = ValidationBuilder()
# Build registration form
form = FormGroup("registration")
form.add_field(b.field("username").required().min_length(3).max_length(20).build())
form.add_field(b.field("email").required().email().max_length(255).build())
form.add_field(
b.field("password")
.required()
.min_length(8)
.pattern(r".*[A-Z].*", "must contain at least one uppercase letter")
.build()
)
# Nested address group (Composite)
address = FormGroup("address")
address.add_field(b.field("street").required().build())
address.add_field(b.field("city").required().min_length(2).build())
address.add_field(
b.field("zip").required().pattern(r"^\d{5}$", "must be a 5-digit zip code").build()
)
form.add_sub_group(address)
# ── Valid data ──────────────────────────────────────────────────
valid_data = {
"username": "alice",
"email": "alice@example.com",
"password": "Secret123",
"address": {"street": "123 Main St", "city": "Springfield", "zip": "62704"},
}
print("=== Valid Data (CollectAll) ===")
result = form.validate(valid_data, CollectAllMode())
print(f"Valid: {result.is_valid}, Errors: {result.errors}\n")
# ── Invalid data ────────────────────────────────────────────────
invalid_data = {
"username": "ab",
"email": "not-an-email",
"password": "short",
"address": {"street": "", "city": "X", "zip": "abc"},
}
print("=== Invalid Data - FailFast Mode ===")
result = form.validate(invalid_data, FailFastMode())
print(f"Valid: {result.is_valid}, Errors: {result.errors}\n")
print("=== Invalid Data - CollectAll Mode ===")
result = form.validate(invalid_data, CollectAllMode())
print(f"Valid: {result.is_valid}")
for err in result.errors:
print(f" - {err}")
print("\nAll operations completed successfully.")Common Mistakes
- ✗Giant if/else blocks for validation: adding a new rule requires editing the monolith
- ✗Not supporting nested form groups: address fields validated separately from the form they belong to
- ✗Hardcoding fail-fast behavior: some UIs need all errors at once for inline display
- ✗Mutable validator chains: sharing a chain between forms and then modifying it corrupts both
Key Points
- ✓Chain of Responsibility: validators link together. Required check passes to format check passes to length check. Each is independent.
- ✓Composite: form groups nest. Address group (street, city, zip) inside a user form. Validating the form validates all groups recursively.
- ✓Builder: fluent API makes rule construction readable and prevents invalid combinations. The builder produces an immutable validator chain.
- ✓Strategy: fail-fast returns on first error (fast feedback), collect-all returns every error at once (form UX).