Entity Hooks
Entity Hooks allow you to inject custom TypeScript business logic into the entity lifecycle. Use hooks to validate data, transform values, generate sequence numbers, and trigger side effects—all before or after database operations.
What are Entity Hooks?
Entity hooks are TypeScript classes that execute at specific points in an entity's lifecycle:
- Before database transactions - Validate, transform, or enrich data
- After database transactions - Trigger notifications, update related records
- On specific operations - Create, update, or delete
When you define a hook, Stack9 automatically:
- ✅ Calls your hook at the right time
- ✅ Provides context (entity data, user, services)
- ✅ Handles errors gracefully
- ✅ Manages transactions
- ✅ Logs execution
Why Use Entity Hooks?
Without Hooks (API Route Approach)
// Scattered logic, hard to reuse
app.post('/api/customer', async (req, res) => {
const customer = req.body;
// Generate CRN
const crn = await generateCRN();
customer.crn = crn;
// Calculate age
if (customer.dob) {
customer.age = calculateAge(customer.dob);
}
// Validate email
if (!isValidEmail(customer.email)) {
return res.status(400).json({ error: 'Invalid email' });
}
await db.insert('customers', customer);
res.json({ success: true });
});
With Entity Hooks (Stack9 Approach)
export class ValidateCustomer extends CustomFunction {
entityName = 'customer';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation } = this.context;
// Auto-generate CRN on create
const crn = operation === HookOperation.create
? { crn: await this.context.db.sequence.nextVal('customer', 1, 999999) }
: {};
// Calculate age from DOB
const age = entity.dob
? { age: dayjs().diff(dayjs(entity.dob), 'year') }
: {};
return {
valid: true,
entity: { ...entity, ...crn, ...age }
};
}
}
Benefits:
- ✅ Centralized: All business logic in one place
- ✅ Reusable: Same validation everywhere
- ✅ Testable: Easy to unit test
- ✅ Type-safe: Full TypeScript support
- ✅ Transactional: Automatic rollback on errors
Hook File Structure
Hooks are defined in src/entity-hooks/{entity_name}.vat.ts:
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import { DBEntityType } from '../models/stack9/EntityType';
export class ValidateEntityName extends CustomFunction {
constructor(private context: CustomFunctionContext<DBEntityType>) {
super();
}
entityName = 'entity_key';
async exec(): Promise<CustomFunctionResponse> {
// Your business logic here
return { valid: true, entity: this.context.entity };
}
}
Hook Context
The context object provides everything you need:
{
entity: any; // Entity data being saved
oldEntity?: any; // Previous entity state (for updates)
operation: HookOperation; // create | update | delete
entityName: string; // Entity key
user: { // Current user
id: number;
email: string;
roles: string[];
};
db: { // Database service
entity: EntityService;
sequence: SequenceService;
knex: Knex;
};
services: { // Platform services
entity: EntityService;
workflow: WorkflowService;
message: MessageService;
cache: CacheService;
query: QueryService;
logger: LoggerService;
};
}
Hook Operations
enum HookOperation {
create = 'create', // Before creating new record
update = 'update', // Before updating existing record
delete = 'delete', // Before deleting record
}
Check the operation type to apply different logic:
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, oldEntity } = this.context;
if (operation === HookOperation.create) {
// Only run on create
const crn = await this.context.db.sequence.nextVal('customer', 1, 999999);
return { valid: true, entity: { ...entity, crn } };
}
if (operation === HookOperation.update) {
// Only run on update
const changes = this.detectChanges(oldEntity, entity);
// ... handle changes
}
return { valid: true, entity };
}
Hook Response
Your hook must return a CustomFunctionResponse:
interface CustomFunctionResponse {
valid: boolean; // Whether validation passed
entity?: any; // Modified entity data
errors?: ValidationError[]; // Validation errors
}
Success Response
return {
valid: true,
entity: {
...entity,
crn: 12345,
calculated_field: value
}
};
Validation Error Response
return {
valid: false,
errors: [
{
field: 'email_address',
message: 'Email already exists'
},
{
field: 'phone',
message: 'Invalid phone number format'
}
]
};
Real-World Example
From a production Stack9 app - Customer validation hook:
import {
CustomFunction,
CustomFunctionContext,
CustomFunctionResponse,
HookOperation,
} from '@april9/stack9-sdk';
import dayjs from 'dayjs';
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, entityName, db, operation, oldEntity } = this.context;
// Only process create and update operations
if (![HookOperation.create, HookOperation.update].includes(operation)) {
return { entity, valid: true };
}
// Auto-generate CRN on create
const crn = operation === HookOperation.create
? { crn: await db.sequence.nextVal(entityName, 1, 4_999_999) }
: {};
// Calculate DOB month from date of birth
const dobMonth = entity.dob
? { dob_month: dayjs(entity.dob).month() + 1 }
: {};
// Trigger side effect when customer becomes inactive
if (operation === HookOperation.update) {
const mergedEntity = { ...oldEntity, ...entity };
if (!mergedEntity.is_active && mergedEntity.is_active !== oldEntity.is_active) {
// Send message to queue for async processing
await this.context.services.message.realtime.sendMessage({
queue: 'handle_inactive_customer',
body: JSON.stringify({
entityId: mergedEntity.id,
}),
entityType: 'customer',
entityId: mergedEntity.id,
});
}
}
return {
valid: true,
entity: {
...entity,
...crn,
...dobMonth,
},
};
}
}
This hook demonstrates:
- ✅ Sequence generation for unique identifiers
- ✅ Calculated fields from other field values
- ✅ Side effects triggering async processing
- ✅ Conditional logic based on operation type
- ✅ Change detection comparing old and new values
Common Patterns
1. Auto-Generate Sequence Numbers
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, db } = this.context;
if (operation === HookOperation.create) {
const orderNumber = await db.sequence.nextVal('sales_order', 1000, 999999);
return {
valid: true,
entity: { ...entity, order_number: orderNumber }
};
}
return { valid: true, entity };
}
2. Validate Unique Fields
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, db } = this.context;
// Check if email already exists
const existing = await db.entity.knex('customers')
.where({ email_address: entity.email_address })
.whereNot({ id: entity.id || 0 })
.first();
if (existing) {
return {
valid: false,
errors: [{
field: 'email_address',
message: 'Email address already in use'
}]
};
}
return { valid: true, entity };
}
3. Calculate Derived Fields
async exec(): Promise<CustomFunctionResponse> {
const { entity } = this.context;
// Calculate total from line items
const total = entity.line_items?.reduce(
(sum: number, item: any) => sum + (item.quantity * item.unit_price),
0
) || 0;
// Calculate tax
const tax = total * 0.1; // 10% GST
return {
valid: true,
entity: {
...entity,
subtotal: total,
tax_amount: tax,
total_amount: total + tax
}
};
}
4. Transform Data
async exec(): Promise<CustomFunctionResponse> {
const { entity } = this.context;
return {
valid: true,
entity: {
...entity,
email_address: entity.email_address?.toLowerCase().trim(),
phone: entity.phone?.replace(/\s+/g, ''),
name: entity.name?.trim()
}
};
}
5. Validate Business Rules
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation } = this.context;
const errors: ValidationError[] = [];
// Business rule: Discount cannot exceed 50%
if (entity.discount_percentage > 50) {
errors.push({
field: 'discount_percentage',
message: 'Discount cannot exceed 50%'
});
}
// Business rule: End date must be after start date
if (entity.end_date && entity.start_date) {
if (dayjs(entity.end_date).isBefore(dayjs(entity.start_date))) {
errors.push({
field: 'end_date',
message: 'End date must be after start date'
});
}
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, entity };
}
6. Trigger Side Effects
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, oldEntity, services } = this.context;
// Send welcome email on customer creation
if (operation === HookOperation.create) {
await services.message.realtime.sendMessage({
queue: 'send_welcome_email',
body: JSON.stringify({
customerId: entity.id,
email: entity.email_address,
name: entity.name
})
});
}
// Send notification on status change
if (operation === HookOperation.update) {
if (entity.status !== oldEntity.status) {
await services.message.realtime.sendMessage({
queue: 'notify_status_change',
body: JSON.stringify({
orderId: entity.id,
oldStatus: oldEntity.status,
newStatus: entity.status
})
});
}
}
return { valid: true, entity };
}
7. Update Related Entities
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, services } = this.context;
// When order is completed, update customer's last order date
if (operation === HookOperation.update && entity.status === 'completed') {
await services.entity.update('customer', entity.customer_id, {
last_order_date: new Date()
});
}
return { valid: true, entity };
}
Accessing Services
Database Service
// Knex query builder
const customers = await this.context.db.entity.knex('customers')
.where({ is_active: true })
.select('*');
// Sequence generation
const nextId = await this.context.db.sequence.nextVal('entity_name', 1, 999999);
Entity Service
// Get entity
const customer = await this.context.services.entity.get('customer', 123);
// Create entity
const newOrder = await this.context.services.entity.insert('sales_order', {
customer_id: 123,
total_amount: 100
});
// Update entity
await this.context.services.entity.update('customer', 123, {
last_order_date: new Date()
});
Message Service
// Send to queue for async processing
await this.context.services.message.realtime.sendMessage({
queue: 'process_order',
body: JSON.stringify({ orderId: entity.id }),
entityType: 'sales_order',
entityId: entity.id
});
Logger Service
this.context.services.logger.info('Processing customer', {
customerId: entity.id,
operation: this.context.operation
});
this.context.services.logger.error('Validation failed', {
errors,
entity
});
Registering Hooks
Hooks are automatically registered when you add them to the entity definition:
{
"head": {
"name": "Customer",
"key": "customer"
},
"fields": [...],
"hooks": ["ValidateCustomer"]
}
Or leave empty for automatic discovery:
{
"hooks": []
}
Stack9 will automatically find and load entity-hooks/customer.vat.ts.
Best Practices
1. Keep Hooks Focused
// ✅ Good - Single responsibility
export class ValidateCustomerEmail extends CustomFunction {
async exec() {
// Only validate email
}
}
// ❌ Bad - Too many responsibilities
export class DoEverythingCustomer extends CustomFunction {
async exec() {
// Validates, transforms, sends emails, updates 10 things...
}
}
2. Handle Errors Gracefully
try {
const result = await someExternalService.validate(entity);
return { valid: true, entity };
} catch (error) {
this.context.services.logger.error('External validation failed', { error });
// Decide: fail validation or continue?
return { valid: true, entity }; // Or return error
}
3. Avoid Heavy Operations
// ❌ Bad - Synchronous heavy operation blocks transaction
const result = await heavyCalculation(); // Takes 5 seconds
// ✅ Good - Queue for async processing
await this.context.services.message.realtime.sendMessage({
queue: 'heavy_calculation',
body: JSON.stringify({ entityId: entity.id })
});
4. Use Type Safety
import { DBCustomer } from '../models/stack9/Customer';
export class ValidateCustomer extends CustomFunction {
constructor(private context: CustomFunctionContext<DBCustomer>) {
super();
}
async exec(): Promise<CustomFunctionResponse> {
// TypeScript knows entity shape
const email = this.context.entity.email_address; // ✅ Type-safe
}
}
5. Document Business Rules
async exec(): Promise<CustomFunctionResponse> {
const { entity } = this.context;
// Business Rule #BR-001: Customers must be 18+ for alcohol purchases
if (entity.date_of_birth) {
const age = dayjs().diff(dayjs(entity.date_of_birth), 'year');
if (age < 18 && entity.purchases_alcohol) {
return {
valid: false,
errors: [{
field: 'purchases_alcohol',
message: 'Customer must be 18 or older to purchase alcohol'
}]
};
}
}
return { valid: true, entity };
}
Testing Hooks
import { ValidateCustomer } from './customer.vat';
import { HookOperation } from '@april9/stack9-sdk';
describe('ValidateCustomer', () => {
it('should generate CRN on create', async () => {
const mockContext = {
entity: { name: 'John', email: 'john@example.com' },
operation: HookOperation.create,
db: {
sequence: {
nextVal: jest.fn().mockResolvedValue(12345)
}
}
};
const hook = new ValidateCustomer(mockContext as any);
const result = await hook.exec();
expect(result.valid).toBe(true);
expect(result.entity.crn).toBe(12345);
});
it('should validate email format', async () => {
const mockContext = {
entity: { email: 'invalid-email' },
operation: HookOperation.create
};
const hook = new ValidateCustomer(mockContext as any);
const result = await hook.exec();
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].field).toBe('email');
});
});
Next Steps
Now that you understand Entity Hooks, learn about:
- Automations - Workflow-driven business logic
- Action Types - Reusable business logic components
- How-To: Implement Validation Logic