Canned Expressions
Reusable, named expressions stored in the database and referenced by alias — with tenant and app-level scoping.
Contents
What Are Canned Expressions?
A canned expression is a named alias stored in the database that maps to a full expression string. Instead of repeating {@ $ctx.tenant.defaultCurrency } in every workflow node, you define it once as @company.currency and reference that alias everywhere.
Benefits:
- DRY — change the underlying expression once in the database; all workflows update automatically
- Scoping — the same alias can have different expressions for different tenants or applications
- Access control — workflow authors reference aliases without needing to know internal system paths
- Composability — canned expressions can reference other canned expressions (depth-tracked)
Syntax
Canned expressions use the AtSign parser (AtSignWildcardParser). The syntax is simply @name or @category.name:
Simple alias
@company.currency
In a template string (mixed with other directives)
Total: {@ $var.subtotal } @company.currency
Alias that itself contains an expression (nested)
@hr.approvalEmail
Stored as: {@ $ctx.tenant.hrDepartmentEmail @lowercase } → resolved at runtime
The @ prefix is the alias directive
When the AtSign parser detects
@name, it routes to CannedExpressionDirectiveService (directive name: "@"). The name is stripped of the @ and used as the lookup key.
Resolution Order
When resolving alias @company.currency for tenant 5, app 12:
- Look up
(TenantId=5, AppId=12, Name="company.currency")— app-specific - If not found:
(TenantId=5, AppId=NULL, Name="company.currency")— tenant-wide - If not found:
Failure(CannedExpressionNotFound)
This hierarchy allows platform-wide defaults to be overridden per-tenant, and tenant defaults to be overridden per-application:
// Tenant 5 (default): @company.currency = "{@ $ctx.env.DEFAULT_CURRENCY }" = "EUR"
// Tenant 5, App 7 (UK subsidiary): @company.currency = "GBP"
// Tenant 5, App 3 (US entity): @company.currency = "USD"
// For AppId=7 → "GBP" (app-specific wins)
// For AppId=3 → "USD" (app-specific wins)
// For AppId=9 → "EUR" (falls back to tenant-wide)
Database Schema
CREATE TABLE Expression_CannedExpressions (
ExpressionID INT IDENTITY(1,1) CONSTRAINT PK_Expression_CannedExpressions PRIMARY KEY,
TenantID INT NOT NULL,
AppID INT NULL, -- NULL = tenant-wide scope
Name NVARCHAR(200) NOT NULL, -- e.g., "company.currency"
Expression NVARCHAR(MAX) NOT NULL, -- the stored expression string
Description NVARCHAR(500) NULL,
Tags NVARCHAR(MAX) NULL, -- JSON array: ["billing","finance"]
IsActive BIT NOT NULL CONSTRAINT DF_Expression_CannedExpressions_IsActive DEFAULT (1),
-- Standard audit columns
CreatedOn DATETIME NOT NULL,
CreatedBy INT NOT NULL,
LastModifiedOn DATETIME NOT NULL,
LastModifiedBy INT NOT NULL,
Deleted BIT NOT NULL CONSTRAINT DF_Expression_CannedExpressions_Deleted DEFAULT (0),
Archived BIT NOT NULL CONSTRAINT DF_Expression_CannedExpressions_Archived DEFAULT (0),
SourceAppID INT NULL,
ClientAccountID INT NULL,
AppDomainID INT NULL,
DataDomainID INT NULL,
DataSegmentID INT NULL,
ResID UNIQUEIDENTIFIER NULL,
CONSTRAINT UQ_Expression_CannedExpressions_TenantAppName
UNIQUE (TenantID, AppID, Name)
);
ICannedExpressionStore
public interface ICannedExpressionStore
{
/// Returns the expression string for the given alias, or null if not found.
/// Applies resolution order: (TenantId, AppId, Name) → (TenantId, NULL, Name)
Task<string?> GetAsync(
string name,
string tenantId,
string? appId,
CancellationToken ct);
/// Returns all active canned expressions for a tenant (for discovery/UI)
Task<IReadOnlyList<CannedExpressionEntry>> GetAllAsync(
string tenantId,
string? appId,
CancellationToken ct);
}
How the @ Directive Works
public class CannedExpressionDirectiveService : BaseDirectiveEvaluator
{
public override string DirectiveName => "@";
protected override async Task<EvaluationResponse> EvaluateAsync(
EvaluationRequest request,
CancellationToken ct)
{
var aliasName = request.Path; // e.g., "company.currency"
// Cycle detection — prevent @loopA → @loopB → @loopA
if (request.VisitedKeys.Contains(aliasName))
return EvaluationResponse.Failure(
EvaluationErrorCode.CycleDetected,
$"Cycle detected: alias '{aliasName}' has already been visited");
var storedExpr = await _store.GetAsync(
aliasName,
Context.TenantId,
Context.AppId,
ct);
if (storedExpr is null)
return EvaluationResponse.Failure(
EvaluationErrorCode.CannedExpressionNotFound,
$"Canned expression '@{aliasName}' not found");
// Chain: re-enter full pipeline with stored expression
GuardChainDepth();
return await ChainExpressionAsync(storedExpr, ct);
}
}
Common Patterns
Pattern 1 — Tenant Constants
| Alias | Stored Expression | Description |
|---|---|---|
@company.currency | {@ $ctx.tenant.currency } | Tenant default currency |
@company.timezone | {@ $ctx.tenant.timezone } | Tenant timezone |
@company.supportEmail | support@acme.com | Static value |
@company.taxRate | 0.20 | Static tax rate (configurable without code) |
Pattern 2 — Cross-App Overrides
-- All apps (AppId=NULL): @tax.rate = "0.20"
-- Ireland app (AppId=7): @tax.rate = "0.23"
-- US app (AppId=3): @tax.rate = "0.00" (tax-exempt entity)
Pattern 3 — Composed Aliases
-- @billing.recipient = "@company.billingContact"
-- @company.billingContact = "{@ $ctx.tenant.billingEmail @lowercase }"
-- Chain: @billing.recipient → @company.billingContact → $ctx.tenant.billingEmail
Managing via API
Canned expressions are managed through a CRUD REST API. Common operations:
// Create a new canned expression
POST /api/expressions/canned
{
"tenantId": 5,
"appId": null, // null = tenant-wide
"name": "company.currency",
"expression": "{@ $ctx.tenant.currency }",
"description": "Tenant default currency code",
"tags": ["finance", "global"]
}
// Update (changing logic without touching any workflow)
PUT /api/expressions/canned/{id}
{
"expression": "EUR" // switch from dynamic to static
}
// Preview — evaluate the expression without a workflow context
POST /api/expressions/canned/{id}/preview
{
"tenantId": 5,
"appId": 7
}