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
- Objects are defined in the
objects:block at the domain level. - Object names MUST be PascalCase.
- Objects contain field definitions using the same syntax as resource
object:blocks (§7). - Objects MUST NOT contain
$mergedirectives — objects cannot merge other objects. Composition is flat, not nested. - Objects MUST NOT contain relationships, permissions, hooks, or other resource-level blocks — they are strictly field groups.
- 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
| Properties | Objects | |
|---|---|---|
| Defines | A single field with type and constraints | A group of related fields |
| Referenced via | $ref: '#/properties/name' on a field | $merge: { $ref: '#/objects/Name' } in the object block |
| Result | One field added to the resource | Multiple fields added to the resource |
| Use when | The same field definition recurs across resources | The same group of fields recurs together |
Property Definition Rules
- Properties are defined in the
properties:block at the domain level. - Property names SHOULD be camelCase.
- A property definition uses the same syntax as a field definition (§7.7) — it MUST include a
typeand MAY include any field constraint. - 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
- All
$includedirectives are resolved first (§6.3), producing a single merged document. - The parser builds lookup tables for
properties,objects, andenumsby name. $refdirectives are resolved by looking up the target in the appropriate table.- Resolution is depth-first: if a resolved definition contains
$ref, it is resolved immediately before continuing.
Reference Targets
| Target Path | Resolves 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:
| Consumer | What It Sees |
|---|---|
| API responses | Fully resolved field values |
| Schema introspection endpoint | Fully resolved field definitions |
| OpenAPI specification | Fully resolved schemas |
| Migration system | Fully resolved models (old and new) |
| Permission evaluator | Fully resolved field sets |
| Computed field engine | Fully resolved dependency references |
| Lua hooks | Fully resolved record objects |
| UI generators | Fully 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 Object | Effect on Resources | Migration |
|---|---|---|
Add a field to Timestamp | Every resource merging Timestamp gains the field | ALTER TABLE ADD COLUMN on every affected table |
Remove a field from Timestamp | Every resource merging Timestamp loses the field | DROP COLUMN (if destructive) on every affected table |
Change a field type in Timestamp | Every resource merging Timestamp has the type change | Type migration (4-step rebuild or native ALTER) on every affected table |
Rename a field in Timestamp | Every resource merging Timestamp has the rename | RENAME 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:
| $merge | component | |
|---|---|---|
| Storage | Each field becomes its own database column | All fields stored as one JSON column |
| Queryable | Each field can be filtered, sorted, indexed | Requires JSON path queries (store-dependent) |
| Appears in | Top level of the API response | Nested under a named key |
| Use when | Fields are frequently queried or filtered | Fields 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