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
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 import re
5
6
7 # ── Result ──────────────────────────────────────────────────────────
8 @dataclass
9 class ValidationResult:
10 is_valid: bool = True
11 errors: list[str] = field(default_factory=list)
12
13 def merge(self, other: ValidationResult) -> ValidationResult:
14 return ValidationResult(
15 is_valid=self.is_valid and other.is_valid,
16 errors=self.errors + other.errors,
17 )
18
19
20 # ── Chain of Responsibility - Validators ────────────────────────────
21 class Validator(ABC):
22 def __init__(self) -> None:
23 self._next: Validator | None = None
24
25 def set_next(self, validator: Validator) -> Validator:
26 self._next = validator
27 return validator
28
29 def validate(self, field_name: str, value: str) -> ValidationResult:
30 result = self._check(field_name, value)
31 if not result.is_valid:
32 return result
33 if self._next:
34 return result.merge(self._next.validate(field_name, value))
35 return result
36
37 @abstractmethod
38 def _check(self, field_name: str, value: str) -> ValidationResult: ...
39
40
41 class RequiredValidator(Validator):
42 def _check(self, field_name: str, value: str) -> ValidationResult:
43 if not value or not value.strip():
44 return ValidationResult(False, [f"{field_name}: field is required"])
45 return ValidationResult()
46
47
48 class EmailValidator(Validator):
49 _PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
50
51 def _check(self, field_name: str, value: str) -> ValidationResult:
52 if value and not self._PATTERN.match(value):
53 return ValidationResult(False, [f"{field_name}: invalid email format"])
54 return ValidationResult()
55
56
57 class MinLengthValidator(Validator):
58 def __init__(self, min_len: int) -> None:
59 super().__init__()
60 self._min_len = min_len
61
62 def _check(self, field_name: str, value: str) -> ValidationResult:
63 if value and len(value) < self._min_len:
64 return ValidationResult(
65 False, [f"{field_name}: must be at least {self._min_len} characters"]
66 )
67 return ValidationResult()
68
69
70 class MaxLengthValidator(Validator):
71 def __init__(self, max_len: int) -> None:
72 super().__init__()
73 self._max_len = max_len
74
75 def _check(self, field_name: str, value: str) -> ValidationResult:
76 if value and len(value) > self._max_len:
77 return ValidationResult(
78 False, [f"{field_name}: must be at most {self._max_len} characters"]
79 )
80 return ValidationResult()
81
82
83 class RegexValidator(Validator):
84 def __init__(self, pattern: str, message: str) -> None:
85 super().__init__()
86 self._pattern = re.compile(pattern)
87 self._message = message
88
89 def _check(self, field_name: str, value: str) -> ValidationResult:
90 if value and not self._pattern.match(value):
91 return ValidationResult(False, [f"{field_name}: {self._message}"])
92 return ValidationResult()
93
94
95 # ── FormField ───────────────────────────────────────────────────────
96 class FormField:
97 def __init__(self, name: str, chain: Validator) -> None:
98 self.name = name
99 self._chain = chain
100
101 def validate(self, value: str) -> ValidationResult:
102 return self._chain.validate(self.name, value)
103
104
105 # ── Composite - FormGroup ───────────────────────────────────────────
106 class FormGroup:
107 def __init__(self, name: str) -> None:
108 self.name = name
109 self._fields: dict[str, FormField] = {}
110 self._sub_groups: list[FormGroup] = []
111
112 def add_field(self, f: FormField) -> None:
113 self._fields[f.name] = f
114
115 def add_sub_group(self, group: FormGroup) -> None:
116 self._sub_groups.append(group)
117
118 def validate(self, data: dict, mode: ValidationMode) -> ValidationResult:
119 return mode.execute(self._fields, self._sub_groups, data)
120
121
122 # ── Builder - ValidationBuilder ─────────────────────────────────────
123 class ValidationBuilder:
124 def __init__(self) -> None:
125 self._name: str = ""
126 self._validators: list[Validator] = []
127
128 def field(self, name: str) -> ValidationBuilder:
129 self._name = name
130 return self
131
132 def required(self) -> ValidationBuilder:
133 self._validators.append(RequiredValidator())
134 return self
135
136 def email(self) -> ValidationBuilder:
137 self._validators.append(EmailValidator())
138 return self
139
140 def min_length(self, n: int) -> ValidationBuilder:
141 self._validators.append(MinLengthValidator(n))
142 return self
143
144 def max_length(self, n: int) -> ValidationBuilder:
145 self._validators.append(MaxLengthValidator(n))
146 return self
147
148 def pattern(self, regex: str, message: str) -> ValidationBuilder:
149 self._validators.append(RegexValidator(regex, message))
150 return self
151
152 def build(self) -> FormField:
153 # Link validators into a chain
154 for i in range(len(self._validators) - 1):
155 self._validators[i].set_next(self._validators[i + 1])
156 chain = self._validators[0] if self._validators else RequiredValidator()
157 ff = FormField(self._name, chain)
158 self._validators = []
159 return ff
160
161
162 # ── Strategy - ValidationMode ───────────────────────────────────────
163 class ValidationMode(ABC):
164 @abstractmethod
165 def execute(
166 self,
167 fields: dict[str, FormField],
168 sub_groups: list[FormGroup],
169 data: dict,
170 ) -> ValidationResult: ...
171
172
173 class FailFastMode(ValidationMode):
174 def execute(self, fields, sub_groups, data) -> ValidationResult:
175 for name, ff in fields.items():
176 result = ff.validate(data.get(name, ""))
177 if not result.is_valid:
178 return result
179 for group in sub_groups:
180 group_data = data.get(group.name, {})
181 result = group.validate(group_data, self)
182 if not result.is_valid:
183 return result
184 return ValidationResult()
185
186
187 class CollectAllMode(ValidationMode):
188 def execute(self, fields, sub_groups, data) -> ValidationResult:
189 combined = ValidationResult()
190 for name, ff in fields.items():
191 combined = combined.merge(ff.validate(data.get(name, "")))
192 for group in sub_groups:
193 group_data = data.get(group.name, {})
194 combined = combined.merge(group.validate(group_data, self))
195 return combined
196
197
198 # ── Demo ────────────────────────────────────────────────────────────
199 if __name__ == "__main__":
200 b = ValidationBuilder()
201
202 # Build registration form
203 form = FormGroup("registration")
204 form.add_field(b.field("username").required().min_length(3).max_length(20).build())
205 form.add_field(b.field("email").required().email().max_length(255).build())
206 form.add_field(
207 b.field("password")
208 .required()
209 .min_length(8)
210 .pattern(r".*[A-Z].*", "must contain at least one uppercase letter")
211 .build()
212 )
213
214 # Nested address group (Composite)
215 address = FormGroup("address")
216 address.add_field(b.field("street").required().build())
217 address.add_field(b.field("city").required().min_length(2).build())
218 address.add_field(
219 b.field("zip").required().pattern(r"^\d{5}$", "must be a 5-digit zip code").build()
220 )
221 form.add_sub_group(address)
222
223 # ── Valid data ──────────────────────────────────────────────────
224 valid_data = {
225 "username": "alice",
226 "email": "alice@example.com",
227 "password": "Secret123",
228 "address": {"street": "123 Main St", "city": "Springfield", "zip": "62704"},
229 }
230 print("=== Valid Data (CollectAll) ===")
231 result = form.validate(valid_data, CollectAllMode())
232 print(f"Valid: {result.is_valid}, Errors: {result.errors}\n")
233
234 # ── Invalid data ────────────────────────────────────────────────
235 invalid_data = {
236 "username": "ab",
237 "email": "not-an-email",
238 "password": "short",
239 "address": {"street": "", "city": "X", "zip": "abc"},
240 }
241
242 print("=== Invalid Data - FailFast Mode ===")
243 result = form.validate(invalid_data, FailFastMode())
244 print(f"Valid: {result.is_valid}, Errors: {result.errors}\n")
245
246 print("=== Invalid Data - CollectAll Mode ===")
247 result = form.validate(invalid_data, CollectAllMode())
248 print(f"Valid: {result.is_valid}")
249 for err in result.errors:
250 print(f" - {err}")
251
252 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).