6.1 YAML Format and Syntax
ADL domain definitions are written in YAML 1.2. YAML is the preferred format for human authorship due to its readability. JSON is accepted as an alternative for programmatic submission.
The parser detects the format automatically: content beginning with { or [ is parsed as JSON; all other content is parsed as YAML. The format field in the submission request MAY explicitly specify "yaml" or "json", but auto-detection is reliable and explicit format declaration is rarely needed.
All YAML examples in this specification use standard YAML 1.2 syntax. Flow mappings ({ key: value }) and block mappings are both valid:
# Block style (preferred for complex definitions)
title:
type: string
required: true
maxLength: 255
# Flow style (acceptable for simple definitions)
title: { type: string, required: true, maxLength: 255 }
Comments (#) are permitted in YAML and are stripped during parsing. They have no effect on the domain model.
6.2 Top-Level Keys
Every ADL domain file has a single top-level domain key containing the domain definition. The following keys are recognized within the domain block:
domain:
name: # Required. Domain identifier.
version: # Required. Semantic version string.
description: # Optional. Human-readable description.
config: # Optional. Domain-level configuration.
properties: # Optional. Reusable atomic type definitions.
objects: # Optional. Reusable composite field groups.
enums: # Optional. Enumeration definitions.
resources: # Required. Business entity definitions.
blocks: # Optional. Custom block type definitions for the blocks editor.
icons: # Optional. Icon set configuration.
fonts: # Optional. Font source configuration.
domain.name
The domain name is a string identifier used in table name prefixes, route paths, action names, and bus event names. It MUST be a non-empty string. It SHOULD use PascalCase or lowercase alphanumeric characters.
domain:
name: Blog
The following names are reserved and MUST NOT be used as domain names: auth, users, admin, domains, status, plugins, routes, version, openapi, echo. The check is case-insensitive — Auth, AUTH, and auth are all rejected by the parser.
domain.version
The version string follows semantic versioning (MAJOR.MINOR.PATCH). It MUST be quoted to prevent YAML from interpreting it as a floating-point number:
domain:
version: "1.0.0" # Correct — string
version: 1.0.0 # Incorrect — parsed as float 1.0
The version string is advisory. The migration system uses content hashing, not version comparison, to detect domain changes (§30.2). However, the version string is recorded in the domain registry and is visible in the admin API.
domain.description
An optional human-readable description of the domain. Included in OpenAPI generation and the admin API.
domain:
name: Orders
version: "2.1.0"
description: >
E-commerce order management with customers, products,
orders, and line items. Supports computed totals,
versioning, and CSV export.
domain.config
An optional configuration block for domain-level settings. Values in config MAY use environment variable substitution (§6.5):
domain:
config:
app_name: "${APP_NAME:My Application}"
max_upload_size: 10485760
Config values are accessible to AEL expressions via config('key') and to Lua scripts via kalos.config('key').
6.3 Multi-File Organization ($include)
For larger domains, the definition can be split across multiple files using the $include directive. The directive accepts an array of file paths, which MAY include glob patterns:
# domain.yaml (root file)
domain:
name: MyApp
version: "1.0.0"
$include:
- ./types/shared-types.yaml
- ./resources/*.yaml
- ./blocks/*.yaml
Resolution Rules
- Paths are resolved relative to the file containing the
$includedirective. - Glob patterns (
*,**) are expanded by the parser using filesystem matching. - Each included file MUST contain a valid YAML fragment that contributes to one or more top-level domain keys (
properties,objects,enums,resources,blocks,icons,fonts). - Included files MUST NOT contain a
domain:block — the domain metadata (name,version,description) is defined only in the root file. - Included files are merged into the root domain in the order they appear. If a key conflict occurs (e.g., two files define a resource with the same name), the later file’s definition wins.
Example Multi-File Layout
my-app/
domain.yaml ← Root file with $include
types/
shared-types.yaml ← properties, objects, enums
resources/
product.yaml ← Product resource definition
order.yaml ← Order resource definition
customer.yaml ← Customer resource definition
blocks/
callout.yaml ← Custom block definitions
types/shared-types.yaml:
properties:
email:
type: email
required: true
maxLength: 255
slug:
type: slug
unique: true
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: always }
SoftDelete:
deletedAt: { type: datetime }
enums:
Status:
values: [active, inactive, archived]
resources/product.yaml:
resources:
Product:
object:
id: { type: uuid, primaryKey: true }
name: { type: string, required: true }
sku: { $ref: '#/properties/slug', from: name }
price: { type: money, required: true }
status: { $ref: '#/enums/Status', default: active }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
permissions:
list: ["*"]
create: [admin]
update: [admin]
delete: [admin]
features:
softDelete: true
Processing Order
The parser processes $include before any other directive resolution. The merge sequence is:
1. Read root file
2. Expand $include (load and merge all included files)
3. Resolve ${VAR} environment variable substitutions
4. Build lookup tables (properties, objects, enums)
5. Resolve $ref and $merge directives
6. Validate: no unresolved directives remain
7. Build DomainModel struct
After step 2, the domain definition is a single in-memory document as if it had been written in one file. All subsequent processing is identical for single-file and multi-file domains.
6.4 Composition Directives ($ref and $merge)
ADL provides two directives for reusing definitions within a domain: $ref for referencing a single definition, and $merge for composing multiple field groups into a resource.
$ref — Reference a Definition
$ref replaces itself with the content of the referenced definition. References use JSON Pointer syntax rooted at the domain level:
# Reference a property
contactEmail: { $ref: '#/properties/email' }
# Reference an enum
status: { $ref: '#/enums/OrderStatus', default: draft }
# Reference an object (typically used with $merge, not standalone)
address: { $ref: '#/objects/Address' }
Reference targets:
| Syntax | Resolves To |
|---|---|
#/properties/{name} | A property definition (atomic type with constraints) |
#/objects/{name} | An object definition (group of fields) |
#/enums/{name} | An enum definition (value list with optional metadata) |
#/resources/{name} | A resource definition (used in relationship targets) |
#/blocks/{name} | A block type definition (used in blocks field configuration) |
External references (cross-file) use the syntax 'filename.yaml#/path':
contactEmail: { $ref: 'types.yaml#/properties/email' }
External references require that the target file has been loaded via $include. The parser resolves all $include directives before resolving $ref directives.
Override on reference: Fields defined alongside a $ref override the referenced definition:
# Reference the email property but make it optional
contactEmail: { $ref: '#/properties/email', required: false }
The $ref resolves first, then the sibling keys (required: false) override the resolved content.
$merge — Compose Field Groups
$merge copies the fields from one or more object definitions into the current resource’s field set. It is used inside a resource’s object: block:
resources:
Post:
object:
id: { type: uuid, primaryKey: true }
title: { type: string, required: true }
body: { type: markdown }
$merge:
- { $ref: '#/objects/Timestamp' } # Adds createdAt, updatedAt
- { $ref: '#/objects/SoftDelete' } # Adds deletedAt
After resolution, the Post resource has five fields: id, title, body, createdAt, updatedAt, and deletedAt.
Single merge shorthand:
$merge: { $ref: '#/objects/Timestamp' }
Merge order and conflicts: When multiple objects are merged, they are applied in array order. If two objects define a field with the same name, the later merge wins. Fields defined explicitly on the resource (outside $merge) always take precedence over merged fields:
object:
createdAt:
type: datetime
description: "Custom description overrides Timestamp"
$merge:
- { $ref: '#/objects/Timestamp' }
The explicit createdAt definition takes precedence. The updatedAt from Timestamp merges normally.
Resolution Transparency
$ref and $merge are resolved at parse time. After resolution, the domain model contains fully expanded field definitions with no trace of the original directives. The API consumer, the schema endpoint, the OpenAPI spec, and the migration system all see the resolved field set — they never encounter $ref or $merge directives.
This means:
- Changing a shared object (e.g., adding a field to
Timestamp) affects every resource that merges it, on the next domain submission. - The migration system sees the change as field additions on every affected resource and generates migration plans accordingly.
- There is no runtime cost to
$ref/$merge— resolution happens once, at parse time.
6.5 Environment Variable Substitution
String values in the domain YAML MAY reference environment variables using the ${VAR} syntax. Three forms are supported:
| Syntax | Behavior |
|---|---|
${VAR} | Substitute the value of VAR. Empty string if not set. |
${VAR:default} | Substitute the value of VAR. Use default if not set. |
${VAR!} | Substitute the value of VAR. Parser error if not set. |
domain:
name: MyApp
version: "1.0.0"
config:
api_url: "${API_BASE_URL:https://api.example.com}"
secret_key: "${APP_SECRET!}"
Environment variable substitution is performed after $include resolution and before $ref/$merge resolution. It applies to string values only — keys, integers, booleans, and structural elements are not subject to substitution.
6.6 JSON Format Input
Domains MAY be submitted as JSON instead of YAML. The JSON structure mirrors the YAML structure exactly:
{
"domain": {
"name": "tasks",
"version": "1.0.0",
"resources": {
"Task": {
"object": {
"id": { "type": "uuid", "primaryKey": true },
"title": { "type": "string", "required": true },
"done": { "type": "boolean", "default": false }
}
}
}
}
}
JSON input supports $ref and $merge directives with the same syntax and semantics as YAML. Environment variable substitution (${VAR}) also works in JSON string values. $include is not supported in JSON format — JSON submissions MUST be self-contained.
The submission endpoint accepts both formats:
# YAML submission
curl -X POST /api/v1/domains \
-H "Content-Type: application/json" \
-d '{"content": "domain:\n name: tasks\n ...", "format": "yaml"}'
# JSON submission
curl -X POST /api/v1/domains \
-H "Content-Type: application/json" \
-d '{"content": "{\"domain\":{\"name\":\"tasks\",...}}", "format": "json"}'
Both formats produce identical DomainModel structs after parsing. The format is a serialization choice, not a semantic difference.
6.7 Reusable Definitions
Properties
Properties define reusable atomic field types with constraints. They are the ADL equivalent of type aliases:
properties:
email:
type: email
required: true
lowercase: true
maxLength: 255
slug:
type: slug
unique: true
phone:
type: phone
pattern: "^\\+[1-9]\\d{1,14}$"
url:
type: url
maxLength: 2048
A property is referenced with $ref: '#/properties/{name}' and produces a field with the referenced type and constraints. Sibling keys override the referenced definition.
Objects
Objects define reusable groups of fields. They are the ADL equivalent of mixins or traits:
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: always }
SoftDelete:
deletedAt: { type: datetime }
Money:
amount: { type: decimal, precision: 19, scale: 4, min: 0 }
currency: { type: currency, default: USD }
Address:
line1: { type: string, maxLength: 255, required: true }
line2: { type: string, maxLength: 255 }
city: { type: string, maxLength: 100, required: true }
state: { type: string, maxLength: 100 }
postalCode: { type: string, maxLength: 20 }
country: { type: country, required: true }
Objects are incorporated into resources using $merge. A resource MAY merge multiple objects:
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
Promotion rule of thumb: If a field definition appears identically in 3+ resources, promote it to properties. If a group of fields recurs together in 3+ resources, promote it to objects. If an enum is referenced by 3+ resources, promote it to enums.
6.8 Reserved Domain Names
The following names are reserved by the platform and MUST NOT be used as domain names. The parser rejects them with HTTP 422:
| Reserved Name | Reason |
|---|---|
auth | Conflicts with authd route prefix |
users | Conflicts with the users endpoint prefix |
admin | Conflicts with admin endpoint prefix |
domains | Conflicts with the domain management endpoint |
status | Conflicts with status endpoint |
plugins | Conflicts with plugins endpoint |
routes | Conflicts with routes endpoint |
version | Conflicts with the version endpoint |
openapi | Conflicts with OpenAPI endpoint |
echo | Reserved for the debug echo endpoint |
The check is case-insensitive.
6.9 Parsing Pipeline
The complete parsing pipeline processes the domain file through seven stages:
Stage 1: Format detection (YAML or JSON)
Stage 2: $include resolution (load and merge external files)
Stage 3: ${VAR} environment variable substitution
Stage 4: Build lookup tables (properties, objects, enums by name)
Stage 5: $ref and $merge resolution (recursive, until no directives remain)
Stage 6: Validation (no unresolved directives, no unknown keys, type checking)
Stage 7: Build DomainModel struct (resources, fields, relationships, features)
If any stage fails, the parser returns a structured error response (HTTP 422) identifying the stage, the location in the YAML, and the nature of the failure:
{
"status": "error",
"code": 422,
"error_code": "PARSE_ERROR",
"error_message": "Unresolved $ref: '#/objects/NonExistent' in resource 'Post', field 'timestamps'"
}
After stage 7, the DomainModel struct is fully resolved and contains no directives, no references, and no unsubstituted variables. It is a complete, self-contained description of the domain ready for DDL generation, action registration, and migration processing.
End of Chapter 6.
Next: Chapter 7 — Type System