Chapter 14 · Volume II — The ADL Domain Language

Validation

14.1 Overview

ADL enforces data integrity through two validation layers:

  1. Field-level constraints — type checking, required fields, unique constraints, min/max bounds, pattern matching, and enum membership. These are declared on individual fields (§7.7) and enforced automatically.

  2. Cross-field validation rules — business rules that span multiple fields or require conditional logic. These are declared in the validation: block and use AEL expressions.

Both layers execute at the API boundary on every create and update operation, before any data reaches the database. Validation failures return HTTP 422 with structured per-field and per-rule error details.


14.2 Field-Level Constraints

Field-level constraints are declared alongside the field type (§7.7) and enforced automatically by the runtime. No additional declaration is needed.

object:
  title:
    type: string
    required: true
    maxLength: 200
    trim: true

  email:
    type: email
    required: true
    unique: true
    lowercase: true

  price:
    type: decimal
    min: 0
    precision: 10
    scale: 2

  sku:
    type: string
    required: true
    unique: true
    immutable: true
    pattern: "^[A-Z]{2}-[0-9]{4}$"
    uppercase: true

Enforcement Order

Field-level constraints are evaluated in a fixed order for each field:

  1. Type check — Is the value the correct JSON type?
  2. Required check — Is the field present and non-null (on create)?
  3. Null check — If nullable: false, is the value null?
  4. Transform — Apply trim, lowercase, uppercase
  5. Format check — Email format, URL format, UUID format, phone pattern
  6. Enum check — Is the value in the allowed set?
  7. Length checkminLength, maxLength
  8. Range checkmin, max
  9. Pattern check — Regex match
  10. Immutable check — On update, has an immutable field changed?
  11. Unique check — Does another record have the same value? (database query)

If any check fails, validation stops for that field and the error is recorded. All fields are validated before the response is returned — the client receives all errors at once.

Required vs Nullable

requirednullableCreate BehaviorUpdate (PATCH) Behavior
truefalseMUST be present and non-nullMay be omitted (unchanged); if present, must be non-null
truetrueMUST be present; may be nullMay be omitted; if present, may be null
falsefalseMay be omitted; if present, must be non-nullSame
falsetrueMay be omitted or nullSame

On PUT (full replace), all required fields MUST be present, since PUT replaces the entire record.

Unique Constraint

Unique constraints are checked against the database after all other field-level validations pass. If another record has the same value, the operation returns HTTP 409:

{
  "status": "error",
  "code": 409,
  "error_code": "DUPLICATE",
  "error_message": "A record with email 'alice@example.com' already exists"
}

On update, the unique check excludes the current record — a record may keep its own unique value without triggering a conflict.

Immutable Constraint

On update, if a field is immutable: true and the submitted value differs from the stored value, the operation returns HTTP 422:

{
  "status": "error",
  "code": 422,
  "error_code": "IMMUTABLE_FIELD",
  "error_message": "Field 'orderNumber' is immutable and cannot be changed after creation"
}

Submitting the same value or omitting the field from a PATCH is allowed.


14.3 Cross-Field Validation Rules

When validation logic spans multiple fields or requires conditional logic, the validation: block declares named rules using AEL expressions:

validation:
  dateOrder:
    rule: "endDate == null || endDate > startDate"
    message: "End date must be after start date"

  pricing:
    rule: "salePrice == null || salePrice <= originalPrice"
    message: "Sale price cannot exceed original price"

  publishReady:
    rule: "status != 'published' || (title != null && body != null && excerpt != null)"
    message: "Published articles require title, body, and excerpt"

Declaration

KeyTypeRequiredDescription
rulestringyesAEL expression that MUST evaluate to true for the record to be valid.
messagestringyesHuman-readable error message returned when the rule fails.
whenstringnoWhen to evaluate: "always" (default), "create", "update".

14.4 The when Modifier

ValueMeaningUse Case
alwaysRuns on both create and updateMost cross-field constraints
createRuns only on create”Must be in the future” rules
updateRuns only on updateRules that compare against $old
validation:
  scheduledFuture:
    rule: "scheduledFor == null || scheduledFor > now()"
    message: "Scheduled date must be in the future"
    when: create

  quantityFloor:
    rule: "quantity >= $old.shippedQuantity"
    message: "Cannot reduce quantity below shipped amount"
    when: update

When omitted, defaults to always.


14.5 Evaluation Context

Cross-field rules evaluate against the AEL validation context (§36.4):

VariableTypeAvailabilityDescription
Field namesanyAlwaysDirect access to field values: startDate, price, status
$thisobjectAlwaysMerged record state (existing + new values)
$oldobjectUpdate onlyRecord before the update. null on create.
$opstringAlways"create" or "update"

Merged State on Update

On PATCH, $this contains the merged record: existing stored values with new submitted values overlaid. A rule like endDate > startDate evaluates correctly whether the request changes one field, both, or neither.

Example: existing record has startDate: 2026-01-01, endDate: 2026-12-31. A PATCH with { "startDate": "2027-01-01" } produces $this.startDate = 2027-01-01 and $this.endDate = 2026-12-31. The rule endDate > startDate2026-12-31 > 2027-01-01false → validation fails.

Using $old

The $old variable enables rules that compare against previous state:

validation:
  noDowngrade:
    rule: "$old == null || priority >= $old.priority"
    message: "Priority cannot be downgraded"
    when: update

