Chapter 10 · Volume II — The ADL Domain Language

Shared Objects & Composition

10.1 Reusable Objects

Objects are named groups of fields defined in the top-level objects: block and incorporated into resources via $merge. They are the primary composition mechanism in ADL — the equivalent of mixins, traits, or base classes in other systems.

Standard Objects

The following objects appear in nearly every ADL domain and constitute the de facto standard library:

Timestamp — automatic creation and modification timestamps:

objects:
  Timestamp:
    createdAt: { type: datetime, autoNow: create }
    updatedAt: { type: datetime, autoNow: always }

Every resource SHOULD merge Timestamp. The autoNow behavior is server-managed — client-submitted values for these fields are silently ignored.

SoftDelete — logical deletion with restore capability:

objects:
  SoftDelete:
    deletedAt: { type: datetime }

Merged by resources that use features.softDelete: true. The deletedAt field is set by the delete handler and cleared by the restore handler. See §15.1.

AuditMeta — tracking which user performed each operation:

objects:
  AuditMeta:
    createdById: { type: uuid }
    updatedById: { type: uuid }

Populated from Request.identity.user_id on create and update. Useful for owner-based queries and accountability trails beyond the audit log.

Domain-Specific Objects

Objects SHOULD be defined for any field group that recurs across resources in the domain:

Address — postal address:

objects:
  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 }

Money — financial amount with currency:

objects:
  Money:
    amount: { type: decimal, precision: 19, scale: 4, min: 0 }
    currency: { type: currency, default: USD }

SEO — search engine optimization metadata:

