Skip to main content

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:

  1. Simple field validation (cheapest)
  2. Cross-field validation
  3. Database queries
  4. 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:

  1. Check file is named correctly: {entity_key}.vat.ts
  2. Verify entity key matches
  3. Check operation type: HookOperation.create, HookOperation.update
  4. Restart dev server

Validation Errors Not Showing

Problem: Errors returned but not displayed

Solutions:

  1. Check error format matches { field: string, message: string }
  2. Ensure valid: false is returned
  3. Check frontend error handling
  4. Verify API response structure

Async Validation Too Slow

Problem: Database queries slow down saves

Solutions:

  1. Add database indexes on frequently queried fields
  2. Cache validation results
  3. Move non-critical validation to background jobs
  4. Use parallel validation where possible

Next Steps

You've mastered validation hooks in Stack9! Continue learning:

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.