The $old == null guard handles accidental evaluation on create.


14.6 Cross-Resource Validation

Validation rules MAY query other resources using query() and exists(). These functions are available ONLY in the validation context:

validation:
  uniqueSlugAcrossTypes:
    rule: "!exists('Page', { slug: $this.slug })"
    message: "This slug is already used by a Page"

  authorMustBeActive:
    rule: "query('User', { id: $this.authorId })[0].status == 'active'"
    message: "Author must be an active user"

  maxLineItems:
    rule: "len(query('OrderItem', { orderId: $this.id })) <= 100"
    message: "Orders cannot have more than 100 line items"

query(resource, filter) → array

Executes a read-only SQL SELECT against another resource’s table.

Constraints:

  • Read-only — no INSERT, UPDATE, or DELETE
  • Result capped at 100 rows (configurable)
  • Single query per expression (no nested query() calls)
  • Timeout: 50ms default (configurable)
  • Available ONLY in the validation context — compile-time error in permission, guard, or computed contexts

exists(resource, filter) → boolean

Shorthand for len(query(resource, filter)) > 0:

rule: "!exists('Reservation', { roomId: $this.roomId, date: $this.date, id_ne: $this.id })"
message: "Room is already reserved for this date"

14.7 Null-Safe Patterns

Validation rules MUST be null-safe. A rule that does not account for null values produces false negatives:

# WRONG — fails when endDate is null (null > startDate → false)
dateOrder:
  rule: "endDate > startDate"

# CORRECT — null endDate is valid (no constraint when absent)
dateOrder:
  rule: "endDate == null || endDate > startDate"

The pattern field == null || condition means “this rule doesn’t apply when the field is absent.” For rules where null IS invalid, use required: true on the field instead.


14.8 Error Response Format

Validation failures return HTTP 422 with all errors collected:

{
  "status": "error",
  "code": 422,
  "error_code": "VALIDATION_ERROR",
  "error_message": "Validation failed",
  "errors": [
    { "field": "title",     "message": "Title is required",                    "rule": "required" },
    { "field": "email",     "message": "Invalid email format",                 "rule": "type" },
    { "field": "price",     "message": "Price must be at least 0",             "rule": "min" },
    { "field": "sku",       "message": "Value does not match pattern",         "rule": "pattern" },
    { "field": "dateOrder", "message": "End date must be after start date",    "rule": "rule_failed" },
    { "field": "pricing",   "message": "Sale price cannot exceed original",    "rule": "rule_failed" }
  ]
}

Error Entry Fields

FieldTypeDescription
fieldstringField name for field-level errors; rule name for cross-field rules
messagestringHuman-readable error message
rulestringConstraint identifier: required, type, min, max, minLength, maxLength, pattern, enum, immutable, rule_failed

All Errors at Once

The runtime collects ALL validation errors before returning. The client receives the complete list in a single 422 response, allowing a UI to highlight all invalid fields simultaneously.

Evaluation Order

1. Field-level constraints (all fields)
   → If any field fails type or required: return 422 with field errors only

2. Cross-field validation rules (matching the current "when" context)
   → If any rule fails: return 422 with rule errors

Field-level constraints are evaluated BEFORE cross-field rules. If a field fails type checking, cross-field rules are skipped — the record is in an inconsistent state and rules would produce misleading errors.


14.9 Validation and State Machine Transitions

When a state machine transition is requested (§12):

  1. Field-level constraints are evaluated on fields in the request body (those provided for requiredFields or as additional fields).
  2. Cross-field validation rules with when: always or when: update are evaluated against the merged state (existing record + sets values + request body).
  3. sets values are applied before validation — a transition that sets closedAt: "now()" produces a merged state where closedAt is non-null, so rules that check closedAt != null pass.

14.10 Design Patterns

Guard Clauses with $op

validation:
  futureEvent:
    rule: "$op != 'create' || startDate > now()"
    message: "New events must be scheduled in the future"

Allows editing existing events with past dates while preventing new events from being created in the past.

Conditional Requirements

validation:
  reasonOnRejection:
    rule: "status != 'rejected' || rejectionReason != null"
    message: "Rejection reason is required when rejecting"

  excerptOnPublish:
    rule: "status != 'published' || (excerpt != null && len(excerpt) >= 50)"
    message: "Published articles require an excerpt of at least 50 characters"

Mutual Exclusion

validation:
  eitherDateOrRange:
    rule: "!(specificDate != null && dateRangeStart != null)"
    message: "Specify either a specific date or a date range, not both"

Cross-Resource Integrity

validation:
  managerInSameDept:
    rule: "managerId == null || query('Employee', { id: managerId })[0].departmentId == departmentId"
    message: "Manager must be in the same department"

  budgetNotExceeded:
    rule: "sum(query('LineItem', { projectId: $this.id }), 'amount') + amount <= budget"
    message: "Line item would exceed project budget"

Monotonic Progression

validation:
  versionIncreases:
    rule: "$old == null || version > $old.version"
    message: "Version number must increase on each update"
    when: update

  statusProgression:
    rule: "$old == null || statusOrder($this.status) >= statusOrder($old.status)"
    message: "Status cannot move backwards"
    when: update

End of Chapter 14.

Next: Chapter 15 — Resource Features