Entity Hooks
Learn how to implement custom business logic using Entity Hooks in Stack9. This guide covers validation, data transformation, calculated fields, triggering workflows, error handling, and performance optimization with real-world examples.
What You'll Build
In this guide, you'll implement Entity Hooks for a Subscription Management System with:
- beforeInsert/afterInsert hooks for initialization
- beforeUpdate/afterUpdate hooks for change tracking
- beforeDelete/afterDelete hooks for cleanup
- Custom validation rules
- Data transformation and normalization
- Calculated and derived fields
- Workflow triggers from hooks
- Error handling patterns
- Performance optimization
Time to complete: 45-60 minutes
Prerequisites
- Completed Building a CRUD Application guide
- Basic understanding of TypeScript
- Familiarity with async/await patterns
Understanding Entity Hooks
Entity Hooks (VAT - Validation After Trigger) execute custom logic during entity lifecycle events.
Hook Lifecycle
┌─────────────────────────────────────────────────┐
│ Client Request (Create/Update/Delete Entity) │
└────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 1. Basic Validation (Field types, required) │
└────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 2. Entity Hook Execution (Your Custom Logic) │
│ - Validate business rules │
│ - Transform data │
│ - Calculate fields │
│ - Trigger side effects │
└────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 3. Database Transaction │
│ - Save entity │
│ - Commit or rollback │
└────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 4. After Hooks & Automations │
│ - afterCreate/afterUpdate/afterDelete │
│ - Automation workflows │
└───────────────────────────────── ────────────────┘
Hook Types
| Operation | When It Runs | Use Cases |
|---|---|---|
beforeInsert | Before creating new record | Generate IDs, set defaults, validate |
afterInsert | After record created | Send notifications, create related records |
beforeUpdate | Before updating record | Validate changes, track history |
afterUpdate | After record updated | Sync to external systems, trigger workflows |
beforeDelete | Before deleting record | Prevent deletion, cleanup dependencies |
afterDelete | After record deleted | Archive data, notify users |
Important: Hooks run synchronously during the database transaction. Keep them fast!
Step 1: Create Entity Definitions
First, create entities for our subscription system.
Subscription Entity
Create src/entities/custom/subscription.json:
{
"head": {
"name": "Subscription",
"key": "subscription",
"pluralisedName": "subscriptions",
"icon": "SyncOutlined"
},
"fields": [
{
"label": "Subscription Number",
"key": "subscription_number",
"type": "NumericField",
"behaviourOptions": {
"readOnly": true
}
},
{
"label": "Customer",
"key": "customer_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "customer"
},
"typeOptions": {
"label": "name"
},
"validateRules": {
"required": true
}
},
{
"label": "Product",
"key": "product_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "product"
},
"typeOptions": {
"label": "name"
},
"validateRules": {
"required": true
}
},
{
"label": "Status",
"key": "status",
"type": "OptionSet",
"typeOptions": {
"values": ["Active", "Suspended", "Cancelled", "Expired"]
},
"defaultValue": "Active"
},
{
"label": "Start Date",
"key": "start_date",
"type": "DateField",
"validateRules": {
"required": true
}
},
{
"label": "End Date",
"key": "end_date",
"type": "DateField"
},
{
"label": "Renewal Date",
"key": "renewal_date",
"type": "DateField",
"behaviourOptions": {
"readOnly": true
}
},
{
"label": "Billing Cycle",
"key": "billing_cycle",
"type": "OptionSet",
"typeOptions": {
"values": ["Monthly", "Quarterly", "Annually"]
},
"defaultValue": "Monthly",
"validateRules": {
"required": true
}
},
{
"label": "Amount",
"key": "amount",
"type": "NumericField",
"typeOptions": {
"decimals": 2
},
"validateRules": {
"required": true,
"min": 0
}
},
{
"label": "Total Paid",
"key": "total_paid",
"type": "NumericField",
"typeOptions": {
"decimals": 2
},
"defaultValue": 0,
"behaviourOptions": {
"readOnly": true
}
},
{
"label": "Payment Method",
"key": "payment_method",
"type": "OptionSet",
"typeOptions": {
"values": ["Credit Card", "Bank Transfer", "PayPal"]
}
},
{
"label": "Auto Renew",
"key": "auto_renew",
"type": "Checkbox",
"defaultValue": true
},
{
"label": "Cancellation Reason",
"key": "cancellation_reason",
"type": "TextField"
},
{
"label": "Cancelled At",
"key": "cancelled_at",
"type": "DateField"
}
],
"hooks": ["subscription"]
}
Payment Entity
Create src/entities/custom/payment.json:
{
"head": {
"name": "Payment",
"key": "payment",
"pluralisedName": "payments"
},
"fields": [
{
"label": "Payment Number",
"key": "payment_number",
"type": "TextField",
"behaviourOptions": {
"readOnly": true
}
},
{
"label": "Subscription",
"key": "subscription_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "subscription"
},
"validateRules": {
"required": true
}
},
{
"label": "Amount",
"key": "amount",
"type": "NumericField",
"typeOptions": {
"decimals": 2
},
"validateRules": {
"required": true,
"min": 0
}
},
{
"label": "Status",
"key": "status",
"type": "OptionSet",
"typeOptions": {
"values": ["Pending", "Completed", "Failed", "Refunded"]
},
"defaultValue": "Pending"
},
{
"label": "Transaction ID",
"key": "transaction_id",
"type": "TextField"
},
{
"label": "Payment Date",
"key": "payment_date",
"type": "DateField",
"defaultValue": "now"
}
],
"hooks": ["payment"]
}
Step 2: Implement Subscription Hook - beforeInsert
Create src/entity-hooks/subscription.vat.ts:
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
SystemError,
} from '@april9/stack9-sdk';
import { DBSubscription } from '../models/stack9/Subscription';
import { DBProduct } from '../models/stack9/Product';
export class ValidateSubscription extends CustomFunction {
constructor(private context: CustomFunctionContext<DBSubscription>) {
super();
}
entityName = 'subscription';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, db, services, oldEntity } = this.context;
// beforeInsert: Generate subscription number and set defaults
if (operation === HookOperation.create) {
return await this.handleCreate(entity);
}
// beforeUpdate: Handle status changes and validations
if (operation === HookOperation.update) {
return await this.handleUpdate(entity, oldEntity);
}
// beforeDelete: Prevent deletion if payments exist
if (operation === HookOperation.delete) {
return await this.handleDelete(entity);
}
return { valid: true, entity };
}
/**
* Handle subscription creation
* - Generate unique subscription number
* - Calculate renewal date
* - Set product-based defaults
* - Validate customer eligibility
*/
private async handleCreate(
entity: DBSubscription
): Promise<CustomFunctionResponse> {
const { db, services } = this.context;
// 1. Generate subscription number
const counter = await db.sequence.nextVal('subscription', 1, 999999);
entity.subscription_number = counter;
// 2. Fetch product details
const product = await services.entity.findOne<DBProduct>('product', {}, {
$where: { id: entity.product_id, _is_deleted: false }
});
if (!product) {
return {
valid: false,
errors: [{
field: 'product_id',
message: 'Product not found'
}]
};
}
// 3. Check if product is active
if (product.status !== 'Active') {
return {
valid: false,
errors: [{
field: 'product_id',
message: 'Cannot create subscription for inactive product'
}]
};
}
// 4. Set amount from product if not provided
if (!entity.amount && product.price) {
entity.amount = product.price;
}
// 5. Calculate renewal date based on billing cycle
entity.renewal_date = this.calculateRenewalDate(
entity.start_date,
entity.billing_cycle
);
// 6. Validate customer doesn't have active subscription for same product
const existingSubscription = await services.entity.findOne(
'subscription',
{},
{
$where: {
customer_id: entity.customer_id,
product_id: entity.product_id,
status: 'Active',
_is_deleted: false
}
}
);
if (existingSubscription) {
return {
valid: false,
errors: [{
field: 'customer_id',
message: 'Customer already has an active subscription for this product'
}]
};
}
// 7. Validate date ranges
if (entity.end_date && entity.start_date > entity.end_date) {
return {
valid: false,
errors: [{
field: 'end_date',
message: 'End date must be after start date'
}]
};
}
return {
valid: true,
entity
};
}
/**
* Handle subscription updates
* - Validate status transitions
* - Track cancellations
* - Recalculate renewal dates
* - Trigger workflows on status change
*/
private async handleUpdate(
entity: DBSubscription,
oldEntity: DBSubscription
): Promise<CustomFunctionResponse> {
const { services } = this.context;
// Merge entity with old values to get complete picture
const mergedEntity = {
...oldEntity,
...entity
};
// 1. Validate status transitions
const statusValidation = this.validateStatusTransition(
oldEntity.status,
mergedEntity.status
);
if (!statusValidation.valid) {
return {
valid: false,
errors: [statusValidation.error]
};
}
// 2. Handle cancellation
if (
mergedEntity.status === 'Cancelled' &&
oldEntity.status !== 'Cancelled'
) {
entity.cancelled_at = new Date();
// Require cancellation reason
if (!mergedEntity.cancellation_reason) {
return {
valid: false,
errors: [{
field: 'cancellation_reason',
message: 'Cancellation reason is required'
}]
};
}
// Trigger cancellation workflow
await services.message.realtime.sendMessage({
queue: 'subscription_cancelled',
body: JSON.stringify({
subscriptionId: mergedEntity.id,
reason: mergedEntity.cancellation_reason
}),
entityType: 'subscription',
entityId: mergedEntity.id
});
}
// 3. Handle reactivation
if (
mergedEntity.status === 'Active' &&
oldEntity.status === 'Suspended'
) {
// Clear cancellation data
entity.cancellation_reason = null;
entity.cancelled_at = null;
// Recalculate renewal date
entity.renewal_date = this.calculateRenewalDate(
new Date(),
mergedEntity.billing_cycle
);
// Trigger reactivation workflow
await services.message.realtime.sendMessage({
queue: 'subscription_reactivated',
body: JSON.stringify({
subscriptionId: mergedEntity.id
}),
entityType: 'subscription',
entityId: mergedEntity.id
});
}
// 4. Recalculate renewal date if billing cycle changed
if (
entity.billing_cycle &&
entity.billing_cycle !== oldEntity.billing_cycle
) {
entity.renewal_date = this.calculateRenewalDate(
mergedEntity.start_date,
entity.billing_cycle
);
}
// 5. Prevent changes to completed subscriptions
if (oldEntity.status === 'Expired') {
return {
valid: false,
errors: [{
field: 'status',
message: 'Cannot modify expired subscription'
}]
};
}
return {
valid: true,
entity
};
}
/**
* Handle subscription deletion
* - Prevent if payments exist
* - Soft delete only
*/
private async handleDelete(
entity: DBSubscription
): Promise<CustomFunctionResponse> {
const { services } = this.context;
// Check for existing payments
const payments = await services.entity.search('payment', {
$where: {
subscription_id: entity.id,
_is_deleted: false
},
$limit: 1
});
if (payments.length > 0) {
return {
valid: false,
errors: [{
field: 'id',
message: 'Cannot delete subscription with existing payments. Cancel it instead.'
}]
};
}
return {
valid: true,
entity
};
}
/**
* Calculate renewal date based on billing cycle
*/
private calculateRenewalDate(
startDate: Date | string,
billingCycle: string
): Date {
const date = new Date(startDate);
switch (billingCycle) {
case 'Monthly':
date.setMonth(date.getMonth() + 1);
break;
case 'Quarterly':
date.setMonth(date.getMonth() + 3);
break;
case 'Annually':
date.setFullYear(date.getFullYear() + 1);
break;
}
return date;
}
/**
* Validate status transitions
*/
private validateStatusTransition(
oldStatus: string,
newStatus: string
): { valid: boolean; error?: any } {
// Define valid transitions
const validTransitions: Record<string, string[]> = {
'Active': ['Suspended', 'Cancelled', 'Expired'],
'Suspended': ['Active', 'Cancelled', 'Expired'],
'Cancelled': [], // Cannot transition from cancelled
'Expired': [] // Cannot transition from expired
};
// Allow same status (no change)
if (oldStatus === newStatus) {
return { valid: true };
}
const allowedTransitions = validTransitions[oldStatus] || [];
if (!allowedTransitions.includes(newStatus)) {
return {
valid: false,
error: {
field: 'status',
message: `Invalid status transition from ${oldStatus} to ${newStatus}`
}
};
}
return { valid: true };
}
}
Step 3: Implement Payment Hook - Track Totals
Create src/entity-hooks/payment.vat.ts:
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import { DBPayment } from '../models/stack9/Payment';
import { DBSubscription } from '../models/stack9/Subscription';
export class ValidatePayment extends CustomFunction {
constructor(private context: CustomFunctionContext<DBPayment>) {
super();
}
entityName = 'payment';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, services, oldEntity } = this.context;
try {
// beforeInsert: Generate payment number and validate
if (operation === HookOperation.create) {
return await this.handleCreate(entity);
}
// afterInsert: Update subscription total_paid
if (operation === HookOperation.afterCreate) {
await this.updateSubscriptionTotal(entity);
return { valid: true, entity };
}
// beforeUpdate: Validate status changes
if (operation === HookOperation.update) {
return await this.handleUpdate(entity, oldEntity);
}
// afterUpdate: Recalculate subscription total if amount changed
if (operation === HookOperation.afterUpdate) {
if (entity.amount !== oldEntity.amount || entity.status !== oldEntity.status) {
await this.updateSubscriptionTotal(entity);
}
return { valid: true, entity };
}
return { valid: true, entity };
} catch (error) {
return {
valid: false,
errors: [`Payment validation failed: ${error.message}`]
};
}
}
/**
* Handle payment creation
*/
private async handleCreate(
entity: DBPayment
): Promise<CustomFunctionResponse> {
const { db, services } = this.context;
// 1. Generate payment number
const counter = await db.sequence.nextVal('payment', 1, 9999999);
entity.payment_number = `PAY-${counter.toString().padStart(7, '0')}`;
// 2. Validate subscription exists and is active
const subscription = await services.entity.findOne<DBSubscription>(
'subscription',
{},
{
$where: { id: entity.subscription_id, _is_deleted: false }
}
);
if (!subscription) {
return {
valid: false,
errors: [{
field: 'subscription_id',
message: 'Subscription not found'
}]
};
}
if (subscription.status !== 'Active') {
return {
valid: false,
errors: [{
field: 'subscription_id',
message: 'Cannot create payment for inactive subscription'
}]
};
}
// 3. Validate amount matches subscription amount
if (entity.amount !== subscription.amount) {
return {
valid: false,
errors: [{
field: 'amount',
message: `Payment amount must match subscription amount (${subscription.amount})`
}]
};
}
// 4. Set payment date if not provided
if (!entity.payment_date) {
entity.payment_date = new Date();
}
return {
valid: true,
entity
};
}
/**
* Handle payment updates
*/
private async handleUpdate(
entity: DBPayment,
oldEntity: DBPayment
): Promise<CustomFunctionResponse> {
// Prevent changing completed payments
if (oldEntity.status === 'Completed' && entity.status !== 'Refunded') {
return {
valid: false,
errors: [{
field: 'status',
message: 'Cannot modify completed payment (only refunds allowed)'
}]
};
}
// Prevent changing failed payments
if (oldEntity.status === 'Failed') {
return {
valid: false,
errors: [{
field: 'status',
message: 'Cannot modify failed payment (create new payment instead)'
}]
};
}
return {
valid: true,
entity
};
}
/**
* Update subscription total_paid amount
* This runs in afterCreate and afterUpdate
*/
private async updateSubscriptionTotal(entity: DBPayment): Promise<void> {
const { db } = this.context;
// Calculate total of all completed payments for this subscription
const result = await db('payments')
.where({
subscription_id: entity.subscription_id,
status: 'Completed',
_is_deleted: false
})
.sum('amount as total');
const totalPaid = result[0]?.total || 0;
// Update subscription
await db('subscriptions')
.where({ id: entity.subscription_id })
.update({
total_paid: totalPaid,
_updated_at: new Date()
});
}
}
Step 4: Implement Customer Hook - Data Normalization
Create src/entity-hooks/customer.vat.ts:
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import { DBCustomer } from '../models/stack9/Customer';
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
entityName = 'customer';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, services, oldEntity } = this.context;
// Skip delete operations
if (operation === HookOperation.delete) {
return { valid: true, entity };
}
try {
// 1. Normalize email (lowercase, trim)
if (entity.email) {
entity.email = entity.email.toLowerCase().trim();
}
// 2. Normalize phone number
if (entity.phone) {
entity.phone = this.normalizePhone(entity.phone);
}
// 3. Generate customer reference number (CRN) on create
if (operation === HookOperation.create) {
const counter = await this.context.db.sequence.nextVal(
'customer',
1,
999999
);
entity.crn = counter;
}
// 4. Calculate age from date of birth
if (entity.date_of_birth) {
entity.age = this.calculateAge(entity.date_of_birth);
}
// 5. Validate email uniqueness
if (entity.email) {
const existingCustomer = await services.entity.findOne(
'customer',
{},
{
$where: {
email: entity.email,
id: { $ne: entity.id || 0 },
_is_deleted: false
}
}
);
if (existingCustomer) {
return {
valid: false,
errors: [{
field: 'email',
message: 'Email address already in use'
}]
};
}
}
// 6. Set full name for searching
if (entity.first_name && entity.last_name) {
entity.full_name = `${entity.first_name} ${entity.last_name}`;
}
// 7. Trigger deactivation workflow if becoming inactive
if (
operation === HookOperation.update &&
!entity.is_active &&
oldEntity.is_active
) {
await services.message.realtime.sendMessage({
queue: 'customer_deactivated',
body: JSON.stringify({
customerId: entity.id
}),
entityType: 'customer',
entityId: entity.id
});
}
return {
valid: true,
entity
};
} catch (error) {
return {
valid: false,
errors: [`Customer validation failed: ${error.message}`]
};
}
}
/**
* Normalize phone number to standard format
*/
private normalizePhone(phone: string): string {
// Remove all non-numeric characters
let cleaned = phone.replace(/\D/g, '');
// Format as (XXX) XXX-XXXX for US numbers
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
}
// Return as-is if not standard format
return phone;
}
/**
* Calculate age from date of birth
*/
private calculateAge(dateOfBirth: Date | string): number {
const dob = new Date(dateOfBirth);
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age;
}
}
Step 5: Complex Validation with Cross-Entity Checks
Create src/entity-hooks/order.vat.ts:
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
SystemError,
} from '@april9/stack9-sdk';
import { DBOrder } from '../models/stack9/Order';
import { DBOrderItem } from '../models/stack9/OrderItem';
import { DBProduct } from '../models/stack9/Product';
export class ValidateOrder extends CustomFunction {
constructor(private context: CustomFunctionContext<DBOrder>) {
super();
}
entityName = 'order';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, services } = this.context;
if (operation === HookOperation.create) {
return await this.handleCreate(entity);
}
if (operation === HookOperation.update) {
return await this.handleUpdate(entity);
}
return { valid: true, entity };
}
/**
* Handle order creation with complex validation
*/
private async handleCreate(
entity: DBOrder
): Promise<CustomFunctionResponse> {
const { db, services } = this.context;
try {
// 1. Generate order number
const counter = await db.sequence.nextVal('order', 1, 9999999);
entity.order_number = `ORD-${counter.toString().padStart(7, '0')}`;
// 2. Validate order items exist
if (!entity.order_items || entity.order_items.length === 0) {
return {
valid: false,
errors: [{
field: 'order_items',
message: 'Order must have at least one item'
}]
};
}
// 3. Validate stock availability for all items
const stockValidation = await this.validateStockAvailability(
entity.order_items
);
if (!stockValidation.valid) {
return stockValidation;
}
// 4. Calculate order totals
const totals = await this.calculateOrderTotals(entity.order_items);
entity.subtotal = totals.subtotal;
entity.tax = totals.tax;
entity.total = totals.total;
// 5. Validate minimum order value
if (entity.total < 10) {
return {
valid: false,
errors: [{
field: 'total',
message: 'Minimum order value is $10.00'
}]
};
}
// 6. Set order date if not provided
if (!entity.order_date) {
entity.order_date = new Date();
}
return {
valid: true,
entity
};
} catch (error) {
throw new SystemError(`Order creation failed: ${error.message}`);
}
}
/**
* Handle order updates
*/
private async handleUpdate(
entity: DBOrder
): Promise<CustomFunctionResponse> {
// Prevent modifications to completed orders
if (entity._original?.status === 'Completed') {
return {
valid: false,
errors: [{
field: 'status',
message: 'Cannot modify completed orders'
}]
};
}
// Recalculate totals if items changed
if (entity.order_items) {
const totals = await this.calculateOrderTotals(entity.order_items);
entity.subtotal = totals.subtotal;
entity.tax = totals.tax;
entity.total = totals.total;
}
return {
valid: true,
entity
};
}
/**
* Validate stock availability for all order items
*/
private async validateStockAvailability(
orderItems: DBOrderItem[]
): Promise<CustomFunctionResponse> {
const { services } = this.context;
const errors: any[] = [];
for (const item of orderItems) {
const product = await services.entity.findOne<DBProduct>(
'product',
{},
{
$where: { id: item.product_id, _is_deleted: false }
}
);
if (!product) {
errors.push({
field: `order_items[${item.product_id}]`,
message: `Product not found`
});
continue;
}
if (product.status !== 'Active') {
errors.push({
field: `order_items[${item.product_id}]`,
message: `Product "${product.name}" is not available`
});
}
if (product.stock_quantity < item.quantity) {
errors.push({
field: `order_items[${item.product_id}]`,
message: `Insufficient stock for "${product.name}". Available: ${product.stock_quantity}, Requested: ${item.quantity}`
});
}
}
if (errors.length > 0) {
return {
valid: false,
errors
};
}
return { valid: true };
}
/**
* Calculate order totals
*/
private async calculateOrderTotals(
orderItems: DBOrderItem[]
): Promise<{
subtotal: number;
tax: number;
total: number;
}> {
const { services } = this.context;
let subtotal = 0;
for (const item of orderItems) {
const product = await services.entity.findOne<DBProduct>(
'product',
{},
{
$where: { id: item.product_id }
}
);
if (product) {
const lineTotal = product.price * item.quantity;
subtotal += lineTotal;
}
}
const tax = subtotal * 0.1; // 10% tax
const total = subtotal + tax;
return {
subtotal: Number(subtotal.toFixed(2)),
tax: Number(tax.toFixed(2)),
total: Number(total.toFixed(2))
};
}
}
Step 6: Register Entity Hooks
Update src/index.ts:
import { ValidateSubscription } from './entity-hooks/subscription.vat';
import { ValidatePayment } from './entity-hooks/payment.vat';
import { ValidateCustomer } from './entity-hooks/customer.vat';
import { ValidateOrder } from './entity-hooks/order.vat';
export const hooks = [
ValidateSubscription,
ValidatePayment,
ValidateCustomer,
ValidateOrder,
];
Common Hook Patterns
Pattern 1: Generate Unique Identifiers
// Generate sequential numbers
if (operation === HookOperation.create) {
const counter = await db.sequence.nextVal('entity_name', 1, 999999);
entity.reference_number = `REF-${counter}`;
}
// Generate UUID
import { v4 as uuidv4 } from 'uuid';
if (operation === HookOperation.create) {
entity.uuid = uuidv4();
}
Pattern 2: Set Timestamps and Audit Fields
// Track who created/updated
if (operation === HookOperation.create) {
entity.created_by = this.context.user.id;
entity.created_at = new Date();
}
if (operation === HookOperation.update) {
entity.updated_by = this.context.user.id;
entity.updated_at = new Date();
}
Pattern 3: Calculated Fields
// Calculate derived values
entity.total_price = entity.quantity * entity.unit_price;
entity.discount_amount = entity.total_price * (entity.discount_percent / 100);
entity.final_price = entity.total_price - entity.discount_amount;
// Calculate from related data
const relatedRecords = await services.entity.search('related_entity', {
$where: { parent_id: entity.id }
});
entity.related_count = relatedRecords.length;
Pattern 4: Data Normalization
// Normalize email
if (entity.email) {
entity.email = entity.email.toLowerCase().trim();
}
// Normalize phone
if (entity.phone) {
entity.phone = entity.phone.replace(/\D/g, ''); // Remove non-digits
}
// Normalize name
if (entity.name) {
entity.name = entity.name.trim().replace(/\s+/g, ' '); // Remove extra spaces
}
Pattern 5: Conditional Validation
// Require field based on condition
if (entity.status === 'Cancelled' && !entity.cancellation_reason) {
return {
valid: false,
errors: [{
field: 'cancellation_reason',
message: 'Cancellation reason is required when cancelling'
}]
};
}
// Validate date ranges
if (entity.end_date && entity.start_date > entity.end_date) {
return {
valid: false,
errors: [{
field: 'end_date',
message: 'End date must be after start date'
}]
};
}
Pattern 6: Cross-Entity Validation
// Check related entity exists
const relatedEntity = await services.entity.findOne('related_entity', {}, {
$where: { id: entity.related_id, _is_deleted: false }
});
if (!relatedEntity) {
return {
valid: false,
errors: [{
field: 'related_id',
message: 'Related entity not found'
}]
};
}
// Check for duplicates
const duplicate = await services.entity.findOne('entity', {}, {
$where: {
unique_field: entity.unique_field,
id: { $ne: entity.id || 0 },
_is_deleted: false
}
});
if (duplicate) {
return {
valid: false,
errors: [{
field: 'unique_field',
message: 'Value already exists'
}]
};
}
Pattern 7: Trigger Workflows
// Trigger workflow on status change
if (
operation === HookOperation.update &&
entity.status !== oldEntity.status
) {
await services.message.realtime.sendMessage({
queue: 'handle_status_change',
body: JSON.stringify({
entityId: entity.id,
oldStatus: oldEntity.status,
newStatus: entity.status
}),
entityType: 'entity_name',
entityId: entity.id
});
}
Pattern 8: Prevent Invalid Operations
// Prevent deletion if has dependencies
if (operation === HookOperation.delete) {
const dependents = await services.entity.search('dependent_entity', {
$where: { parent_id: entity.id, _is_deleted: false },
$limit: 1
});
if (dependents.length > 0) {
return {
valid: false,
errors: [{
field: 'id',
message: 'Cannot delete entity with existing dependencies'
}]
};
}
}
Error Handling
Pattern 1: Graceful Error Handling
async exec(): Promise<CustomFunctionResponse> {
try {
// Your validation logic here
return { valid: true, entity };
} catch (error) {
// Log error
this.context.logger.error(`Validation error: ${error.message}`);
// Return validation failure
return {
valid: false,
errors: [`Operation failed: ${error.message}`]
};
}
}
Pattern 2: Specific Error Messages
// Provide clear, actionable error messages
return {
valid: false,
errors: [
{
field: 'email',
message: 'Email address is invalid. Please use format: user@example.com'
},
{
field: 'phone',
message: 'Phone number must be 10 digits'
}
]
};
Pattern 3: System Errors for Critical Failures
import { SystemError } from '@april9/stack9-sdk';
// Throw SystemError for critical issues
if (!criticalDependency) {
throw new SystemError('Critical dependency missing: external service unavailable');
}
Performance Optimization
1. Minimize Database Queries
// Bad: Multiple queries in loop
for (const item of items) {
const product = await db('products').where({ id: item.product_id }).first();
// Process product
}
// Good: Single query with IN clause
const productIds = items.map(i => i.product_id);
const products = await db('products').whereIn('id', productIds);
const productMap = new Map(products.map(p => [p.id, p]));
for (const item of items) {
const product = productMap.get(item.product_id);
// Process product
}
2. Use Indexes
// Ensure fields used in WHERE clauses are indexed
// Add to entity definition:
{
"label": "Email",
"key": "email",
"type": "TextField",
"index": true // Creates database index
}
3. Cache Frequently Accessed Data
// Cache lookup data
private categoryCache: Map<number, any> = new Map();
async getCategory(id: number) {
if (this.categoryCache.has(id)) {
return this.categoryCache.get(id);
}
const category = await this.context.services.entity.findOne(
'category',
{},
{ $where: { id } }
);
this.categoryCache.set(id, category);
return category;
}
4. Avoid Heavy Operations in Hooks
// Bad: Slow operation in hook
async exec(): Promise<CustomFunctionResponse> {
// Don't do this - it's too slow!
await this.sendEmail(entity);
await this.callExternalAPI(entity);
return { valid: true, entity };
}
// Good: Trigger async workflow instead
async exec(): Promise<CustomFunctionResponse> {
// Quick validation only
// Trigger async workflow for slow operations
await this.context.services.message.realtime.sendMessage({
queue: 'process_entity',
body: JSON.stringify({ entityId: entity.id }),
entityType: 'entity_name',
entityId: entity.id
});
return { valid: true, entity };
}
5. Use Specific Selects
// Bad: Select all fields
const entity = await services.entity.findOne('entity', {}, {
$where: { id: entityId }
});
// Good: Select only needed fields
const entity = await services.entity.findOne('entity', {}, {
$select: ['id', 'name', 'status'],
$where: { id: entityId }
});
Best Practices
Hook Design
- Keep hooks fast - Hooks run synchronously, keep them under 100ms
- Validate early - Return validation errors as soon as possible
- Use clear error messages - Help users understand what went wrong
- Avoid external API calls - Use workflows for slow operations
- Test thoroughly - Test all validation paths
Data Integrity
- Validate relationships - Ensure foreign keys point to valid records
- Check for duplicates - Prevent duplicate unique values
- Enforce business rules - Implement domain-specific constraints
- Maintain referential integrity - Prevent orphaned records
- Use transactions - Ensure data consistency
Code Organization
- One hook per entity - Keep entity-specific logic together
- Extract helper methods - Reuse common validation logic
- Document complex logic - Explain why, not just what
- Use TypeScript types - Leverage type safety
- Handle errors gracefully - Don't let exceptions crash the system
Troubleshooting
Hook Not Executing
Problem: Hook doesn't run when expected
Solutions:
- Check hook is registered in
src/index.ts - Verify
entityNamematches entity key exactly - Ensure entity has
hooksarray in definition - Check operation type (create/update/delete)
- Look for errors in logs
Validation Fails Silently
Problem: Entity saves even though validation should fail
Solutions:
- Ensure hook returns
{ valid: false, errors: [...] } - Check errors array is not empty
- Verify hook is actually running (add logging)
- Check for try/catch swallowing errors
- Ensure proper error format
Performance Issues
Problem: Hooks taking too long
Solutions:
- Profile hook execution time
- Reduce database queries
- Use indexes on queried fields
- Cache frequently accessed data
- Move slow operations to workflows
Circular Dependencies
Problem: Hook triggers another hook in loop
Solutions:
- Avoid updating entities in afterCreate/afterUpdate hooks
- Use flags to prevent recursion
- Move logic to workflows
- Check relationship cascades
- Review hook chain carefully
Next Steps
You've mastered Entity Hooks! Continue learning:
- Building Workflows - Async operations and automation
- Implementing Business Logic - Action types and complex logic
- Performance Optimization - Optimize hook performance
- Custom Queries - Advanced database patterns
Summary
In this guide, you learned how to:
- Implement all hook types (beforeInsert, afterInsert, etc.)
- Validate business rules and data integrity
- Transform and normalize data
- Calculate derived fields
- Trigger workflows from hooks
- Handle errors gracefully
- Optimize hook performance
- Follow best practices
Entity Hook capabilities implemented:
- Sequential ID generation
- Cross-entity validation
- Status transition rules
- Data normalization
- Calculated fields
- Workflow triggers
- Relationship validation
- Duplicate prevention
- Custom business rules