Skip to main content

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

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 saved
  • update - Before an existing record is updated
  • delete - 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:

  1. Order number generated automatically (ORD-20250110-0001)
  2. Stock validated (fails if insufficient)
  3. Unit prices fetched from products
  4. Line totals calculated
  5. Order subtotal, tax, and total calculated
  6. Order created in database
  7. 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:

  1. Payment processed via payment gateway
  2. Order status updated to "processing"
  3. Payment status updated to "paid"
  4. 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:

  1. Status transition validated (must be valid transition)
  2. Order status updated
  3. 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

  1. Keep hooks fast - They run synchronously during entity operations
  2. Use for validation - Perfect for data validation and transformation
  3. Calculate derived fields - Auto-calculate fields based on other fields
  4. Avoid external API calls - Use action types for slow operations
  5. Return clear errors - Provide helpful error messages to users

Action Types

  1. Use for side effects - Email, notifications, external APIs
  2. Make idempotent - Safe to run multiple times
  3. Log extensively - Help with debugging
  4. Handle failures gracefully - Don't crash on external API failures
  5. Use transactions - Wrap database operations in transactions

General

  1. Validate parameters - Use runtypes for runtime validation
  2. Use TypeScript - Take advantage of type safety
  3. Test thoroughly - Test both success and failure cases
  4. Document your code - Explain complex business logic
  5. Monitor performance - Watch for slow hooks/actions

Troubleshooting

Hook Not Executing

Problem: Entity hook doesn't run when expected

Solutions:

  1. Check hook is registered in src/index.ts
  2. Verify entityName matches entity name exactly
  3. Check operation type matches (create/update/delete)
  4. Look for errors in logs: npm run dev

Validation Fails Silently

Problem: Entity saves even though validation should fail

Solutions:

  1. Ensure hook returns { valid: false, errors: [...] }
  2. Check errors array is not empty
  3. Verify hook is actually running (add logging)

Action Type Not Found

Problem: Automation can't find action type

Solutions:

  1. Check action type is registered in src/index.ts
  2. Verify key property matches automation's actionType
  3. Restart dev server after adding new action type

Performance Issues

Problem: Orders taking too long to create

Solutions:

  1. Profile hooks to find slow operations
  2. Move slow operations to action types
  3. Use database indices on frequently queried fields
  4. Cache product prices if they don't change often

Next Steps

Now you've mastered implementing business logic! Continue learning: