Skip to main content

Action Types

Action Types are reusable TypeScript functions that define custom business logic for automations. They encapsulate complex operations into modular, testable units that can be used across multiple workflows.

What are Action Types?

Action Types are TypeScript classes that:

  • Execute custom logic - Perform complex operations beyond simple CRUD
  • Access all Stack9 services - Database, entity service, query library, workflows
  • Accept parameters - Define typed inputs with validation rules
  • Return structured responses - Pass data to subsequent actions or return to caller
  • Handle errors gracefully - Built-in error handling and logging

When you define an action type, Stack9 automatically:

  • ✅ Validates input parameters
  • ✅ Provides access to services and context
  • ✅ Manages error handling and logging
  • ✅ Supports async operations
  • ✅ Enables workflow chaining

Why Use Action Types?

Without Action Types (Inline Logic)

// Logic scattered across multiple automations
// Hard to test, hard to reuse, hard to maintain
app.post('/api/sync-customer', async (req, res) => {
const { customerId } = req.body;

// Fetch customer
const customer = await db('customers').where({ id: customerId }).first();

// Update search index
await opensearch.index({
index: 'customers',
id: customer.id,
body: customer
});

// Send to queue
await queue.send('customer-synced', customer);

res.json({ success: true });
});

With Action Types (Stack9 Approach)

export class SyncCustomerData implements S9AutomationActionType {
description = {
name: 'Sync customer data',
key: 'sync_customer_data',
properties: [
{
name: 'customerId',
label: 'Customer ID',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }]
}
]
};

execute = async ({ params, services, next }: S9AutomationContext) => {
const { customerId } = Params.check(params);
await services.search.syncCustomer(customerId);
next();
};
}

Benefits:

  • Reusable: Use same action across multiple automations
  • Testable: Easy to unit test in isolation
  • Type-safe: Full TypeScript type checking
  • Discoverable: Shows up in automation builder
  • Maintainable: Change once, update everywhere

Action Type File Structure

Action types are defined in src/action-types/{action_name}.ts:

import {
S9AutomationActionType,
S9AutomationActionTypeDescription,
S9AutomationContext,
S9InputTypes,
} from '@april9/stack9-sdk';
import * as rt from 'runtypes';

const Params = rt.Record({
// Define parameters with types
customerId: rt.Number
});
type Params = rt.Static<typeof Params>;

export class ActionTypeName implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Human Readable Name',
key: 'action_type_key',
description: 'What this action does',
icon: 'EmailIcon',
properties: [
// Define input parameters
]
};

execute = async (context: S9AutomationContext): Promise<void> => {
// Your business logic here
const { params, services, next } = context;
const validatedParams = Params.check(params);

// Do work...

next(); // Continue to next action
};
}

Action Type Description

The description object defines metadata for the automation builder:

description: S9AutomationActionTypeDescription = {
name: 'Sync Customer Data', // Display name in UI
key: 'sync_customer_data', // Unique identifier
description: 'Syncs customer to search', // Help text
icon: 'EmailIcon', // Icon in UI
properties: [
{
name: 'customerId', // Parameter name
label: 'Customer ID', // Display label
type: S9InputTypes.ValueCodeMirror, // Input type
rules: [{ required: true }] // Validation rules
}
]
};

Input Types

enum S9InputTypes {
ValueCodeMirror = 'ValueCodeMirror', // Template expression editor
EntityDropDown = 'EntityDropDown', // Entity picker
QueryDropDown = 'QueryDropDown', // Query picker
TextArea = 'TextArea', // Multi-line text
TextField = 'TextField', // Single-line text
Boolean = 'Boolean', // Checkbox
Number = 'Number', // Number input
DatePicker = 'DatePicker', // Date picker
OptionSet = 'OptionSet', // Dropdown with options
}

Automation Context

The S9AutomationContext provides access to everything you need:

interface S9AutomationContext {
params: any; // Input parameters
services: {
entity: EntityService; // CRUD operations
query: QueryService; // Execute queries
workflow: WorkflowService; // Workflow operations
message: MessageService; // Queue messages
storage: StorageService; // File storage
environmentVariable: EnvironmentVariableService; // Env vars
batchProcess: BatchProcessService; // Batch operations
cache: CacheService; // Caching
logger: LoggerService; // Logging
};
db: {
entity: EntityService; // Entity database service
knex: Knex; // Knex query builder
};
user: {
id: number; // Current user ID
email: string; // Current user email
};
logger: LoggerService; // Logger instance
environmentType: EnvironmentType; // 'dev' | 'staging' | 'prod'
next: (response?: any) => void; // Continue to next action
}

