14.1 Overview
ADL enforces data integrity through two validation layers:
-
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.
-
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:
- Type check — Is the value the correct JSON type?
- Required check — Is the field present and non-null (on create)?
- Null check — If
nullable: false, is the value null? - Transform — Apply
trim,lowercase,uppercase - Format check — Email format, URL format, UUID format, phone pattern
- Enum check — Is the value in the allowed set?
- Length check —
minLength,maxLength - Range check —
min,max - Pattern check — Regex match
- Immutable check — On update, has an immutable field changed?
- 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
required | nullable | Create Behavior | Update (PATCH) Behavior |
|---|---|---|---|
true | false | MUST be present and non-null | May be omitted (unchanged); if present, must be non-null |
true | true | MUST be present; may be null | May be omitted; if present, may be null |
false | false | May be omitted; if present, must be non-null | Same |
false | true | May be omitted or null | Same |
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
| Key | Type | Required | Description |
|---|---|---|---|
rule | string | yes | AEL expression that MUST evaluate to true for the record to be valid. |
message | string | yes | Human-readable error message returned when the rule fails. |
when | string | no | When to evaluate: "always" (default), "create", "update". |
14.4 The when Modifier
| Value | Meaning | Use Case |
|---|---|---|
always | Runs on both create and update | Most cross-field constraints |
create | Runs only on create | ”Must be in the future” rules |
update | Runs only on update | Rules 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):
| Variable | Type | Availability | Description |
|---|---|---|---|
| Field names | any | Always | Direct access to field values: startDate, price, status |
$this | object | Always | Merged record state (existing + new values) |
$old | object | Update only | Record before the update. null on create. |
$op | string | Always | "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 > startDate → 2026-12-31 > 2027-01-01 → false → 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
| Field | Type | Description |
|---|---|---|
field | string | Field name for field-level errors; rule name for cross-field rules |
message | string | Human-readable error message |
rule | string | Constraint 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):
- Field-level constraints are evaluated on fields in the request body (those provided for
requiredFieldsor as additional fields). - Cross-field validation rules with
when: alwaysorwhen: updateare evaluated against the merged state (existing record +setsvalues + request body). setsvalues are applied before validation — a transition that setsclosedAt: "now()"produces a merged state whereclosedAtis non-null, so rules that checkclosedAt != nullpass.
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