Chapter 13 · Volume II — The ADL Domain Language

Computed Fields

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:

KindStored in DB?TriggerUse Case
VirtualNoEvaluated on every readDisplay strings, boolean flags, simple derivations
MaterializedYesRecalculated on write to trigger fieldsMonetary calculations, values queried in bulk
AggregationYesRecalculated when child records changeCounts, 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

KeyTypeDefaultDescription
typestringrequiredThe result type of the computed field.
formulastringrequiredAEL expression that produces the value.
storedbooleanfalseWhen false (or omitted), the field is virtual.
descriptionstring""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]=Alice and ?sort=fullName are 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

KeyTypeDefaultDescription
typestringrequiredResult type.
formulastringrequiredAEL expression.
storedbooleantrueMust be true for materialized fields.
updateOnstring[]requiredField names on this resource that trigger recalculation.
descriptionstring""Human-readable description.

Trigger Behavior

When a create or update request modifies any field listed in updateOn, the runtime:

  1. Persists the submitted field values.
  2. Evaluates the formula against the persisted record.
  3. Stores the computed result in the materialized column.
  4. 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

KeyTypeDefaultDescription
typestringrequiredResult type.
formulastringrequiredAggregation expression: count(rel), sum(rel.field), avg(rel.field), min(rel.field), max(rel.field).
storedbooleantrueMust be true for aggregation fields.
updateOnstring[]requiredRelationship event patterns: {relationship}.{event}.
descriptionstring""Human-readable description.

Aggregation Functions

FunctionSyntaxReturnsDescription
countcount(relationship)integerNumber of related records
sumsum(relationship.field)decimal/moneySum of a numeric field across related records
avgavg(relationship.field)decimalAverage of a numeric field
minmin(relationship.field)variesMinimum value
maxmax(relationship.field)variesMaximum value

Event Pattern Syntax

The updateOn array uses {relationship}.{event} patterns:

PatternTriggers When
orderItems.createAn OrderItem is created with a FK pointing to this Order
orderItems.updateAn OrderItem belonging to this Order is updated
orderItems.deleteAn 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:

  1. The child record’s write completes (create, update, or delete).
  2. The runtime identifies the parent record via the relationship FK.
  3. The runtime executes the aggregation query: SELECT SUM(subtotal) FROM order_items WHERE orderId = ?.
  4. The runtime updates the parent record’s computed column with the result.
  5. 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:

storedFormula PatternDetected Kind
false or omittedAnyVirtual
trueNo aggregation functionMaterialized
trueStarts 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:

  1. subtotal recalculates (quantity * unitPrice)
  2. taxAmount recalculates (subtotal * taxRate) because subtotal is in its updateOn
  3. total recalculates (subtotal + taxAmount + shippingCost) because both subtotal and taxAmount are in its updateOn

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

KeyTypeDefaultDescription
reactivebooleanfalseIf true, the runtime maintains a dependency graph and recomputes when source values change across records.
recomputeOnstring[][]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

OperatorMeaningExample
+Addition / string concatenationquantity + bonus
-Subtractiontotal - discount
*Multiplicationquantity * unitPrice
/Divisiontotal / count
%Moduloindex % 2
==Equalitystatus == 'active'
!=Inequalitytype != 'free'
>, >=, <, <=ComparisondueDate < now()
&&Logical ANDisActive && !isExpired
||Logical ORstatus == 'a' || status == 'b'
!Logical NOT!isDeleted
? :Ternaryquantity > 10 ? price * 0.9 : price
??Nullish coalescingdiscount ?? 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

KindFilterable?Sortable?Indexable?
VirtualNoNoNo
MaterializedYesYesYes
AggregationYesYesYes

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:

ChangeMigration
Add a virtual fieldNo DDL — virtual fields have no column
Add a materialized/aggregation fieldALTER TABLE ADD COLUMN — then backfill all existing records
Remove a virtual fieldNo DDL
Remove a stored fieldDROP COLUMN (if destructive)
Change a formulaNo DDL — the formula is evaluated at runtime, not stored in DDL
Change stored: falsestored: trueALTER TABLE ADD COLUMN + backfill
Change stored: truestored: falseDROP 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