Implementing Business Logic
Learn how to implement custom business logic in Stack9 using Entity Hooks and Action Types. This guide covers validation, data transformation, calculated fields, side effects, and integrating with external systems.
What You'll Build
In this guide, you'll implement business logic for an Order Management System that includes:
- Entity Hooks for validation and data transformation
- Action Types for complex business operations
- Calculated fields (order totals, tax calculations)
- Side effects (inventory updates, notifications)
- External API integration (payment processing)
Time to complete: 45-60 minutes
Prerequisites
- Completed Building a CRUD Application guide
- Basic understanding of TypeScript
- Familiarity with async/await patterns
Step 1: Understanding Business Logic in Stack9
Stack9 provides two primary mechanisms for custom business logic:
Entity Hooks (VAT - Validation After Trigger)
Execute custom logic during entity lifecycle events:
create- Before a new record is savedupdate- Before an existing record is updateddelete- Before a record is deleted
Use cases:
- Field validation
- Data transformation
- Calculated fields
- Simple side effects
Action Types
Execute custom logic in automations:
- Triggered by webhooks, entity events, workflows, or schedules
- Can orchestrate multiple operations
- Handle complex business processes
- Integrate with external systems
Use cases:
- Multi-step business processes
- External API calls
- Complex data aggregation
- Email/notification sending
- Background jobs
Step 2: Create the Entity Definitions
First, create the entities for our order management system.
Product Entity
Create src/entities/custom/product.json:
{
"name": "product",
"label": "Product",
"primaryField": "name",
"fields": [
{
"label": "Name",
"key": "name",
"type": "TextField",
"typeOptions": {
"required": true
}
},
{
"label": "SKU",
"key": "sku",
"type": "TextField",
"typeOptions": {
"required": true
}
},
{
"label": "Price",
"key": "price",
"type": "NumericField",
"typeOptions": {
"required": true,
"min": 0
}
},
{
"label": "Stock Quantity",
"key": "stock_quantity",
"type": "NumericField",
"typeOptions": {
"required": true,
"min": 0
}
},
{
"label": "Active",
"key": "active",
"type": "Checkbox",
"typeOptions": {
"default": true
}
}
]
}
Order Entity
Create src/entities/custom/order.json:
{
"name": "order",
"label": "Order",
"primaryField": "order_number",
"fields": [
{
"label": "Order Number",
"key": "order_number",
"type": "TextField",
"typeOptions": {
"required": true,
"readOnly": true
}
},
{
"label": "Customer Email",
"key": "customer_email",
"type": "TextField",
"typeOptions": {
"required": true
}
},
{
"label": "Status",
"key": "status",
"type": "SingleDropDown",
"typeOptions": {
"options": [
{ "value": "pending", "label": "Pending" },
{ "value": "processing", "label": "Processing" },
{ "value": "shipped", "label": "Shipped" },
{ "value": "delivered", "label": "Delivered" },
{ "value": "cancelled", "label": "Cancelled" }
],
"default": "pending"
}
},
{
"label": "Order Items",
"key": "order_items",
"type": "Grid",
"relationshipOptions": {
"ref": "order_item"
},
"typeOptions": {
"relationshipField": "order_id"
}
},
{
"label": "Subtotal",
"key": "subtotal",
"type": "NumericField",
"typeOptions": {
"readOnly": true,
"default": 0
}
},
{
"label": "Tax Amount",
"key": "tax_amount",
"type": "NumericField",
"typeOptions": {
"readOnly": true,
"default": 0
}
},
{
"label": "Total",
"key": "total",
"type": "NumericField",
"typeOptions": {
"readOnly": true,
"default": 0
}
},
{
"label": "Payment Status",
"key": "payment_status",
"type": "SingleDropDown",
"typeOptions": {
"options": [
{ "value": "pending", "label": "Pending" },
{ "value": "paid", "label": "Paid" },
{ "value": "failed", "label": "Failed" },
{ "value": "refunded", "label": "Refunded" }
],
"default": "pending"
}
},
{
"label": "Payment Transaction ID",
"key": "payment_transaction_id",
"type": "TextField"
}
]
}
Order Item Entity
Create src/entities/custom/order_item.json:
{
"name": "order_item",
"label": "Order Item",
"fields": [
{
"label": "Order",
"key": "order_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "order",
"labelField": "order_number"
}
},
{
"label": "Product",
"key": "product_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "product",
"labelField": "name"
},
"typeOptions": {
"required": true
}
},
{
"label": "Quantity",
"key": "quantity",
"type": "NumericField",
"typeOptions": {
"required": true,
"min": 1
}
},
{
"label": "Unit Price",
"key": "unit_price",
"type": "NumericField",
"typeOptions": {
"required": true,
"readOnly": true
}
},
{
"label": "Line Total",
"key": "line_total",
"type": "NumericField",
"typeOptions": {
"readOnly": true
}
}
]
}
Step 3: Implement Entity Hook for Order Validation
Create src/entity-hooks/order.vat.ts:
import { CustomFunction, CustomFunctionResponse, HookOperation } from '@april9/stack9-sdk';
/**
* Order Validation and Transformation Hook
*
* Responsibilities:
* - Generate unique order numbers
* - Calculate order totals (subtotal, tax, total)
* - Validate order can be placed (stock availability)
* - Set initial status
*/
export class ValidateOrder extends CustomFunction {
entityName = 'order';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, db } = this.context;
try {
// 1. Generate order number for new orders
if (operation === HookOperation.create) {
const orderNumber = await this.generateOrderNumber();
entity.order_number = orderNumber;
}
// 2. Calculate totals
const totals = await this.calculateOrderTotals(entity.order_items || []);
entity.subtotal = totals.subtotal;
entity.tax_amount = totals.tax;
entity.total = totals.total;
// 3. Validate stock availability
if (operation === HookOperation.create) {
const stockValidation = await this.validateStock(entity.order_items || []);
if (!stockValidation.valid) {
return {
valid: false,
errors: stockValidation.errors,
};
}
}
// 4. Validate status transitions
if (operation === HookOperation.update) {
const statusValidation = this.validateStatusTransition(
entity._original?.status,
entity.status
);
if (!statusValidation.valid) {
return {
valid: false,
errors: [statusValidation.error],
};
}
}
return {
valid: true,
entity,
};
} catch (error) {
return {
valid: false,
errors: [`Order validation failed: ${error.message}`],
};
}
}
/**
* Generate unique order number
* Format: ORD-YYYYMMDD-XXXX
*/
private async generateOrderNumber(): Promise<string> {
const date = new Date();
const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
// Get next sequence number for today
const sequence = await this.context.db.sequence.nextVal(
'order',
1,
9999
);
const seqStr = sequence.toString().padStart(4, '0');
return `ORD-${dateStr}-${seqStr}`;
}
/**
* Calculate order totals
*/
private async calculateOrderTotals(orderItems: any[]): Promise<{
subtotal: number;
tax: number;
total: number;
}> {
let subtotal = 0;
// Calculate subtotal from order items
for (const item of orderItems) {
// Fetch product details to get current price
const product = await this.context.db('product')
.where({ id: item.product_id })
.first();
if (product) {
const lineTotal = product.price * item.quantity;
subtotal += lineTotal;
}
}
// Calculate tax (10% for this example)
const tax = subtotal * 0.1;
const total = subtotal + tax;
return {
subtotal: Number(subtotal.toFixed(2)),
tax: Number(tax.toFixed(2)),
total: Number(total.toFixed(2)),
};
}
/**
* Validate stock availability
*/
private async validateStock(orderItems: any[]): Promise<{
valid: boolean;
errors?: string[];
}> {
const errors: string[] = [];
for (const item of orderItems) {
const product = await this.context.db('product')
.where({ id: item.product_id })
.first();
if (!product) {
errors.push(`Product ${item.product_id} not found`);
continue;
}
if (!product.active) {
errors.push(`Product ${product.name} is not active`);
}
if (product.stock_quantity < item.quantity) {
errors.push(
`Insufficient stock for ${product.name}. ` +
`Available: ${product.stock_quantity}, Requested: ${item.quantity}`
);
}
}
return {
valid: errors.length === 0,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
* Validate status transitions
*/
private validateStatusTransition(
oldStatus: string,
newStatus: string
): { valid: boolean; error?: string } {
const validTransitions: Record<string, string[]> = {
pending: ['processing', 'cancelled'],
processing: ['shipped', 'cancelled'],
shipped: ['delivered'],
delivered: [],
cancelled: [],
};
const allowedTransitions = validTransitions[oldStatus] || [];
if (!allowedTransitions.includes(newStatus)) {
return {
valid: false,
error: `Invalid status transition from ${oldStatus} to ${newStatus}`,
};
}
return { valid: true };
}
}
Register the Hook
Add to src/index.ts:
import { ValidateOrder } from './entity-hooks/order.vat';
export const hooks = [
ValidateOrder,
];
Step 4: Implement Order Item Hook
Create src/entity-hooks/order_item.vat.ts:
import { CustomFunction, CustomFunctionResponse, HookOperation } from '@april9/stack9-sdk';
/**
* Order Item Hook
*
* Responsibilities:
* - Fetch product price when product is selected
* - Calculate line total (quantity × unit price)
*/
export class ValidateOrderItem extends CustomFunction {
entityName = 'order_item';
async exec(): Promise<CustomFunctionResponse> {
const { entity, operation, db } = this.context;
try {
// Fetch product details
const product = await db('product')
.where({ id: entity.product_id })
.first();
if (!product) {
return {
valid: false,
errors: ['Product not found'],
};
}
// Set unit price from product (on create or if product changed)
if (
operation === HookOperation.create ||
entity.product_id !== entity._original?.product_id
) {
entity.unit_price = product.price;
}
// Calculate line total
entity.line_total = Number(
(entity.unit_price * entity.quantity).toFixed(2)
);
return {
valid: true,
entity,
};
} catch (error) {
return {
valid: false,
errors: [`Order item validation failed: ${error.message}`],
};
}
}
}
Step 5: Create Action Type for Payment Processing
Create src/action-types/processPayment.ts:
import { Record, String, Number } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';
/**
* Parameters for processPayment action
*/
const ProcessPaymentParams = Record({
order_id: Number,
payment_method: String,
amount: Number,
});
/**
* Process Payment Action Type
*
* Handles payment processing through external payment gateway
*/
export class ProcessPayment extends S9AutomationActionType {
key = 'process_payment';
name = 'Process Payment';
description = 'Process payment for an order through payment gateway';
async exec(params: any) {
// Validate parameters
const validatedParams = ProcessPaymentParams.check(params);
const { order_id, payment_method, amount } = validatedParams;
const { db, connectors, logger } = this.context;
try {
// 1. Fetch order details
const order = await db('order')
.where({ id: order_id })
.first();
if (!order) {
throw new Error(`Order ${order_id} not found`);
}
// 2. Call payment gateway API
const paymentGateway = connectors['payment_gateway'];
const paymentResponse = await paymentGateway.call({
method: 'POST',
path: '/payments',
body: {
amount: amount,
currency: 'USD',
payment_method: payment_method,
order_reference: order.order_number,
customer_email: order.customer_email,
},
});
// 3. Update order with payment status
if (paymentResponse.status === 'success') {
await db('order')
.where({ id: order_id })
.update({
payment_status: 'paid',
payment_transaction_id: paymentResponse.transaction_id,
status: 'processing',
_updated_at: new Date(),
});
logger.info(`Payment processed successfully for order ${order.order_number}`);
return {
success: true,
transaction_id: paymentResponse.transaction_id,
message: 'Payment processed successfully',
};
} else {
// Payment failed
await db('order')
.where({ id: order_id })
.update({
payment_status: 'failed',
_updated_at: new Date(),
});
logger.error(`Payment failed for order ${order.order_number}: ${paymentResponse.error}`);
return {
success: false,
error: paymentResponse.error,
message: 'Payment processing failed',
};
}
} catch (error) {
logger.error(`Payment processing error: ${error.message}`);
throw error;
}
}
}
Step 6: Create Action Type for Inventory Update
Create src/action-types/updateInventory.ts:
import { Record, Number, String } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';
/**
* Update Inventory Action Type
*
* Deducts stock quantities when order is placed
*/
export class UpdateInventory extends S9AutomationActionType {
key = 'update_inventory';
name = 'Update Inventory';
description = 'Update product inventory after order is placed';
async exec(params: any) {
const { order_id } = params;
const { db, logger } = this.context;
try {
// 1. Fetch order with items
const order = await db('order')
.where({ id: order_id })
.first();
if (!order) {
throw new Error(`Order ${order_id} not found`);
}
const orderItems = await db('order_item')
.where({ order_id: order_id });
// 2. Update stock for each product
for (const item of orderItems) {
const product = await db('product')
.where({ id: item.product_id })
.first();
if (!product) {
logger.warn(`Product ${item.product_id} not found`);
continue;
}
// Deduct stock
const newStock = product.stock_quantity - item.quantity;
await db('product')
.where({ id: item.product_id })
.update({
stock_quantity: newStock,
_updated_at: new Date(),
});
logger.info(
`Updated inventory for ${product.name}: ` +
`${product.stock_quantity} → ${newStock}`
);
// Check if stock is low (less than 10)
if (newStock < 10) {
logger.warn(
`Low stock alert for ${product.name}: ${newStock} units remaining`
);
// Could trigger a notification here
}
}
return {
success: true,
message: `Inventory updated for ${orderItems.length} products`,
};
} catch (error) {
logger.error(`Inventory update error: ${error.message}`);
throw error;
}
}
}
Step 7: Create Action Type for Order Notification
Create src/action-types/sendOrderNotification.ts:
import { Record, Number, String } from 'runtypes';
import { S9AutomationActionType } from '@april9/stack9-sdk';
/**
* Send Order Notification Action Type
*
* Sends email notifications for order events
*/
export class SendOrderNotification extends S9AutomationActionType {
key = 'send_order_notification';
name = 'Send Order Notification';
description = 'Send email notification for order events';
async exec(params: any) {
const { order_id, notification_type } = params;
const { db, connectors, logger } = this.context;
try {
// Fetch order details
const order = await db('order')
.where({ id: order_id })
.first();
if (!order) {
throw new Error(`Order ${order_id} not found`);
}
// Fetch order items with product details
const orderItems = await db('order_item')
.where({ order_id: order_id })
.join('product', 'order_item.product_id', 'product.id')
.select(
'order_item.*',
'product.name as product_name',
'product.sku as product_sku'
);
// Build email content based on notification type
let subject: string;
let template: string;
switch (notification_type) {
case 'order_confirmation':
subject = `Order Confirmation - ${order.order_number}`;
template = 'order_confirmation';
break;
case 'order_shipped':
subject = `Your Order Has Shipped - ${order.order_number}`;
template = 'order_shipped';
break;
case 'order_delivered':
subject = `Your Order Has Been Delivered - ${order.order_number}`;
template = 'order_delivered';
break;
default:
throw new Error(`Unknown notification type: ${notification_type}`);
}
// Send email via email connector
const emailConnector = connectors['email_service'];
await emailConnector.call({
method: 'POST',
path: '/send',
body: {
to: order.customer_email,
subject: subject,
template: template,
data: {
order_number: order.order_number,
order_status: order.status,
subtotal: order.subtotal,
tax_amount: order.tax_amount,
total: order.total,
items: orderItems,
},
},
});
logger.info(
`Sent ${notification_type} notification for order ${order.order_number} ` +
`to ${order.customer_email}`
);
return {
success: true,
message: 'Notification sent successfully',
};
} catch (error) {
logger.error(`Notification error: ${error.message}`);
throw error;
}
}
}
Step 8: Register Action Types
Update src/index.ts:
import { ValidateOrder } from './entity-hooks/order.vat';
import { ValidateOrderItem } from './entity-hooks/order_item.vat';
import { ProcessPayment } from './action-types/processPayment';
import { UpdateInventory } from './action-types/updateInventory';
import { SendOrderNotification } from './action-types/sendOrderNotification';
export const hooks = [
ValidateOrder,
ValidateOrderItem,
];
export const actionTypes = [
ProcessPayment,
UpdateInventory,
SendOrderNotification,
];
Step 9: Create Automation Workflows
Order Created Automation
Create src/automations/when_order_created.json:
{
"name": "when_order_created",
"label": "When Order Created",
"description": "Process order after it's created",
"trigger": {
"type": "afterCreate",
"entity": "order"
},
"actions": [
{
"name": "update_inventory",
"actionType": "update_inventory",
"params": {
"order_id": "{{trigger.entity.id}}"
}
},
{
"name": "send_confirmation",
"actionType": "send_order_notification",
"params": {
"order_id": "{{trigger.entity.id}}",
"notification_type": "order_confirmation"
}
}
]
}
Order Status Changed Automation
Create src/automations/when_order_status_changed.json:
{
"name": "when_order_status_changed",
"label": "When Order Status Changed",
"description": "Send notifications when order status changes",
"trigger": {
"type": "afterUpdate",
"entity": "order"
},
"conditions": [
{
"field": "status",
"operator": "changed"
}
],
"actions": [
{
"name": "send_shipped_notification",
"actionType": "send_order_notification",
"params": {
"order_id": "{{trigger.entity.id}}",
"notification_type": "order_shipped"
},
"conditions": [
{
"field": "{{trigger.entity.status}}",
"operator": "equals",
"value": "shipped"
}
]
},
{
"name": "send_delivered_notification",
"actionType": "send_order_notification",
"params": {
"order_id": "{{trigger.entity.id}}",
"notification_type": "order_delivered"
},
"conditions": [
{
"field": "{{trigger.entity.status}}",
"operator": "equals",
"value": "delivered"
}
]
}
]
}
Payment Webhook
Create src/automations/webhook_process_payment.json:
{
"name": "webhook_process_payment",
"label": "Process Payment Webhook",
"description": "Endpoint to process order payments",
"trigger": {
"type": "webhook",
"method": "POST",
"path": "/process-payment"
},
"actions": [
{
"name": "process_payment",
"actionType": "process_payment",
"params": {
"order_id": "{{trigger.body.order_id}}",
"payment_method": "{{trigger.body.payment_method}}",
"amount": "{{trigger.body.amount}}"
}
}
]
}
Step 10: Test Your Implementation
Test Order Creation
# Create a new order via API
curl -X POST http://localhost:3000/api/order \
-H "Content-Type: application/json" \
-d '{
"customer_email": "customer@example.com",
"order_items": [
{
"product_id": 1,
"quantity": 2
},
{
"product_id": 2,
"quantity": 1
}
]
}'
Expected behavior:
- Order number generated automatically (
ORD-20250110-0001) - Stock validated (fails if insufficient)
- Unit prices fetched from products
- Line totals calculated
- Order subtotal, tax, and total calculated
- Order created in database
- Automation triggers:
- Inventory updated (stock quantities reduced)
- Confirmation email sent
Test Payment Processing
# Process payment via webhook
curl -X POST http://localhost:3000/webhooks/process-payment \
-H "Content-Type: application/json" \
-d '{
"order_id": 1,
"payment_method": "credit_card",
"amount": 150.00
}'
Expected behavior:
- Payment processed via payment gateway
- Order status updated to "processing"
- Payment status updated to "paid"
- Transaction ID saved
Test Status Updates
# Update order status
curl -X PUT http://localhost:3000/api/order/1 \
-H "Content-Type: application/json" \
-d '{
"status": "shipped"
}'
Expected behavior:
- Status transition validated (must be valid transition)
- Order status updated
- Automation triggers:
- Shipped notification email sent
Common Patterns
Pattern 1: Calculated Fields
Use entity hooks to automatically calculate derived values:
// In entity hook
async exec(): Promise<CustomFunctionResponse> {
const { entity } = this.context;
// Calculate derived field
entity.full_name = `${entity.first_name} ${entity.last_name}`;
entity.total_price = entity.quantity * entity.unit_price;
return { valid: true, entity };
}
Pattern 2: Cross-Entity Validation
Validate against related entities:
// Check if related entity exists
const relatedEntity = await this.context.db('related_entity')
.where({ id: entity.related_id })
.first();
if (!relatedEntity) {
return {
valid: false,
errors: ['Related entity not found'],
};
}
Pattern 3: Conditional Logic
Execute logic based on conditions:
// Different logic for create vs update
if (operation === HookOperation.create) {
// Only on create
entity.created_by = this.context.user.id;
} else if (operation === HookOperation.update) {
// Only on update
entity.updated_by = this.context.user.id;
// Check what changed
if (entity.status !== entity._original?.status) {
// Status changed
}
}
Pattern 4: Side Effects in Action Types
Use action types for operations that should happen after the entity is saved:
// Good: Action type for side effects
export class SendWelcomeEmail extends S9AutomationActionType {
async exec(params: any) {
const { user_id } = params;
// Fetch user
const user = await this.context.db('user')
.where({ id: user_id })
.first();
// Send email
await this.context.connectors['email'].call({
to: user.email,
template: 'welcome',
});
}
}
Pattern 5: Error Handling
Always handle errors gracefully:
async exec(): Promise<CustomFunctionResponse> {
try {
// Your logic here
return { valid: true, entity };
} catch (error) {
this.context.logger.error(`Error: ${error.message}`);
return {
valid: false,
errors: [`Operation failed: ${error.message}`],
};
}
}
Best Practices
Entity Hooks
- Keep hooks fast - They run synchronously during entity operations
- Use for validation - Perfect for data validation and transformation
- Calculate derived fields - Auto-calculate fields based on other fields
- Avoid external API calls - Use action types for slow operations
- Return clear errors - Provide helpful error messages to users
Action Types
- Use for side effects - Email, notifications, external APIs
- Make idempotent - Safe to run multiple times
- Log extensively - Help with debugging
- Handle failures gracefully - Don't crash on external API failures
- Use transactions - Wrap database operations in transactions
General
- Validate parameters - Use runtypes for runtime validation
- Use TypeScript - Take advantage of type safety
- Test thoroughly - Test both success and failure cases
- Document your code - Explain complex business logic
- Monitor performance - Watch for slow hooks/actions
Troubleshooting
Hook Not Executing
Problem: Entity hook doesn't run when expected
Solutions:
- Check hook is registered in
src/index.ts - Verify
entityNamematches entity name exactly - Check operation type matches (create/update/delete)
- Look for errors in logs:
npm run dev
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)
Action Type Not Found
Problem: Automation can't find action type
Solutions:
- Check action type is registered in
src/index.ts - Verify
keyproperty matches automation'sactionType - Restart dev server after adding new action type
Performance Issues
Problem: Orders taking too long to create
Solutions:
- Profile hooks to find slow operations
- Move slow operations to action types
- Use database indices on frequently queried fields
- Cache product prices if they don't change often
Next Steps
Now you've mastered implementing business logic! Continue learning:
- Building Workflows - Complex multi-step processes
- Integrating External APIs - Connect to third-party services
- Action Types Reference - Learn all action type features
- Entity Hooks Reference - Master entity hook patterns