Validation Hooks
Learn how to implement comprehensive validation logic using Stack9 entity hooks. This guide covers field-level validation, cross-field validation, async validation, custom rules, conditional logic, and integration with entity lifecycle hooks.
What You'll Build
In this guide, you'll implement validation for a Customer and Order Management System that includes:
- Field-level validation for individual fields
- Cross-field validation checking relationships between fields
- Async validation with database queries and external APIs
- Custom validation rules for complex business logic
- Error messages with proper formatting
- Conditional validation based on entity state
- Integration with entity hooks for comprehensive validation
Time to complete: 45-60 minutes
Prerequisites
- Understanding of Entity Hooks
- Basic TypeScript knowledge
- Familiarity with Stack9 entities
Understanding Validation in Stack9
Stack9 provides two levels of validation:
1. Declarative Validation (Entity Definition)
Simple field-level rules defined in JSON:
{
"fields": [
{
"label": "Email",
"key": "email_address",
"type": "TextField",
"validateRules": {
"required": true,
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"maxLength": 255
}
}
]
}
2. Programmatic Validation (Entity Hooks)
Complex validation logic in TypeScript:
export class ValidateCustomer extends CustomFunction {
async exec(): Promise<CustomFunctionResponse> {
// Complex validation logic here
return { valid: true, entity };
}
}
Use entity hooks for:
- Cross-field validation
- Database lookups (uniqueness checks)
- External API validation
- Complex business rules
- Conditional validation logic
Step 1: Field-Level Validation
Field-level validation focuses on individual field constraints.
Create Customer Entity
File: src/entities/custom/customer.json
{
"head": {
"name": "Customer",
"key": "customer",
"pluralisedName": "customers",
"icon": "UserOutlined",
"isActive": true
},
"fields": [
{
"label": "Email Address",
"key": "email_address",
"type": "TextField",
"validateRules": {
"required": true,
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"maxLength": 255
},
"index": true
},
{
"label": "Phone Number",
"key": "phone",
"type": "TextField",
"validateRules": {
"pattern": "^\\+?[1-9]\\d{1,14}$",
"maxLength": 20
}
},
{
"label": "Date of Birth",
"key": "dob",
"type": "DateField",
"validateRules": {
"required": true
}
},
{
"label": "Tax ID",
"key": "tax_id",
"type": "TextField",
"validateRules": {
"pattern": "^[0-9]{9}$",
"maxLength": 9
}
},
{
"label": "Credit Limit",
"key": "credit_limit",
"type": "NumericField",
"typeOptions": {
"decimals": 2,
"allowNegative": false
},
"validateRules": {
"min": 0,
"max": 1000000
}
},
{
"label": "Customer Type",
"key": "customer_type",
"type": "OptionSet",
"typeOptions": {
"values": ["Individual", "Business"]
},
"validateRules": {
"required": true
}
},
{
"label": "Business Name",
"key": "business_name",
"type": "TextField",
"validateRules": {
"maxLength": 200
}
},
{
"label": "Business Registration Number",
"key": "business_registration",
"type": "TextField",
"validateRules": {
"maxLength": 50
}
}
],
"hooks": []
}
These declarative validations are checked automatically before entity hooks run.
Step 2: Basic Hook Validation
Create a validation hook with field-level checks.
File: src/entity-hooks/customer.vat.ts
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import dayjs from 'dayjs';
import { DBCustomer } from '../models/stack9/Customer';
interface ValidationError {
field: string;
message: string;
}
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
entityName = 'customer';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation } = this.context;
// Only validate on create and update
if (![HookOperation.create, HookOperation.update].includes(operation)) {
return { valid: true, entity };
}
const errors: ValidationError[] = [];
// Validate age (must be 18+)
if (entity.dob) {
const age = dayjs().diff(dayjs(entity.dob), 'year');
if (age < 18) {
errors.push({
field: 'dob',
message: 'Customer must be at least 18 years old',
});
}
}
// Validate email format (additional check)
if (entity.email_address) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(entity.email_address)) {
errors.push({
field: 'email_address',
message: 'Invalid email format',
});
}
}
// Validate phone format (E.164 format)
if (entity.phone && entity.phone.trim() !== '') {
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
if (!phoneRegex.test(entity.phone.replace(/\s/g, ''))) {
errors.push({
field: 'phone',
message: 'Phone must be in E.164 format (e.g., +1234567890)',
});
}
}
// If there are validation errors, return them
if (errors.length > 0) {
return {
valid: false,
errors,
};
}
return { valid: true, entity };
}
}
Step 3: Cross-Field Validation
Validate relationships between multiple fields.
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import dayjs from 'dayjs';
import { DBCustomer } from '../models/stack9/Customer';
interface ValidationError {
field: string;
message: string;
}
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
entityName = 'customer';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, oldEntity } = this.context;
if (![HookOperation.create, HookOperation.update].includes(operation)) {
return { valid: true, entity };
}
const errors: ValidationError[] = [];
// Merge entity with old values for complete picture
const mergedEntity = operation === HookOperation.update
? { ...oldEntity, ...entity }
: entity;
// Cross-field validation: Business customers must have business details
if (mergedEntity.customer_type === 'Business') {
if (!mergedEntity.business_name || mergedEntity.business_name.trim() === '') {
errors.push({
field: 'business_name',
message: 'Business name is required for business customers',
});
}
if (!mergedEntity.business_registration || mergedEntity.business_registration.trim() === '') {
errors.push({
field: 'business_registration',
message: 'Business registration number is required for business customers',
});
}
if (!mergedEntity.tax_id || mergedEntity.tax_id.trim() === '') {
errors.push({
field: 'tax_id',
message: 'Tax ID is required for business customers',
});
}
}
// Cross-field validation: Individual customers shouldn't have business details
if (mergedEntity.customer_type === 'Individual') {
if (mergedEntity.business_name && mergedEntity.business_name.trim() !== '') {
errors.push({
field: 'business_name',
message: 'Business name should not be set for individual customers',
});
}
}
// Cross-field validation: Age and credit limit
if (mergedEntity.dob && mergedEntity.credit_limit) {
const age = dayjs().diff(dayjs(mergedEntity.dob), 'year');
// Under 21: max credit $5,000
if (age < 21 && mergedEntity.credit_limit > 5000) {
errors.push({
field: 'credit_limit',
message: 'Credit limit for customers under 21 cannot exceed $5,000',
});
}
// Age 21-25: max credit $25,000
if (age >= 21 && age < 25 && mergedEntity.credit_limit > 25000) {
errors.push({
field: 'credit_limit',
message: 'Credit limit for customers under 25 cannot exceed $25,000',
});
}
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, entity };
}
}
Step 4: Async Validation with Database Queries
Validate uniqueness and check related records.
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import dayjs from 'dayjs';
import { DBCustomer } from '../models/stack9/Customer';
interface ValidationError {
field: string;
message: string;
}
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
entityName = 'customer';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, oldEntity, db, services } = this.context;
if (![HookOperation.create, HookOperation.update].includes(operation)) {
return { valid: true, entity };
}
const errors: ValidationError[] = [];
const mergedEntity = operation === HookOperation.update
? { ...oldEntity, ...entity }
: entity;
// Async validation: Check email uniqueness
if (entity.email_address) {
const existingCustomer = await db.entity.knex('customer')
.where({ email_address: entity.email_address })
.whereNot({ id: mergedEntity.id || 0 })
.where({ _is_deleted: false })
.first();
if (existingCustomer) {
errors.push({
field: 'email_address',
message: 'This email address is already registered',
});
}
}
// Async validation: Check tax ID uniqueness
if (entity.tax_id && entity.tax_id.trim() !== '') {
const existingTaxId = await db.entity.knex('customer')
.where({ tax_id: entity.tax_id })
.whereNot({ id: mergedEntity.id || 0 })
.where({ _is_deleted: false })
.first();
if (existingTaxId) {
errors.push({
field: 'tax_id',
message: 'This tax ID is already registered',
});
}
}
// Async validation: Check for outstanding debt before increasing credit limit
if (operation === HookOperation.update && entity.credit_limit) {
if (entity.credit_limit > (oldEntity.credit_limit || 0)) {
// Check if customer has outstanding invoices
const outstandingInvoices = await db.entity.knex('invoice')
.where({ customer_id: mergedEntity.id })
.where({ status: 'overdue' })
.where({ _is_deleted: false })
.count('id as count')
.first();
if (outstandingInvoices && outstandingInvoices.count > 0) {
errors.push({
field: 'credit_limit',
message: 'Cannot increase credit limit while customer has overdue invoices',
});
}
}
}
// Async validation: Check business registration number format and validity
if (mergedEntity.customer_type === 'Business' && entity.business_registration) {
// Example: Verify format matches expected pattern
const registrationRegex = /^[A-Z]{2}[0-9]{8}$/;
if (!registrationRegex.test(entity.business_registration)) {
errors.push({
field: 'business_registration',
message: 'Invalid business registration format (expected: XX12345678)',
});
}
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, entity };
}
}
Step 5: Custom Validation Rules
Implement complex business rules with custom validators.
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import dayjs from 'dayjs';
import { DBCustomer } from '../models/stack9/Customer';
interface ValidationError {
field: string;
message: string;
}
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
entityName = 'customer';
/**
* Custom validator: Check if email domain is allowed
*/
private validateEmailDomain(email: string): ValidationError | null {
const blockedDomains = [
'tempmail.com',
'throwaway.email',
'guerrillamail.com',
];
const domain = email.split('@')[1]?.toLowerCase();
if (blockedDomains.includes(domain)) {
return {
field: 'email_address',
message: 'Temporary email addresses are not allowed',
};
}
return null;
}
/**
* Custom validator: Risk assessment based on multiple factors
*/
private async assessCustomerRisk(
entity: DBCustomer,
): Promise<{ riskScore: number; riskLevel: string }> {
let riskScore = 0;
// Factor 1: Age
if (entity.dob) {
const age = dayjs().diff(dayjs(entity.dob), 'year');
if (age < 21) riskScore += 10;
else if (age < 25) riskScore += 5;
}
// Factor 2: Credit limit
if (entity.credit_limit) {
if (entity.credit_limit > 50000) riskScore += 15;
else if (entity.credit_limit > 25000) riskScore += 10;
else if (entity.credit_limit > 10000) riskScore += 5;
}
// Factor 3: Email domain reputation
const domain = entity.email_address?.split('@')[1];
const freeEmailDomains = ['gmail.com', 'yahoo.com', 'hotmail.com'];
if (domain && freeEmailDomains.includes(domain)) {
riskScore += 5;
}
// Factor 4: Business vs Individual
if (entity.customer_type === 'Individual') {
riskScore += 3;
}
// Determine risk level
let riskLevel = 'Low';
if (riskScore >= 30) riskLevel = 'High';
else if (riskScore >= 15) riskLevel = 'Medium';
return { riskScore, riskLevel };
}
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, oldEntity, db } = this.context;
if (![HookOperation.create, HookOperation.update].includes(operation)) {
return { valid: true, entity };
}
const errors: ValidationError[] = [];
const mergedEntity = operation === HookOperation.update
? { ...oldEntity, ...entity }
: entity;
// Apply custom validators
if (entity.email_address) {
const emailDomainError = this.validateEmailDomain(entity.email_address);
if (emailDomainError) errors.push(emailDomainError);
}
// Calculate risk assessment (for new customers or credit limit changes)
if (operation === HookOperation.create || entity.credit_limit) {
const { riskScore, riskLevel } = await this.assessCustomerRisk(mergedEntity);
// Block high-risk customers with high credit limits
if (riskLevel === 'High' && (mergedEntity.credit_limit || 0) > 10000) {
errors.push({
field: 'credit_limit',
message: 'High-risk customers cannot have credit limits exceeding $10,000',
});
}
// Add risk assessment to entity
entity.risk_score = riskScore;
entity.risk_level = riskLevel;
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, entity };
}
}
Step 6: Conditional Validation
Apply different validation rules based on entity state.
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import dayjs from 'dayjs';
import { DBCustomer } from '../models/stack9/Customer';
interface ValidationError {
field: string;
message: string;
}
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
entityName = 'customer';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, oldEntity, db, user } = this.context;
if (![HookOperation.create, HookOperation.update].includes(operation)) {
return { valid: true, entity };
}
const errors: ValidationError[] = [];
const mergedEntity = operation === HookOperation.update
? { ...oldEntity, ...entity }
: entity;
// Conditional validation: Different rules for create vs update
if (operation === HookOperation.create) {
// On create: Require all essential fields
if (!entity.email_address) {
errors.push({
field: 'email_address',
message: 'Email address is required',
});
}
if (!entity.dob) {
errors.push({
field: 'dob',
message: 'Date of birth is required',
});
}
}
// Conditional validation: Only validate changed fields on update
if (operation === HookOperation.update) {
// If email is being changed, additional validation required
if (entity.email_address && entity.email_address !== oldEntity.email_address) {
// Require email verification flag to be reset
entity.email_verified = false;
entity.email_verification_sent_at = new Date();
}
// If credit limit is being changed, check authorization
if (entity.credit_limit && entity.credit_limit !== oldEntity.credit_limit) {
// Only managers can increase credit limit above $50,000
const isManager = user.roles?.includes('manager') || user.roles?.includes('admin');
if (entity.credit_limit > 50000 && !isManager) {
errors.push({
field: 'credit_limit',
message: 'Manager approval required for credit limits above $50,000',
});
}
// Log credit limit change
await db.entity.knex('audit_log').insert({
entity_type: 'customer',
entity_id: mergedEntity.id,
field_name: 'credit_limit',
old_value: oldEntity.credit_limit,
new_value: entity.credit_limit,
changed_by_user_id: user.id,
changed_at: new Date(),
});
}
}
// Conditional validation: Based on customer status
if (mergedEntity.status === 'suspended') {
// Suspended customers cannot have orders
const hasActiveOrders = await db.entity.knex('sales_order')
.where({ customer_id: mergedEntity.id })
.where({ status: 'pending' })
.where({ _is_deleted: false })
.count('id as count')
.first();
if (hasActiveOrders && hasActiveOrders.count > 0) {
errors.push({
field: 'status',
message: 'Cannot suspend customer with pending orders',
});
}
}
// Conditional validation: Time-based rules
if (operation === HookOperation.update) {
// Prevent changes to locked customers (accounts locked for 30 days after suspension)
if (oldEntity.status === 'suspended' && oldEntity.suspended_at) {
const lockPeriodDays = 30;
const suspendedDate = dayjs(oldEntity.suspended_at);
const lockEndDate = suspendedDate.add(lockPeriodDays, 'day');
if (dayjs().isBefore(lockEndDate)) {
errors.push({
field: 'status',
message: `Account is locked until ${lockEndDate.format('YYYY-MM-DD')}`,
});
}
}
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, entity };
}
}
Step 7: Complete Validation Hook Example
Here's a complete, production-ready validation hook combining all patterns:
File: src/entity-hooks/customer.vat.ts
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
SystemError,
} from '@april9/stack9-sdk';
import dayjs from 'dayjs';
import { DBCustomer } from '../models/stack9/Customer';
interface ValidationError {
field: string;
message: string;
}
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
entityName = 'customer';
/**
* Validate age requirement
*/
private validateAge(dob: string): ValidationError | null {
const age = dayjs().diff(dayjs(dob), 'year');
if (age < 18) {
return {
field: 'dob',
message: 'Customer must be at least 18 years old',
};
}
return null;
}
/**
* Validate email uniqueness
*/
private async validateEmailUniqueness(
email: string,
customerId?: number,
): Promise<ValidationError | null> {
const { db } = this.context;
const existing = await db.entity.knex('customer')
.where({ email_address: email })
.whereNot({ id: customerId || 0 })
.where({ _is_deleted: false })
.first();
if (existing) {
return {
field: 'email_address',
message: 'This email address is already registered',
};
}
return null;
}
/**
* Validate business customer requirements
*/
private validateBusinessCustomer(entity: DBCustomer): ValidationError[] {
const errors: ValidationError[] = [];
if (!entity.business_name || entity.business_name.trim() === '') {
errors.push({
field: 'business_name',
message: 'Business name is required for business customers',
});
}
if (!entity.business_registration || entity.business_registration.trim() === '') {
errors.push({
field: 'business_registration',
message: 'Business registration is required for business customers',
});
}
if (!entity.tax_id || entity.tax_id.trim() === '') {
errors.push({
field: 'tax_id',
message: 'Tax ID is required for business customers',
});
}
return errors;
}
/**
* Validate credit limit rules
*/
private async validateCreditLimit(
creditLimit: number,
dob: string,
customerId?: number,
): Promise<ValidationError[]> {
const { db } = this.context;
const errors: ValidationError[] = [];
// Age-based limits
const age = dayjs().diff(dayjs(dob), 'year');
if (age < 21 && creditLimit > 5000) {
errors.push({
field: 'credit_limit',
message: 'Credit limit for customers under 21 cannot exceed $5,000',
});
} else if (age < 25 && creditLimit > 25000) {
errors.push({
field: 'credit_limit',
message: 'Credit limit for customers under 25 cannot exceed $25,000',
});
}
// Check for outstanding debt
if (customerId) {
const overdueInvoices = await db.entity.knex('invoice')
.where({ customer_id: customerId })
.where({ status: 'overdue' })
.where({ _is_deleted: false })
.count('id as count')
.first();
if (overdueInvoices && overdueInvoices.count > 0) {
errors.push({
field: 'credit_limit',
message: 'Cannot modify credit limit while customer has overdue invoices',
});
}
}
return errors;
}
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, oldEntity, db, user, services, logger } = this.context;
// Only validate on create and update
if (![HookOperation.create, HookOperation.update].includes(operation)) {
return { valid: true, entity };
}
const errors: ValidationError[] = [];
// Merge entity for complete validation
const mergedEntity = operation === HookOperation.update
? { ...oldEntity, ...entity }
: entity;
try {
// 1. Field-level validation
if (entity.dob) {
const ageError = this.validateAge(entity.dob);
if (ageError) errors.push(ageError);
}
// 2. Async validation - Email uniqueness
if (entity.email_address) {
const emailError = await this.validateEmailUniqueness(
entity.email_address,
mergedEntity.id,
);
if (emailError) errors.push(emailError);
}
// 3. Cross-field validation - Business customer
if (mergedEntity.customer_type === 'Business') {
const businessErrors = this.validateBusinessCustomer(mergedEntity);
errors.push(...businessErrors);
}
// 4. Complex validation - Credit limit
if (entity.credit_limit && mergedEntity.dob) {
const creditErrors = await this.validateCreditLimit(
entity.credit_limit,
mergedEntity.dob,
mergedEntity.id,
);
errors.push(...creditErrors);
}
// 5. Conditional validation - Status changes
if (operation === HookOperation.update && entity.status === 'suspended') {
const activeOrders = await db.entity.knex('sales_order')
.where({ customer_id: mergedEntity.id })
.where({ status: 'pending' })
.where({ _is_deleted: false })
.count('id as count')
.first();
if (activeOrders && activeOrders.count > 0) {
errors.push({
field: 'status',
message: 'Cannot suspend customer with pending orders',
});
}
}
// 6. Authorization validation - Credit limit changes
if (
operation === HookOperation.update &&
entity.credit_limit &&
entity.credit_limit !== oldEntity.credit_limit
) {
const isManager = user.roles?.includes('manager') || user.roles?.includes('admin');
if (entity.credit_limit > 50000 && !isManager) {
errors.push({
field: 'credit_limit',
message: 'Manager approval required for credit limits above $50,000',
});
}
}
// Return validation result
if (errors.length > 0) {
logger.warn('Customer validation failed', {
customerId: mergedEntity.id,
operation,
errorCount: errors.length,
errors,
});
return {
valid: false,
errors,
};
}
// Auto-generate customer reference number on create
if (operation === HookOperation.create) {
entity.crn = await db.sequence.nextVal('customer', 1, 9999999);
}
// Calculate derived fields
if (entity.dob) {
entity.age = dayjs().diff(dayjs(entity.dob), 'year');
}
// Normalize data
if (entity.email_address) {
entity.email_address = entity.email_address.toLowerCase().trim();
}
if (entity.phone) {
entity.phone = entity.phone.replace(/\s/g, '');
}
logger.info('Customer validation passed', {
customerId: mergedEntity.id,
operation,
});
return {
valid: true,
entity,
};
} catch (error) {
logger.error('Customer validation error', {
error,
customerId: mergedEntity.id,
operation,
});
throw new SystemError('Validation failed due to system error');
}
}
}
Testing Your Validation Hooks
Test Basic Validation
# Test creating customer (should fail - under 18)
curl -X POST http://localhost:3000/api/customer \
-H "Content-Type: application/json" \
-d '{
"email_address": "john@example.com",
"dob": "2015-01-01",
"customer_type": "Individual"
}'
# Expected response includes validation errors
Test Cross-Field Validation
# Test business customer without required fields (should fail)
curl -X POST http://localhost:3000/api/customer \
-H "Content-Type: application/json" \
-d '{
"email_address": "business@example.com",
"dob": "1990-01-01",
"customer_type": "Business"
}'
# Expected response includes multiple field errors
Test Async Validation
# Create first customer
curl -X POST http://localhost:3000/api/customer \
-H "Content-Type: application/json" \
-d '{
"email_address": "unique@example.com",
"dob": "1990-01-01",
"customer_type": "Individual"
}'
# Try to create second customer with same email (should fail)
curl -X POST http://localhost:3000/api/customer \
-H "Content-Type: application/json" \
-d '{
"email_address": "unique@example.com",
"dob": "1992-05-15",
"customer_type": "Individual"
}'
# Expected response: email already registered error
Best Practices
1. Validation Order
Validate in order of cost:
- Simple field validation (cheapest)
- Cross-field validation
- Database queries
- External API calls (most expensive)
// Fail fast with cheap validations
if (!entity.email_address) {
return { valid: false, errors: [{ field: 'email_address', message: 'Required' }] };
}
// Then check expensive validations
const emailExists = await checkDatabase(entity.email_address);
2. Clear Error Messages
// Good - Specific and actionable
{
field: 'credit_limit',
message: 'Credit limit for customers under 21 cannot exceed $5,000'
}
// Bad - Vague
{
field: 'credit_limit',
message: 'Invalid value'
}
3. Separate Concerns
// Good - Validation and transformation separate
async exec() {
// Validate first
const errors = await this.validate(entity);
if (errors.length > 0) {
return { valid: false, errors };
}
// Transform after validation passes
const transformed = this.transform(entity);
return { valid: true, entity: transformed };
}
4. Handle Async Errors
// Good - Graceful degradation
try {
const valid = await externalAPI.validate(email);
} catch (error) {
logger.error('External validation failed', { error });
// Continue without external validation rather than blocking
return null;
}
5. Log Validation Failures
// Good - Log for debugging
if (errors.length > 0) {
logger.warn('Validation failed', {
entity: 'customer',
operation,
errorCount: errors.length,
errors,
userId: user.id,
});
}
Troubleshooting
Validation Not Running
Problem: Hook doesn't execute
Solutions:
- Check file is named correctly:
{entity_key}.vat.ts - Verify entity key matches
- Check operation type:
HookOperation.create,HookOperation.update - Restart dev server
Validation Errors Not Showing
Problem: Errors returned but not displayed
Solutions:
- Check error format matches
{ field: string, message: string } - Ensure
valid: falseis returned - Check frontend error handling
- Verify API response structure
Async Validation Too Slow
Problem: Database queries slow down saves
Solutions:
- Add database indexes on frequently queried fields
- Cache validation results
- Move non-critical validation to background jobs
- Use parallel validation where possible
Next Steps
You've mastered validation hooks in Stack9! Continue learning:
- Entity Hooks Reference - Complete hook documentation
- Building Custom Actions - Action types for workflows
- Building Workflows - Automation patterns
- Performance Optimization - Optimize validation performance
Summary
In this guide, you learned how to:
- Implement field-level validation with custom logic
- Perform cross-field validation checking multiple fields
- Execute async validation with database and API calls
- Create custom validation rules for complex business logic
- Format validation errors properly
- Apply conditional validation based on entity state
- Integrate validation with entity lifecycle hooks
Validation hooks ensure data integrity and enforce business rules at the database level, providing a robust foundation for your Stack9 applications.