Parameter Validation

Use runtypes for type-safe parameter validation:

import * as rt from 'runtypes';

// Simple parameters
const Params = rt.Record({
customerId: rt.Number,
emailAddress: rt.String,
isActive: rt.Boolean
});

// Optional parameters
const Params = rt.Record({
customerId: rt.Number,
notes: rt.String.optional()
});

// Nested objects
const Params = rt.Record({
customer: rt.Record({
name: rt.String,
email: rt.String
})
});

// Arrays
const Params = rt.Record({
customerIds: rt.Array(rt.Number)
});

// Union types
const Params = rt.Record({
status: rt.Union(rt.Literal('active'), rt.Literal('inactive'))
});

// Constraints
const Params = rt.Record({
age: rt.Number.withConstraint(n => n >= 18 || 'Must be 18 or older')
});

Real-World Examples

Example 1: Sync Customer to Search Index

From a production Stack9 app:

import {
S9AutomationActionType,
S9AutomationActionTypeDescription,
S9AutomationContext,
S9InputTypes,
} from '@april9/stack9-sdk';
import * as rt from 'runtypes';

import { CustomerOpenSearchService } from '../services/CustomerOpenSearchService';

const Params = rt.Record({ customerId: rt.Number });
type Params = rt.Static<typeof Params>;

export class SyncCustomerData implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Sync customer data',
key: 'sync_customer_data',
description: 'Sync customer data to OpenSearch',
icon: 'EmailIcon',
properties: [
{
name: 'customerId',
label: 'Customer ID',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
],
};

execute = async ({
next,
params,
services,
logger,
db,
}: S9AutomationContext): Promise<void> => {
const { customerId } = Params.check(params);

const service = new CustomerOpenSearchService(
services.entity,
services.batchProcess,
services.queryLibrary,
services.message.standard,
db,
logger,
);

await service.updateCustomer(+customerId);

next();
};
}

Example 2: Verify Customer

import {
S9AutomationActionType,
S9AutomationActionTypeDescription,
S9AutomationContext,
S9InputTypes,
} from '@april9/stack9-sdk';
import * as rt from 'runtypes';

import { Customer } from '../models/stack9/Customer';

const Params = rt.Record({
crn: rt.Number.withConstraint(n => Number.isInteger(n)),
});
type Params = rt.Static<typeof Params>;

export class VerifyCustomer implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Verify customer',
key: 'verify_customer',
description: 'Verify customer by CRN',
icon: 'EmailIcon',
properties: [
{
name: 'crn',
label: 'CRN',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
],
};

execute = async ({
next,
params,
services,
db,
}: S9AutomationContext): Promise<void> => {
const { crn } = Params.check(params);

const customer = await services.entity.findOne(
'customer',
Customer.pick('id', 'name', 'last_name'),
{
$where: {
_is_deleted: false,
crn: crn,
is_active: true
},
},
);

if (!customer) {
return next({
response_code: 400,
message: 'Invalid CRN',
crn,
});
}

return next({
response_code: 200,
crn: customer.crn,
name: customer.name,
last_name: customer.last_name,
});
};
}

Example 3: Get Customer Dashboard Data

export class GetCustomerDashboardData implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Get customer dashboard data',
key: 'get_customer_dashboard_data',
description: 'Fetch complete customer dashboard data',
icon: 'EmailIcon',
properties: [
{
name: 'token',
label: 'Token',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
],
};

execute = async ({
next,
params,
services,
}: S9AutomationContext): Promise<void> => {
try {
const { token } = Payload.check(params);

// Decode token
const { email } = await decodeToken(token);

// Find customer
const customer = await services.entity.findOne('customer', Customer, {
$where: {
is_active: true,
_is_deleted: false,
website_username: email,
},
});

if (!customer) {
throw new SystemError('Customer not found');
}

// Fetch sales orders
const salesOrders = await services.entity.findAll('sales_order', SalesOrder, {
$where: {
_is_deleted: false,
customer_id: customer.id,
_workflow_outcome: 'success',
},
$withRelated: [
'sales_order_items(notDeleted)',
'sales_order_items(notDeleted).game(notDeleted)',
],
});

// Fetch subscriptions
const subscriptions = await services.entity.findAll('subscription', Subscription, {
$where: {
_is_deleted: false,
customer_id: customer.id,
_workflow_current_step: { $in: ['active', 'deferred'] },
},
});

return next({
response_code: 200,
data: {
...customer,
sales_orders: salesOrders,
subscriptions,
},
});
} catch (error: unknown) {
if (error instanceof SystemError) {
return next({
response_code: error.status,
message: error.message,
});
}
throw error;
}
};
}

