Chapter 6 · Volume II — The ADL Domain Language

Document Structure

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

  1. Paths are resolved relative to the file containing the $include directive.
  2. Glob patterns (*, **) are expanded by the parser using filesystem matching.
  3. 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).
  4. Included files MUST NOT contain a domain: block — the domain metadata (name, version, description) is defined only in the root file.
  5. 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:

SyntaxResolves 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:

SyntaxBehavior
${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 NameReason
authConflicts with authd route prefix
usersConflicts with the users endpoint prefix
adminConflicts with admin endpoint prefix
domainsConflicts with the domain management endpoint
statusConflicts with status endpoint
pluginsConflicts with plugins endpoint
routesConflicts with routes endpoint
versionConflicts with the version endpoint
openapiConflicts with OpenAPI endpoint
echoReserved 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