objects:
  SEO:
    metaTitle: { type: string, maxLength: 60 }
    metaDescription: { type: text, maxLength: 160 }
    ogImage: { type: media, accept: [image/*] }
    canonicalUrl: { type: url }
    noIndex: { type: boolean, default: false }
    noFollow: { type: boolean, default: false }

ContactInfo — contact details:

objects:
  ContactInfo:
    email: { type: email }
    phone: { type: phone }
    website: { type: url }

Object Definition Rules

  1. Objects are defined in the objects: block at the domain level.
  2. Object names MUST be PascalCase.
  3. Objects contain field definitions using the same syntax as resource object: blocks (§7).
  4. Objects MUST NOT contain $merge directives — objects cannot merge other objects. Composition is flat, not nested.
  5. Objects MUST NOT contain relationships, permissions, hooks, or other resource-level blocks — they are strictly field groups.
  6. Objects MAY reference properties or enums via $ref.

10.2 Reusable Properties

Properties are named single-field type definitions in the top-level properties: block. They define a type with constraints that can be referenced from any resource field via $ref.

properties:
  email:
    type: email
    required: true
    lowercase: true
    maxLength: 255

  slug:
    type: slug
    unique: true

  phone:
    type: phone
    pattern: "^\\+[1-9]\\d{1,14}$"

  shortText:
    type: string
    maxLength: 280
    trim: true

  url:
    type: url
    maxLength: 2048

  money:
    type: decimal
    precision: 19
    scale: 4
    min: 0

Referencing Properties

resources:
  Customer:
    object:
      contactEmail: { $ref: '#/properties/email' }
      website: { $ref: '#/properties/url' }
      phone: { $ref: '#/properties/phone' }

After resolution, contactEmail has type email, required: true, lowercase: true, and maxLength: 255 — all inherited from the property definition.

Override on Reference

Sibling keys override the referenced property:

# Use the email property but make it optional
secondaryEmail: { $ref: '#/properties/email', required: false }

The required: false override applies after the $ref resolves. All other constraints (lowercase, maxLength) are inherited unchanged.

Properties vs Objects

PropertiesObjects
DefinesA single field with type and constraintsA group of related fields
Referenced via$ref: '#/properties/name' on a field$merge: { $ref: '#/objects/Name' } in the object block
ResultOne field added to the resourceMultiple fields added to the resource
Use whenThe same field definition recurs across resourcesThe same group of fields recurs together

Property Definition Rules

  1. Properties are defined in the properties: block at the domain level.
  2. Property names SHOULD be camelCase.
  3. A property definition uses the same syntax as a field definition (§7.7) — it MUST include a type and MAY include any field constraint.
  4. Properties MUST NOT reference other properties — no chaining.

10.3 $ref Resolution

$ref replaces itself with the content of the referenced definition. Resolution is recursive — a $ref that resolves to a definition containing another $ref is resolved again, until no $ref directives remain.

Resolution Order

  1. All $include directives are resolved first (§6.3), producing a single merged document.
  2. The parser builds lookup tables for properties, objects, and enums by name.
  3. $ref directives are resolved by looking up the target in the appropriate table.
  4. Resolution is depth-first: if a resolved definition contains $ref, it is resolved immediately before continuing.

Reference Targets

Target PathResolves To
#/properties/{name}Field definition from the properties: block
#/objects/{name}Field group from the objects: block
#/enums/{name}Enum definition from the enums: block
#/blocks/{name}Block type definition from the blocks: block
{file}#/{path}Definition from an included external file

Error Handling

If a $ref target does not exist, the parser returns HTTP 422:

{
  "status": "error",
  "code": 422,
  "error_code": "PARSE_ERROR",
  "error_message": "Unresolved $ref: '#/objects/NonExistent' in resource 'Post'"
}

Circular Reference Detection

The parser detects circular $ref chains and rejects them with HTTP 422. A circular reference occurs when resolving a $ref leads back to a definition that is currently being resolved:

# This is rejected:
properties:
  a: { $ref: '#/properties/b' }
  b: { $ref: '#/properties/a' }

10.4 $merge Resolution

$merge copies the fields from one or more object definitions into the current resource’s object: block. Unlike $ref (which replaces a single value), $merge adds multiple fields to the enclosing context.

Single Merge

object:
  id: { type: uuid, primaryKey: true }
  title: { type: string, required: true }
  $merge: { $ref: '#/objects/Timestamp' }

After resolution, the resource has four fields: id, title, createdAt, updatedAt.

Multiple Merge

object:
  id: { type: uuid, primaryKey: true }
  $merge:
    - { $ref: '#/objects/Timestamp' }
    - { $ref: '#/objects/SoftDelete' }
    - { $ref: '#/objects/AuditMeta' }

After resolution: id, createdAt, updatedAt, deletedAt, createdById, updatedById.

Merge Order and Conflicts

Merges are applied in array order. If two objects define a field with the same name, the later merge wins:

objects:
  ObjectA:
    status: { type: string, default: "active" }
  ObjectB:
    status: { type: string, default: "pending" }

resources:
  Item:
    object:
      $merge:
        - { $ref: '#/objects/ObjectA' }    # status default = "active"
        - { $ref: '#/objects/ObjectB' }    # status default = "pending" — wins

The resolved status field has default: "pending".

Explicit Field Precedence

Fields defined directly on the resource (outside $merge) always take precedence over merged fields, regardless of declaration order:

object:
  createdAt:
    type: datetime
    description: "Custom creation timestamp with description"

  $merge:
    - { $ref: '#/objects/Timestamp' }

The explicit createdAt definition takes precedence. The updatedAt from Timestamp merges normally. This allows shared objects to serve as defaults while individual resources customize specific fields.


10.5 Parse-Time Transparency

All composition directives ($ref, $merge, $include) are resolved at parse time. After resolution, the DomainModel struct contains fully expanded field definitions with no trace of the original directives.

Consumers that never see directives:

ConsumerWhat It Sees
API responsesFully resolved field values
Schema introspection endpointFully resolved field definitions
OpenAPI specificationFully resolved schemas
Migration systemFully resolved models (old and new)
Permission evaluatorFully resolved field sets
Computed field engineFully resolved dependency references
Lua hooksFully resolved record objects
UI generatorsFully resolved schema

This transparency has an important consequence for migration: the migration system compares two fully-resolved models. It cannot distinguish between “a field was added directly to the resource” and “a field was added to a shared object that the resource merges.” Both appear as the same FieldChange.Added in the diff. This is by design — the migration system cares about the database schema, not the YAML organization.

Implications for Domain Evolution

When a shared object changes, the impact propagates to every resource that merges it:

Change to Shared ObjectEffect on ResourcesMigration
Add a field to TimestampEvery resource merging Timestamp gains the fieldALTER TABLE ADD COLUMN on every affected table
Remove a field from TimestampEvery resource merging Timestamp loses the fieldDROP COLUMN (if destructive) on every affected table
Change a field type in TimestampEvery resource merging Timestamp has the type changeType migration (4-step rebuild or native ALTER) on every affected table
Rename a field in TimestampEvery resource merging Timestamp has the renameRENAME COLUMN on every affected table (requires renamedFrom hint on each)

The blast radius of shared object changes is proportional to the number of consumers. This is both the power and the risk of composition — a one-line change to Timestamp can produce migration plans across dozens of tables.

Recommendation: Shared objects SHOULD be treated as stable interfaces. Adding fields is safe. Changing or removing fields requires careful migration planning. The renamedFrom hint MUST be applied to the renamed field in the shared object itself — it propagates through $merge resolution just like any other field attribute.


10.6 Composition Patterns

The Standard Stack

Most resources merge the same three objects:

$merge:
  - { $ref: '#/objects/Timestamp' }
  - { $ref: '#/objects/SoftDelete' }
  - { $ref: '#/objects/AuditMeta' }

This is so common that it SHOULD be the default starting point for every resource. Resources that do not need soft delete or audit tracking omit the corresponding merge.

Component vs Merge

ADL offers two ways to embed structured data in a resource: $merge (flat composition) and component type (nested composition). The choice depends on whether the embedded fields should be top-level columns or a nested JSON object:

$mergecomponent
StorageEach field becomes its own database columnAll fields stored as one JSON column
QueryableEach field can be filtered, sorted, indexedRequires JSON path queries (store-dependent)
Appears inTop level of the API responseNested under a named key
Use whenFields are frequently queried or filteredFields are read/written as a group
# Merge: address fields are top-level columns, individually queryable
$merge: { $ref: '#/objects/Address' }

# Component: address is a nested JSON object, queried as a whole
shippingAddress:
  type: component
  object: { $ref: '#/objects/Address' }

Multi-File Composition

For large domains, shared definitions and resources are split across files (§6.3). The recommended file layout:

domain.yaml                      ← Root: name, version, $include
types/
  shared-types.yaml              ← properties, objects, enums
  content-types.yaml             ← blocks, icons, fonts
resources/
  customer.yaml                  ← One resource per file
  product.yaml
  order.yaml
  order-item.yaml

Each resource file contains only its resources: block. Shared definitions live in types/. The root domain.yaml includes everything:

domain:
  name: Orders
  version: "2.0.0"
  $include:
    - ./types/*.yaml
    - ./resources/*.yaml

This layout enables:

  • Independent editing — each resource is a separate file
  • Clear ownership — the types/ directory is the shared vocabulary
  • Minimal merge conflicts — developers rarely edit the same file

Orphan Detection

The linter (§39) checks for orphaned definitions — objects, properties, and enums that are defined but never referenced by any resource. Orphaned definitions are not harmful (they are ignored at runtime) but indicate dead code that should be cleaned up.


End of Chapter 10.

Next: Chapter 11 — Permissions & RBAC