Example 4: Send Notification

export class SendOutboundCorrespondence implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Send outbound correspondence',
key: 'send_outbound_correspondence',
description: 'Send email or postal correspondence',
icon: 'EmailIcon',
properties: [
{
name: 'correspondenceId',
label: 'Correspondence ID',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
],
};

execute = async ({
next,
params,
services,
logger,
db,
environmentType,
}: S9AutomationContext): Promise<void> => {
const { correspondenceId } = Params.check(params);

const service = new OutboundCorrespondenceService(
environmentType,
services.entity,
services.storage,
services.environmentVariable,
services.queryLibrary,
services.workflow,
services.batchProcess,
logger,
db,
);

await service.process(correspondenceId);

next();
};
}

Common Patterns

1. Query Data

execute = async ({ services, params, next }: S9AutomationContext) => {
const { customerId } = Params.check(params);

const customer = await services.entity.findOne('customer', Customer, {
$where: { id: customerId },
$withRelated: ['sales_orders', 'subscriptions']
});

next({ customer });
};

2. Update Entities

execute = async ({ services, params, next }: S9AutomationContext) => {
const { customerId, data } = Params.check(params);

await services.entity.update('customer', customerId, {
last_login_date: new Date(),
...data
});

next({ success: true });
};

3. Execute Queries

execute = async ({ services, params, next }: S9AutomationContext) => {
const { queryKey, queryParams } = Params.check(params);

const result = await services.query.execute(queryKey, queryParams);

next({ result });
};

4. Send to Queue

execute = async ({ services, params, next }: S9AutomationContext) => {
const { orderId } = Params.check(params);

await services.message.realtime.sendMessage({
queue: 'process_order',
body: JSON.stringify({ orderId }),
entityType: 'sales_order',
entityId: orderId
});

next();
};

5. File Storage Operations

execute = async ({ services, params, next }: S9AutomationContext) => {
const { fileName, fileContent } = Params.check(params);

const url = await services.storage.upload(
'document_storage',
`uploads/${fileName}`,
Buffer.from(fileContent)
);

next({ url });
};

6. Workflow Operations

execute = async ({ services, params, next }: S9AutomationContext) => {
const { entityKey, entityId, action } = Params.check(params);

await services.workflow.executeAction(entityKey, entityId, action);

next({ success: true });
};

7. Error Handling

execute = async ({ services, params, next, logger }: S9AutomationContext) => {
try {
const { orderId } = Params.check(params);

const result = await processOrder(orderId);

next({ success: true, result });
} catch (error) {
logger.error('Order processing failed', { error, params });

return next({
response_code: 500,
message: error.message,
success: false
});
}
};

8. Database Transactions

execute = async ({ db, params, next }: S9AutomationContext) => {
const { customerId, data } = Params.check(params);

await db.knex.transaction(async (trx) => {
// Update customer
await trx('customers')
.where({ id: customerId })
.update(data);

// Create audit log
await trx('audit_logs').insert({
entity_type: 'customer',
entity_id: customerId,
action: 'update',
changes: JSON.stringify(data)
});
});

next();
};

Using Action Types in Automations

Reference action types in your automation definitions:

{
"key": "when_customer_created",
"name": "When Customer Created",
"entityKey": "customer",
"triggerType": "afterCreate",
"actions": [
{
"name": "Sync to search",
"key": "sync_customer",
"actionTypeKey": "sync_customer_data",
"params": {
"customerId": "{{trigger.entity.id}}"
}
},
{
"name": "Send welcome email",
"key": "send_welcome",
"actionTypeKey": "send_outbound_correspondence",
"params": {
"correspondenceId": "{{trigger.entity.welcome_correspondence_id}}"
}
}
]
}

Registering Action Types

Action types are automatically discovered from src/action-types/:

// src/action-types/index.ts
export { SyncCustomerData } from './syncCustomerData';
export { VerifyCustomer } from './verifyCustomer';
export { GetCustomerDashboardData } from './getCustomerDashboardData';
export { SendOutboundCorrespondence } from './sendOutboundCorrespondence';

