13.1 Overview
Computed fields are derived values calculated from other fields on the same resource or from related resources. They appear in API responses as regular fields but are never editable — they are excluded from the input schema on create and update. The runtime manages their values automatically.
ADL supports three kinds of computed fields, distinguished by storage strategy and trigger source:
| Kind | Stored in DB? | Trigger | Use Case |
|---|---|---|---|
| Virtual | No | Evaluated on every read | Display strings, boolean flags, simple derivations |
| Materialized | Yes | Recalculated on write to trigger fields | Monetary calculations, values queried in bulk |
| Aggregation | Yes | Recalculated when child records change | Counts, sums, averages across relationships |
Computed fields are declared in the computed: block of a resource, separate from the object: block:
resources:
Order:
object:
id: { type: uuid, primaryKey: true }
customerId: { type: uuid, required: true }
$merge: Timestamp
computed:
orderTotal:
type: money
formula: "sum(orderItems.subtotal)"
stored: true
updateOn: [orderItems.create, orderItems.update, orderItems.delete]
13.2 Virtual Computed Fields
Virtual fields are calculated on every read request. They have no database column, consume no storage, and are always current. They cannot be filtered, sorted, or indexed because they exist only in the API response, not in the database.
computed:
fullName:
type: string
formula: "concat(firstName, ' ', lastName)"
isOverdue:
type: boolean
formula: "status != 'closed' && dueDate != null && dueDate < now()"
displayLabel:
type: string
formula: "concat(sku, ' — ', name)"
Declaration
| Key | Type | Default | Description |
|---|---|---|---|
type | string | required | The result type of the computed field. |
formula | string | required | AEL expression that produces the value. |
stored | boolean | false | When false (or omitted), the field is virtual. |
description | string | "" | Human-readable description. |
When to Use Virtual
- Simple concatenations or formatting for display (
fullName,displayLabel) - Boolean flags derived from current state (
isActive,isExpired,isOverdue) - Values that change on every read (formulas referencing
now()) - Fields that are never used in queries (filtering, sorting, aggregation)
When NOT to Use Virtual
- Aggregations (count, sum, avg across child records) — virtual aggregations execute a SQL subquery on every read. For a list endpoint returning 100 records, that is 100 subqueries. Use stored aggregation instead.
- Values used in filters or sorts — virtual fields have no database column, so
?filter[fullName]=Aliceand?sort=fullNameare not supported. - Expensive calculations — if the formula involves cross-resource lookups or complex logic, every read pays the cost. Use materialized instead.
13.3 Materialized Computed Fields
Materialized fields are stored as real database columns. They are calculated when the record is created and recalculated whenever any of their trigger fields change. Because they have database columns, they can be filtered, sorted, and indexed.
computed:
subtotal:
type: decimal
formula: "quantity * unitPrice"
stored: true
updateOn: [quantity, unitPrice]
description: "Recalculated when quantity or unitPrice changes"
Declaration
| Key | Type | Default | Description |
|---|---|---|---|
type | string | required | Result type. |
formula | string | required | AEL expression. |
stored | boolean | true | Must be true for materialized fields. |
updateOn | string[] | required | Field names on this resource that trigger recalculation. |
description | string | "" | Human-readable description. |
Trigger Behavior
When a create or update request modifies any field listed in updateOn, the runtime:
- Persists the submitted field values.
- Evaluates the
formulaagainst the persisted record. - Stores the computed result in the materialized column.
- Returns the response with the updated computed value.
All three steps occur within the same database transaction. The client sees the correct computed value in the create/update response without a subsequent GET.
Recalculation on Create
Materialized fields are always calculated on create, regardless of whether the updateOn fields are explicitly provided. If a field has a default value, the formula evaluates against the defaulted values.
DDL
Materialized fields generate a database column identical to a regular field of the same type. The column is managed by the runtime — it does not appear in the input schema, and client-submitted values for it are silently ignored.
13.4 Aggregation Computed Fields
Aggregation fields are stored values that summarize data from related resources. They are the most architecturally demanding computed field kind because they require cross-resource event handling.
computed:
commentCount:
type: integer
formula: "count(comments)"
stored: true
updateOn: [comments.create, comments.delete]
orderTotal:
type: money
formula: "sum(orderItems.subtotal)"
stored: true
updateOn: [orderItems.create, orderItems.update, orderItems.delete]
averageRating:
type: decimal
formula: "avg(reviews.rating)"
stored: true
updateOn: [reviews.create, reviews.update, reviews.delete]
Declaration
| Key | Type | Default | Description |
|---|---|---|---|
type | string | required | Result type. |
formula | string | required | Aggregation expression: count(rel), sum(rel.field), avg(rel.field), min(rel.field), max(rel.field). |
stored | boolean | true | Must be true for aggregation fields. |
updateOn | string[] | required | Relationship event patterns: {relationship}.{event}. |
description | string | "" | Human-readable description. |
Aggregation Functions
| Function | Syntax | Returns | Description |
|---|---|---|---|
count | count(relationship) | integer | Number of related records |
sum | sum(relationship.field) | decimal/money | Sum of a numeric field across related records |
avg | avg(relationship.field) | decimal | Average of a numeric field |
min | min(relationship.field) | varies | Minimum value |
max | max(relationship.field) | varies | Maximum value |
Event Pattern Syntax
The updateOn array uses {relationship}.{event} patterns:
| Pattern | Triggers When |
|---|---|
orderItems.create | An OrderItem is created with a FK pointing to this Order |
orderItems.update | An OrderItem belonging to this Order is updated |
orderItems.delete | An OrderItem belonging to this Order is deleted |
The runtime resolves the relationship to identify which parent record to recalculate. For a belongsTo relationship, the child’s FK value identifies the parent. When an OrderItem with orderId = "order-1" is created, the runtime recalculates orderTotal on the Order with id = "order-1".
Cross-Resource Execution
When a child record triggers an aggregation update:
- The child record’s write completes (create, update, or delete).
- The runtime identifies the parent record via the relationship FK.
- The runtime executes the aggregation query:
SELECT SUM(subtotal) FROM order_items WHERE orderId = ?. - The runtime updates the parent record’s computed column with the result.
- Both operations occur within the same transaction.
If the child record’s FK changes on update (e.g., an OrderItem is moved to a different Order), BOTH the old parent and the new parent are recalculated.
13.5 Kind Auto-Detection
The parser auto-detects the computed field kind from the declaration:
stored | Formula Pattern | Detected Kind |
|---|---|---|
false or omitted | Any | Virtual |
true | No aggregation function | Materialized |
true | Starts with count(, sum(, avg(, min(, max( | Aggregation |
The domain author does not declare the kind explicitly. The parser infers it from the combination of stored and the formula pattern.
13.6 Dependency Graphs
Computed fields may depend on other computed fields, forming dependency chains:
computed:
subtotal:
type: money
formula: "quantity * unitPrice"
stored: true
updateOn: [quantity, unitPrice]
taxAmount:
type: money
formula: "subtotal * taxRate"
stored: true
updateOn: [subtotal, taxRate]
total:
type: money
formula: "subtotal + taxAmount + shippingCost"
stored: true
updateOn: [subtotal, taxAmount, shippingCost]
When quantity changes:
subtotalrecalculates (quantity * unitPrice)taxAmountrecalculates (subtotal * taxRate) becausesubtotalis in itsupdateOntotalrecalculates (subtotal + taxAmount + shippingCost) because bothsubtotalandtaxAmountare in itsupdateOn
The runtime resolves the dependency order at domain parse time using topological sorting. Fields are recalculated in dependency order within a single transaction.
Circular Dependency Detection
The parser detects circular dependencies and rejects them with HTTP 422:
{
"status": "error",
"code": 422,
"error_code": "CIRCULAR_DEPENDENCY",
"error_message": "Circular dependency detected in computed fields: subtotal → total → subtotal"
}
Circular dependencies are a parse-time error. They cannot occur at runtime.
Maximum Chain Depth
The runtime enforces a maximum dependency chain depth (default: 10 levels). If a chain exceeds this depth, the parser rejects it. This prevents pathological cases where deeply nested dependencies create excessive recalculation cascades.
13.7 Reactive Computed Fields [v0.3]
Standard materialized fields recalculate when the declaring resource’s trigger fields change. Reactive computed fields extend this to respond to changes in ANY record that affects the formula, including cross-record and cross-resource changes.
computed:
categoryBudgetTotal:
type: money
formula: "sum(BudgetLine, 'total', category == $this.category)"
stored: true
reactive: true
recomputeOn: [BudgetLine.create, BudgetLine.update, BudgetLine.delete]
Declaration
| Key | Type | Default | Description |
|---|---|---|---|
reactive | boolean | false | If true, the runtime maintains a dependency graph and recomputes when source values change across records. |
recomputeOn | string[] | [] | Bus event patterns that trigger recomputation. Uses the same {resource}.{event} syntax as aggregation updateOn. |
Difference from Aggregation
Standard aggregation fields use relationship-based triggers: “when MY OrderItems change, recalculate MY orderTotal.” Reactive fields use resource-level triggers: “when ANY BudgetLine changes, recalculate ALL BudgetSummary records whose category matches.” The scope is broader — reactive fields can respond to changes in resources they have no direct relationship with.
Performance Considerations
Reactive fields with broad triggers (e.g., recomputeOn: [BudgetLine.update]) cause recalculation on every write to the triggering resource. For high-write-volume resources, this can be expensive. The runtime uses the formula’s filter expression to narrow which records actually need recalculation, but the evaluation overhead is proportional to the number of potentially-affected parent records.
Reactive computed fields SHOULD be used sparingly and only when the summary value must be current in real-time. For reporting scenarios where near-real-time is acceptable, consider using views (§26) with scheduled refresh instead.
13.8 Formula Language
Computed field formulas use a subset of AEL (§36). The formula engine supports arithmetic, string operations, conditionals, and aggregation functions, but does NOT support auth functions, state functions, or query functions.
Available Operators
| Operator | Meaning | Example |
|---|---|---|
+ | Addition / string concatenation | quantity + bonus |
- | Subtraction | total - discount |
* | Multiplication | quantity * unitPrice |
/ | Division | total / count |
% | Modulo | index % 2 |
== | Equality | status == 'active' |
!= | Inequality | type != 'free' |
>, >=, <, <= | Comparison | dueDate < now() |
&& | Logical AND | isActive && !isExpired |
|| | Logical OR | status == 'a' || status == 'b' |
! | Logical NOT | !isDeleted |
? : | Ternary | quantity > 10 ? price * 0.9 : price |
?? | Nullish coalescing | discount ?? 0 |
Available Functions
String: concat(a, b, ...), upper(s), lower(s), trim(s), substring(s, start, end?)
Math: round(n, decimals?), floor(n), ceil(n), abs(n), min(a, b), max(a, b)
Conditional: if(condition, then, else), coalesce(a, b, ...)
Date: now(), today()
Aggregation: count(rel), sum(rel.field), avg(rel.field), min(rel.field), max(rel.field)
Function Scoping
Computed field formulas operate in the computed field context (§36.4). Auth functions (hasRole(), isOwner()), state functions (inState()), and query functions (query(), exists()) are NOT available. These functions require request context (authenticated user, operation type) that does not exist during computed field evaluation.
If a computed value requires auth-context or cross-resource lookup logic that the formula engine cannot express, use a Lua hook instead (§31).
Field References
Formula expressions reference fields by name. Bare field names resolve against the current record:
formula: "quantity * unitPrice"
# Equivalent to: $this.quantity * $this.unitPrice
For aggregation formulas, the relationship name references the related resource’s records:
formula: "sum(orderItems.subtotal)"
# SQL: SELECT SUM(subtotal) FROM order_items WHERE orderId = $this.id
13.9 API Behavior
Response Inclusion
Computed fields appear in GET responses alongside regular fields:
{
"id": "customer-1",
"firstName": "Alice",
"lastName": "Smith",
"fullName": "Alice Smith",
"email": "alice@example.com"
}
The fullName field is computed — it has no database column (virtual) or is auto-managed (materialized/aggregation). The API consumer cannot distinguish computed fields from regular fields in the response unless they inspect the schema.
Input Exclusion
Computed fields are excluded from the input schema. They do not appear in the OpenAPI request body definition. If a client submits a computed field in a create or update request, the value is silently ignored.
Schema Endpoint
The schema introspection endpoint identifies computed fields:
{
"computed": {
"fullName": {
"type": "string",
"formula": "concat(firstName, ' ', lastName)",
"stored": false,
"kind": "virtual"
},
"orderTotal": {
"type": "money",
"formula": "sum(orderItems.subtotal)",
"stored": true,
"kind": "aggregation",
"updateOn": ["orderItems.create", "orderItems.update", "orderItems.delete"]
}
}
}
A UI generator uses this data to:
- Render computed fields as read-only in forms
- Exclude computed fields from create/edit form inputs
- Display dependency information (“updates when quantity or unitPrice changes”)
- Show aggregation badges on parent records (“12 comments”, “$1,234.56 total”)
Filtering and Sorting
| Kind | Filterable? | Sortable? | Indexable? |
|---|---|---|---|
| Virtual | No | No | No |
| Materialized | Yes | Yes | Yes |
| Aggregation | Yes | Yes | Yes |
Virtual fields have no database column and cannot participate in SQL WHERE or ORDER BY clauses. Materialized and aggregation fields have real columns and support all query operations.
13.10 Migration Behavior
Computed fields interact with the migration system:
| Change | Migration |
|---|---|
| Add a virtual field | No DDL — virtual fields have no column |
| Add a materialized/aggregation field | ALTER TABLE ADD COLUMN — then backfill all existing records |
| Remove a virtual field | No DDL |
| Remove a stored field | DROP COLUMN (if destructive) |
| Change a formula | No DDL — the formula is evaluated at runtime, not stored in DDL |
Change stored: false → stored: true | ALTER TABLE ADD COLUMN + backfill |
Change stored: true → stored: false | DROP COLUMN (if destructive) |
Backfill on add: When a new materialized or aggregation field is added to a resource with existing records, the migration system generates the column and then backfills all rows by evaluating the formula against each record. For aggregation fields, this requires running the aggregation query for each parent record. Large tables may take significant time to backfill.
End of Chapter 13.
Next: Chapter 14 — Validation