Understanding the Validasi Engine
The Validasi engine is the core component that orchestrates the validation process. This guide provides a deep dive into how the engine processes data, applies transformations, executes rules, and manages validation flow.
Engine Architecture
The ValidasiEngine<T> is responsible for:
- Preprocessing: Transforming raw input into the expected type
- Type checking: Ensuring type safety
- Rule execution: Running validation rules sequentially
- Context management: Maintaining validation state
- Result generation: Producing structured validation results
- Caching: Optimizing repeated validations
Validation Pipeline
When you call validate(), the engine executes a multi-stage pipeline:
Input Value
↓
┌───────────────┐
│ Cache Lookup │ ← Check if already validated
└───────┬───────┘
↓ (miss)
┌───────────────┐
│ Preprocess │ ← Transform raw input to type T
└───────┬───────┘
↓
┌───────────────┐
│ Type Check │ ← Verify value is T or T?
└───────┬───────┘
↓
┌───────────────┐
│ Rule Loop │ ← Execute each rule sequentially
│ - runOnNull? │
│ - apply() │
│ - stopped? │
└───────┬───────┘
↓
┌───────────────┐
│ Build Result │ ← Create ValidasiResult<T>
└───────┬───────┘
↓
┌───────────────┐
│ Cache Store │ ← Store result for future lookups
└───────┬───────┘
↓
Return ResultStage 1: Cache Lookup
Before running validation, the engine checks if this input was validated before.
final cacheKey = cacheEnabled ? computeCacheKey(originalInput) : null;
if (cacheKey != null && cacheEnabled) {
final cached = EngineCache.get(this, cacheKey);
if (cached != null) {
return cached as ValidasiResult<T>;
}
}Key Points:
- Cache lookup happens with the original input before preprocessing
- If cache hit, entire pipeline is skipped
- Cache is enabled by default (
cacheEnabled: true)
Example:
final schema = ValidasiEngine<int>(rules: [MoreThan(0)]);
// First call: full pipeline
final result1 = schema.validate(5);
// Second call: cache hit, immediate return
final result2 = schema.validate(5);Stage 2: Preprocessing
Preprocessing transforms the raw input into the expected type T. This is useful for type conversion or data normalization.
if (preprocess != null) {
final result = preprocess!.tryTransform(value);
if (!result.isValid) {
return ValidasiResult<T>.error(
ValidationError(
rule: 'Preprocess',
message: 'Failed to preprocess value',
details: {
'exception': result.error?.toString() ?? 'Unknown error',
},
),
);
}
value = result.data;
}Key Points:
- Preprocessing runs before type checking
- Preprocessing failures create a validation error with rule name
'Preprocess' - Transformed value is used for subsequent stages
Example:
// Convert string to int before validation
final schema = ValidasiEngine<int>(
preprocess: ValidasiTransformation.tryParse(int.parse),
rules: [MoreThan(0)],
);
// Input: "42" (String)
// After preprocess: 42 (int)
// Then: validate against rules
final result = schema.validate("42");
print(result.isValid); // true
print(result.data); // 42Adding Preprocessing
Use withPreprocess() to add preprocessing to an existing engine:
final baseEngine = ValidasiEngine<int>(
rules: [MoreThan(0), LessThan(100)],
);
final engineWithPreprocess = baseEngine.withPreprocess(
ValidasiTransformation.tryParse(int.parse),
);
engineWithPreprocess.validate("50"); // Parses "50" to 50Stage 3: Type Checking
After preprocessing, the engine verifies the value matches type T:
if (value is! T?) {
return ValidasiResult<T>.error(ValidationError(
rule: 'TypeCheck',
message: 'Expected type $T, got ${value.runtimeType}',
details: {'value': value},
));
}Key Points:
- Type check happens after preprocessing
- The check is
T?(nullable) to allow null values - Type errors create a validation error with rule name
'TypeCheck' - Validation stops immediately on type mismatch
Example:
final schema = ValidasiEngine<int>(rules: [MoreThan(0)]);
// Type mismatch: expects int, got String
final result = schema.validate("hello");
print(result.isValid); // false
print(result.errors.first.rule); // 'TypeCheck'Stage 4: Context Creation
The engine creates a ValidationContext<T> to manage validation state:
final context = ValidationContext(value: value);The context provides:
value: Current value being validated (mutable)errors: List of validation errorsisStopped: Flag to stop rule execution- Methods:
setValue(),addError(),stop(),requireValue
Example:
// Context is created internally, but rules interact with it:
class CustomRule extends Rule<String> {
@override
void apply(ValidationContext<String> context) {
// Access current value
final value = context.value;
// Add error
context.addError(ValidationError(
rule: 'CustomRule',
message: 'Validation failed',
));
// Transform value
context.setValue(value?.toUpperCase());
// Stop further validation
context.stop();
}
}Stage 5: Rule Loop
The engine iterates through all rules and applies them sequentially:
for (final rule in rules ?? <Rule<T>>[]) {
// Skip null values unless rule explicitly handles them
if (context.value == null && !rule.runOnNull) {
continue;
}
// Apply the rule
rule.apply(context);
// Stop if requested
if (context.isStopped) {
break;
}
}Rule Execution Order
Rules execute in the order they're defined:
final schema = ValidasiEngine<String>(
rules: [
MinLength(5), // 1st: Check minimum length
MaxLength(20), // 2nd: Check maximum length
Transform((s) => s?.trim()), // 3rd: Transform
MinLength(3), // 4th: Check trimmed length
],
);Order matters! Transformations affect subsequent validations:
final schema = ValidasiEngine<String>(
rules: [
MinLength(10), // Checks original length
Transform((s) => s?.trim()), // Removes whitespace
MaxLength(5), // Checks trimmed length
],
);
// " hi " → original length 8 (fails MinLength)
// But would pass after trimThe runOnNull Check
Before applying each rule, the engine checks if the value is null:
if (context.value == null && !rule.runOnNull) {
continue; // Skip this rule
}Rules with runOnNull = true:
Required: Must check null to add errorNullable: Allows null valuesTransform: May transform null to non-null
Rules with runOnNull = false (default):
- All type-specific rules (string, number, list, map)
- These assume a non-null value exists
Example:
final schema = ValidasiEngine<String>(
rules: [
Nullable(), // runOnNull = true → executes
MinLength(5), // runOnNull = false → skipped if null
],
);
// Value is null
schema.validate(null); // Valid! MinLength skippedContext Value Changes
Rules can modify the value during validation:
final schema = ValidasiEngine<String>(
rules: [
Transform((s) => s?.toUpperCase()), // Changes value
MinLength(5), // Validates transformed value
],
);
final result = schema.validate("hello");
print(result.data); // "HELLO" (transformed)Flow:
- Initial value:
"hello" - After Transform:
"HELLO"(context.value changed) - MinLength validates:
"HELLO"(5 chars, passes)
Stopping Validation
Rules can stop the validation chain using context.stop():
class StopOnError extends Rule<String> {
@override
void apply(ValidationContext<String> context) {
if (context.requireValue.isEmpty) {
context.addError(ValidationError(
rule: 'StopOnError',
message: 'Empty value',
));
context.stop(); // Prevents further rules from running
}
}
}
final schema = ValidasiEngine<String>(
rules: [
StopOnError(), // Stops if empty
MinLength(5), // Won't execute if stopped
MaxLength(20), // Won't execute if stopped
],
);
schema.validate(""); // Only StopOnError executesWhen to stop:
- Critical validation failure (no point continuing)
- Performance optimization (skip expensive checks)
- Error cascades (prevent redundant errors)
Stage 6: Result Generation
After all rules execute (or stop), the engine creates the result:
final ValidasiResult<T> result = ValidasiResult<T>(
isValid: context.errors.isEmpty,
data: context.value,
errors: context.errors,
);Result properties:
isValid:trueif no errors were addeddata: Final value (may be transformed by rules)errors: List of all validation errors
Example:
final schema = ValidasiEngine<String>(
rules: [
Required(),
MinLength(5),
MaxLength(10),
],
);
final result = schema.validate("hi");
print(result.isValid); // false
print(result.data); // "hi"
print(result.errors.length); // 1 (MinLength failed)
print(result.errors.first.rule); // "MinLength"Stage 7: Cache Storage
If caching is enabled, the result is stored for future lookups:
if (cacheKey != null && cacheEnabled) {
EngineCache.set(this, cacheKey, result);
}Key Points:
- Cache stores the final result (including transformed value and errors)
- Cache key is computed from original input (before preprocessing)
- Cache is per-engine-instance
Example:
final schema = ValidasiEngine<String>(
rules: [Transform((s) => s?.toUpperCase()), MinLength(5)],
);
// First validation: full pipeline
final result1 = schema.validate("hello");
print(result1.data); // "HELLO"
// Second validation: cache hit
final result2 = schema.validate("hello");
print(result2.data); // "HELLO" (from cache, transform not re-run)Complete Example: Engine Flow
Let's trace a complete validation through all stages:
final schema = ValidasiEngine<int>(
preprocess: ValidasiTransformation.tryParse(int.parse),
rules: [
Required(),
MoreThan(0),
LessThan(100),
Transform((n) => n! * 2),
LessThan(150),
],
);
final result = schema.validate("42");Execution trace:
Cache Lookup: No cached result for
"42"Preprocess:
- Input:
"42"(String) - Transform:
int.parse("42") - Output:
42(int)
- Input:
Type Check:
- Check:
42 is int?→ ✓ true
- Check:
Context Creation:
context.value = 42context.errors = []
Rule Loop:
- Required: value = 42 (not null) → passes
- MoreThan(0): 42 > 0 → ✓ passes
- LessThan(100): 42 < 100 → ✓ passes
- Transform: 42 * 2 = 84 →
context.value = 84 - LessThan(150): 84 < 150 → ✓ passes
Result Generation:
isValid = true(no errors)data = 84(transformed)errors = []
Cache Storage: Store result for
"42"Return:
ValidasiResult<int>(isValid: true, data: 84)
Advanced Patterns
Conditional Rule Application
Use Having to conditionally apply rules:
final schema = ValidasiEngine<String>(
rules: [
Having(
(value) => value?.startsWith('@') ?? false,
[MinLength(5)], // Only if starts with '@'
),
],
);
schema.validate("@hi"); // Fails MinLength
schema.validate("hi"); // Passes (Having condition false)Error Accumulation
The engine accumulates all errors, not just the first:
final schema = ValidasiEngine<String>(
rules: [
MinLength(5),
MaxLength(2), // Changed to 2
InlineRule((s) => s?.contains('@') ?? false, message: 'Must contain @'),
],
);
final result = schema.validate("hi");
// Multiple rules fail:
print(result.errors.length); // 2 (MinLength and InlineRule)Preprocessing for Normalization
Use preprocessing to normalize data before validation:
// Normalize whitespace before validation
final schema = ValidasiEngine<String>(
preprocess: ValidasiTransformation<String, String>((input) {
if (input is String) {
return input.trim().toLowerCase();
}
throw FormatException('Expected string');
}),
rules: [
MinLength(3),
MaxLength(20),
],
);
// " HELLO " → "hello" → validates as "hello"
final result = schema.validate(" HELLO ");
print(result.data); // "hello"Early Termination for Performance
Stop validation early to avoid expensive checks:
final schema = ValidasiEngine<String>(
rules: [
InlineRule((s) {
if (s == null || s.isEmpty) {
return false; // Failed
}
return true;
}, message: 'Required'),
InlineRule((s) {
// Expensive check only if previous passed
return expensiveValidation(s);
}),
],
);Or use explicit stopping:
class RequiredWithStop extends Rule<String> {
@override
bool get runOnNull => true;
@override
void apply(ValidationContext<String> context) {
if (context.value == null || context.value!.isEmpty) {
context.addError(ValidationError(
rule: 'Required',
message: 'Field is required',
));
context.stop(); // Skip expensive rules
}
}
}