Stack9 automatically registers all exported action types.

Best Practices

1. Keep Actions Focused

// ✅ Good - Single responsibility
export class SendWelcomeEmail implements S9AutomationActionType {
execute = async ({ params, services, next }) => {
await services.email.send('welcome', params);
next();
};
}

// ❌ Bad - Too many responsibilities
export class ProcessCustomerSignup implements S9AutomationActionType {
execute = async ({ params, services, next }) => {
await createCustomer();
await sendWelcomeEmail();
await syncToSearch();
await notifySlack();
await updateAnalytics();
next();
};
}

2. Validate All Parameters

// ✅ Good - Strict validation
const Params = rt.Record({
email: rt.String.withConstraint(s => s.includes('@')),
age: rt.Number.withConstraint(n => n >= 18),
role: rt.Union(rt.Literal('admin'), rt.Literal('user'))
});

// ❌ Bad - No validation
const params: any = context.params;

3. Handle Errors Gracefully

// ✅ Good - Proper error handling
execute = async ({ params, services, next, logger }: S9AutomationContext) => {
try {
const result = await services.external.call(params);
next({ success: true, result });
} catch (error) {
logger.error('External call failed', { error, params });
return next({
response_code: 500,
message: 'Operation failed',
success: false
});
}
};

// ❌ Bad - Unhandled errors crash the automation
execute = async ({ params, services, next }) => {
const result = await services.external.call(params);
next({ result });
};

4. Use TypeScript Types

// ✅ Good - Full type safety
import { DBCustomer } from '../models/stack9/Customer';

const customer = await services.entity.findOne<DBCustomer>(
'customer',
DBCustomer,
{ $where: { id: customerId } }
);

// ❌ Bad - No type safety
const customer = await services.entity.findOne(
'customer',
{} as any,
{ $where: { id: customerId } }
);

5. Log Important Events

// ✅ Good - Helpful logging
execute = async ({ params, logger, next }: S9AutomationContext) => {
logger.info('Processing order', { orderId: params.orderId });

const result = await processOrder(params.orderId);

logger.info('Order processed successfully', { orderId: params.orderId, result });

next({ result });
};

6. Name Actions Descriptively

// ✅ Good - Clear purpose
export class SyncCustomerToSearchIndex implements S9AutomationActionType {}
export class SendOrderConfirmationEmail implements S9AutomationActionType {}

// ❌ Bad - Unclear purpose
export class Action1 implements S9AutomationActionType {}
export class DoStuff implements S9AutomationActionType {}

7. Document Complex Logic

export class CalculateShippingCost implements S9AutomationActionType {
execute = async ({ params, next }: S9AutomationContext) => {
const { weight, destination } = Params.check(params);

// Base rate: $5 + $0.50 per kg
// International shipping adds 200% surcharge
// Express shipping adds $10 flat fee
const baseRate = 5 + (weight * 0.5);
const internationalSurcharge = destination.international ? baseRate * 2 : 0;
const expressFee = params.express ? 10 : 0;

const total = baseRate + internationalSurcharge + expressFee;

next({ shippingCost: total });
};
}

Testing Action Types

import { SyncCustomerData } from './syncCustomerData';

describe('SyncCustomerData', () => {
it('should sync customer to search index', async () => {
const mockContext = {
params: { customerId: 123 },
services: {
entity: {
findOne: jest.fn().mockResolvedValue({ id: 123, name: 'John' })
},
search: {
syncCustomer: jest.fn()
}
},
next: jest.fn()
};

const action = new SyncCustomerData();
await action.execute(mockContext as any);

expect(mockContext.services.search.syncCustomer).toHaveBeenCalledWith(123);
expect(mockContext.next).toHaveBeenCalled();
});

it('should handle errors', async () => {
const mockContext = {
params: { customerId: 999 },
services: {
search: {
syncCustomer: jest.fn().mockRejectedValue(new Error('Not found'))
}
},
logger: {
error: jest.fn()
},
next: jest.fn()
};

const action = new SyncCustomerData();
await action.execute(mockContext as any);

expect(mockContext.logger.error).toHaveBeenCalled();
expect(mockContext.next).toHaveBeenCalledWith(
expect.objectContaining({ success: false })
);
});
});

Next Steps

Now that you understand Action Types, learn about: