Skip to main content

Entities

Entities are the foundation of every Stack9 application. An entity represents a business object (like Customer, Order, Product) and defines its data structure, validation rules, and relationships.

Stack9 Console — Domain Entities list screen under Data Hub showing registered entities with name, key, pluralised name and active status columns

What is an Entity?​

An entity is a JSON configuration file that tells Stack9:

  • What fields the entity has
  • What type each field is (text, number, date, etc.)
  • How to validate the data
  • What relationships exist with other entities
  • What UI behaviors to apply

When you define an entity, Stack9 automatically:

  • ✅ Creates a database table
  • ✅ Generates REST API endpoints (CRUD)
  • ✅ Creates TypeScript models
  • ✅ Handles validation
  • ✅ Manages relationships
  • ✅ Provides audit logging

Entity File Structure​

Entities are defined in src/entities/custom/{entity_name}.json:

{
"head": {
// Entity metadata
},
"fields": [
// Field definitions
],
"hooks": [
// Optional validation hooks
]
}

Entity Head Configuration​

The head section defines entity-level metadata:

{
"head": {
"name": "Customer", // Display name (singular)
"key": "customer", // Unique identifier (lowercase, no spaces)
"pluralisedName": "customers", // Plural form
"allowComments": true, // Enable comments feature
"allowTasks": true, // Enable tasks feature
"isActive": true, // Entity is active
"maxAttachmentSizeAllowed": 30000000, // 30MB max file size (null for no limit)
"acceptedAttachmentFileTypes": [ // Allowed MIME types
"image/jpg",
"image/jpeg",
"image/png",
"application/pdf"
]
}
}

Key Properties​

PropertyTypeRequiredDescription
namestringYesHuman-readable entity name
keystringYesUnique identifier (used in API endpoints)
pluralisedNamestringYesPlural form of the name
isActivebooleanYesWhether entity is active
allowCommentsbooleanYesEnable comments on records
allowTasksbooleanYesEnable tasks on records
maxAttachmentSizeAllowednumber | nullNoMax attachment size in bytes
acceptedAttachmentFileTypesstring[]NoAllowed file MIME types

Field Types​

Stack9 supports various field types to model your data:

Stack9 Console — Domain Entity detail screen with the field editor listing entity fields and a side panel showing label, key, field type, validation rules and behaviour options

1. TextField​

Single-line text input.

{
"label": "Email Address",
"key": "email",
"type": "TextField",
"placeholder": "Enter email address",
"description": "Primary contact email",
"validateRules": {
"required": true,
"maxLength": 100,
"minLength": 5,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$" // Email validation pattern
},
"typeOptions": {
"multiLine": false, // Single-line input
"text": false // Use varchar instead of text column
},
"behaviourOptions": {
"readOnly": false
}
}

Type Options:

  • multiLine: Enable multi-line text input
  • text: Use text database column type instead of varchar
  • sensitive: Mark field as containing sensitive data

Note: Use validation patterns for email, phone, or URL validation instead of a format property.

2. NumericField​

Number input with validation.

{
"label": "Age",
"key": "age",
"type": "NumericField",
"validateRules": {
"required": true,
"min": 18, // Minimum value (use min: 0 to prevent negatives)
"max": 120
},
"typeOptions": {
"precision": 0 // Number of decimal places
}
}

Type Options:

  • precision: Number of decimal places

Note: To prevent negative values, use min: 0 in validateRules. There is no allowNegative or decimals property.

3. DateField​

Date or datetime picker.

{
"label": "Date of Birth",
"key": "dob",
"type": "DateField",
"placeholder": "Select date",
"typeOptions": {
"time": false // Set to true for datetime
},
"validateRules": {
"required": true
}
}

4. Checkbox​

Boolean field (true/false).

{
"label": "Is Active",
"key": "is_active",
"type": "Checkbox",
"defaultValue": true,
"description": "Whether the customer is active"
}

5. OptionSet​

Dropdown with predefined values.

{
"label": "Customer Type",
"key": "customer_type",
"type": "OptionSet",
"typeOptions": {
"values": ["Individual", "Business"]
},
"validateRules": {
"required": true
}
}

6. SingleDropDown​

Foreign key relationship (Many-to-One).

{
"label": "Sales Channel",
"key": "sales_channel_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "sales_channel" // Related entity key
},
"typeOptions": {
"label": "name" // Field to display in dropdown
},
"validateRules": {
"required": false
}
}

7. MultiDropDown​

Many-to-Many relationship.

{
"label": "Available Times",
"key": "available_times",
"type": "MultiDropDown",
"relationshipOptions": {
"ref": "available_time"
},
"typeOptions": {
"label": "{{period}}", // Template for display
"value": "{{id}}" // Value field
}
}

8. Grid​

One-to-Many relationship (child records).

{
"label": "Alternative Emails",
"key": "alternative_emails",
"type": "Grid",
"relationshipOptions": {
"ref": "alternative_email" // Related entity
},
"typeOptions": {
"relationshipField": "customer_id" // FK field in child entity
}
}

9. RichTextEditor​

HTML content editor.

{
"label": "Notes",
"key": "notes",
"type": "RichTextEditor",
"description": "Additional notes about the customer"
}

Field Properties​

All fields share these common properties:

PropertyTypeRequiredDescription
labelstringYesDisplay label
keystringYesDatabase column name (snake_case)
typestringYesField type
placeholderstringNoPlaceholder text
descriptionstringNoHelp text
defaultValueanyNoDefault value for new records
validateRulesobjectNoValidation rules
behaviourOptionsobjectNoUI behavior options
typeOptionsobjectNoType-specific options
indexbooleanNoCreate database index

Field descriptions​

A field's description is one definition shared by people and AI agents:

  • Forms — every form generated from the entity passes the description to the field component as its hint text, so you write it once rather than per form.
  • AI agents — yarn generate-models carries the description into the generated input models (as the field's schema description), and MCP query tools that derive their input schema from those models show it to agents as the argument description. An agent working with the field, including a Stack9 AI Agent, reads the same definition a user sees instead of guessing what the term means.

Write descriptions for both audiences: say what the value means and any format or unit, not just restate the label.

Validation Rules​

{
"validateRules": {
"required": true, // Field is required
"maxLength": 100, // Max string length
"minLength": 5, // Min string length
"max": 999, // Max numeric value
"min": 0, // Min numeric value
"pattern": "^[A-Z0-9]+$" // Regex pattern
}
}

Behaviour Options​

{
"behaviourOptions": {
"readOnly": true, // Field cannot be edited
"hidden": false // Hide field in forms
}
}

Relationships​

One-to-Many (Parent → Children)​

Use the Grid field type in the parent entity:

Parent Entity (customer.json):

{
"label": "Sales Orders",
"key": "sales_orders",
"type": "Grid",
"relationshipOptions": {
"ref": "sales_order"
},
"typeOptions": {
"relationshipField": "customer_id"
}
}

Child Entity (sales_order.json):

{
"label": "Customer",
"key": "customer_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "customer"
},
"typeOptions": {
"label": "name"
}
}

Many-to-One (Child → Parent)​

Use the SingleDropDown field type:

{
"label": "Customer",
"key": "customer_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "customer"
},
"typeOptions": {
"label": "name"
}
}

Many-to-Many​

Use the MultiDropDown field type:

{
"label": "Tags",
"key": "tags",
"type": "MultiDropDown",
"relationshipOptions": {
"ref": "tag"
},
"typeOptions": {
"label": "name",
"value": "id"
}
}

Stack9 automatically creates a junction table for many-to-many relationships.

Real-World Example​

Here's a simplified Customer entity from a production Stack9 app:

{
"head": {
"name": "Customer",
"key": "customer",
"pluralisedName": "customers",
"allowComments": true,
"allowTasks": true,
"maxAttachmentSizeAllowed": 30000000,
"acceptedAttachmentFileTypes": [
"image/jpg",
"image/jpeg",
"image/png",
"application/pdf"
]
},
"fields": [
{
"label": "CRN",
"key": "crn",
"type": "NumericField",
"description": "Unique customer reference number",
"behaviourOptions": {
"readOnly": true
}
},
{
"label": "Customer Type",
"key": "customer_type",
"type": "OptionSet",
"typeOptions": {
"values": ["Individual", "Business"]
},
"validateRules": {
"required": true
}
},
{
"label": "First Name",
"key": "name",
"type": "TextField",
"placeholder": "First name or Business Name",
"validateRules": {
"required": true,
"maxLength": 100,
"minLength": 1
}
},
{
"label": "Last Name",
"key": "last_name",
"type": "TextField",
"validateRules": {
"maxLength": 80
}
},
{
"label": "Email",
"key": "email_address",
"type": "TextField",
"validateRules": {
"required": true,
"maxLength": 100,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"
}
},
{
"label": "Phone",
"key": "phone",
"type": "TextField",
"validateRules": {
"maxLength": 13,
"pattern": "^\\+?[1-9]\\d{1,14}$"
}
},
{
"label": "Date of Birth",
"key": "dob",
"type": "DateField",
"typeOptions": {
"time": false
}
},
{
"label": "Is Active",
"key": "is_active",
"type": "Checkbox",
"defaultValue": true
},
{
"label": "Sales Channel",
"key": "sales_channel_id",
"type": "SingleDropDown",
"relationshipOptions": {
"ref": "sales_channel"
},
"typeOptions": {
"label": "name"
}
},
{
"label": "Sales Orders",
"key": "sales_orders",
"type": "Grid",
"relationshipOptions": {
"ref": "sales_order"
},
"typeOptions": {
"relationshipField": "customer_id"
}
}
],
"hooks": []
}

Auto-Generated Features​

When you create this entity, Stack9 automatically generates:

Database Table​

CREATE TABLE customers (
id SERIAL PRIMARY KEY,
crn INTEGER,
customer_type VARCHAR(50),
name VARCHAR(100) NOT NULL,
last_name VARCHAR(80),
email_address VARCHAR(100) NOT NULL,
phone VARCHAR(13),
dob DATE,
is_active BOOLEAN DEFAULT true,
sales_channel_id INTEGER REFERENCES sales_channels(id),
_created_at TIMESTAMP DEFAULT NOW(),
_updated_at TIMESTAMP DEFAULT NOW(),
_created_by INTEGER,
_updated_by INTEGER,
_is_deleted BOOLEAN DEFAULT false
);

REST API Endpoints​

GET    /api/customer/:id          - Get customer by ID
POST /api/customer - Create customer
PUT /api/customer/:id - Update customer
DELETE /api/customer/:id - Soft delete customer
POST /api/customer/search - Search customers
POST /api/customer/query - Query with aggregations

TypeScript Models​

interface Customer {
id: number;
crn?: number;
customer_type?: 'Individual' | 'Business';
name: string;
last_name?: string;
email_address: string;
phone?: string;
dob?: Date;
is_active?: boolean;
sales_channel_id?: number;
_created_at?: Date;
_updated_at?: Date;
_created_by?: number;
_updated_by?: number;
_is_deleted?: boolean;
}

System Fields​

Every entity automatically includes these system fields:

FieldTypeDescription
idintegerPrimary key (auto-increment)
_created_attimestampCreation timestamp
_updated_attimestampLast update timestamp
_created_byintegerUser who created the record
_updated_byintegerUser who last updated the record
_is_deletedbooleanSoft delete flag

Best Practices​

1. Use Consistent Naming​

  • Entity keys: customer, sales_order (lowercase, underscore)
  • Field keys: first_name, email_address (snake_case)
  • Label everything clearly for non-technical users

2. Add Descriptions​

{
"label": "CRN",
"key": "crn",
"description": "Unique customer reference number - automatically generated"
}

3. Set Appropriate Validation​

{
"validateRules": {
"required": true,
"maxLength": 100,
"minLength": 1
}
}

4. Use Read-Only Fields for System-Generated Values​

{
"key": "crn",
"behaviourOptions": {
"readOnly": true
}
}

5. Index Frequently Queried Fields​

{
"key": "email_address",
"index": true
}

Organize fields in the JSON in a logical order:

  • Basic info first (name, email)
  • Contact details next
  • Relationships at the end

Common Patterns​

Auto-Generated Sequence Numbers​

{
"label": "Order Number",
"key": "order_number",
"type": "NumericField",
"behaviourOptions": {
"readOnly": true
}
}

Then use an entity hook to generate the sequence:

// entity-hooks/order.vat.ts
const orderNumber = await db.sequence.nextVal('sales_order', 1, 999999);

Conditional Fields​

Conditional field display is typically handled through custom UI components or screen configurations, not at the entity level. Use behaviourOptions to control basic visibility:

{
"label": "Business ABN",
"key": "business_abn",
"type": "TextField",
"behaviourOptions": {
"hidden": false // Can be toggled via entity hooks based on conditions
}
}

Note: Complex conditional logic should be implemented in entity hooks or custom UI components.

Calculated Fields​

Define fields that are calculated in entity hooks:

{
"label": "Age",
"key": "age",
"type": "NumericField",
"behaviourOptions": {
"readOnly": true
}
}

Calculate in hook:

const age = dayjs().diff(dayjs(entity.dob), 'year');

Next Steps​

Now that you understand entities, learn about: