Chapter 8 · Volume II — The ADL Domain Language

Relationships

8.1 Relationship Types

ADL supports three relationship types that model the cardinality between resources. Each type determines where the foreign key column lives, what endpoints are generated, and what cascade behavior is available.

TypeCardinalityFK LocationGenerated Endpoints
belongsToMany-to-oneOn this resourceNone (FK is a regular field)
hasManyOne-to-manyOn the related resourceGET /:id/{related}
belongsToManyMany-to-manyJunction tableGET /:id/{related}, POST /:id/{related}, DELETE /:id/{related}/:otherKey

Choosing a Relationship Type

  • Does this resource hold a FK column pointing to the other resource?belongsTo
  • Does the other resource hold a FK column pointing back to this resource?hasMany
  • Do both resources connect through a shared junction table?belongsToMany
Product  ──belongsTo──▶  Category     (Product has categoryId)
Category ──hasMany────▶  Product      (Product has categoryId)
Post     ◀──belongsToMany──▶  Tag     (via PostTag junction table)

A belongsTo and a hasMany are two sides of the same relationship. The belongsTo side owns the FK column and generates the DDL. The hasMany side is the inverse — it generates no DDL and exists only to enable ?include= eager loading and the nested list endpoint.


8.2 belongsTo (Many-to-One)

A belongsTo relationship declares that this resource holds a foreign key column referencing another resource.

resources:
  Post:
    object:
      id: { type: uuid, primaryKey: true }
      title: { type: string, required: true }
      authorId: { type: uuid, required: true }
      $merge: Timestamp

    relationships:
      author:
        type: belongsTo
        target: User
        foreignKey: authorId
        onDelete: restrict

Declaration

KeyTypeRequiredDescription
typestringyes"belongsTo"
targetstringyesName of the related resource (PascalCase).
foreignKeystringnoFK column name on this resource. Auto-derived as {relationshipName}Id if omitted.
onDeletestringnoCascade behavior: cascade, restrict, nullify, none. Default: none.
requiredbooleannoIf true, the FK field MUST be non-null on create. Default: false.

Auto-Derived FK Name

If foreignKey is omitted, it is derived from the relationship name by appending Id:

relationships:
  author:
    type: belongsTo
    target: User
    # foreignKey defaults to "authorId"

FK Column DDL

The DDL generator produces a REFERENCES clause on the FK column:

CREATE TABLE blog_posts (
    ...
    authorId TEXT REFERENCES blog_users(id) ON DELETE RESTRICT,
    ...
)

If the FK column is not explicitly declared in the object: block, the parser adds it automatically as { type: uuid } during resolution.

Generated Behavior

belongsTo generates no additional endpoints. The FK is a regular field that appears in create/update request bodies and in read responses. To include the full related record, use ?include=:

GET /api/v1/blog/posts?include=author

Response:

{
  "id": "post-1",
  "title": "My Post",
  "authorId": "user-1",
  "author": {
    "id": "user-1",
    "username": "alice",
    "email": "alice@example.com"
  }
}

Without ?include=author, only the authorId UUID appears.


8.3 hasMany (One-to-Many)

A hasMany relationship declares that another resource holds a FK column pointing back to this resource.

resources:
  User:
    relationships:
      posts:
        type: hasMany
        target: Post
        foreignKey: authorId

Declaration

KeyTypeRequiredDescription
typestringyes"hasMany"
targetstringyesName of the related resource.
foreignKeystringyesFK column name on the target resource (not on this resource).
orderByobjectnoDefault sort for the nested list: { field: desc }.
whereobjectnoStatic filter applied to the nested list: { status: active }.

DDL Rule

hasMany generates no DDL on this resource. The FK column lives on the target resource and is generated by that resource’s belongsTo declaration. The migration system processes only the FK-owning side to avoid duplicate column additions.

Generated Endpoints

MethodPathActionDescription
GET/api/v1/{d}/{r}/:id/{related}adl.{d}.{r}.{related}.listNested list of related records

Example:

GET /api/v1/blog/users/user-1/posts

Returns all posts where authorId = user-1, sorted by orderBy (or createdAt ascending by default). The nested list endpoint supports the same query parameters as the top-level list endpoint (§4.7).

Eager Loading

GET /api/v1/blog/users/user-1?include=posts

Response:

{
  "id": "user-1",
  "username": "alice",
  "posts": [
    { "id": "post-1", "title": "First Post", "authorId": "user-1" },
    { "id": "post-2", "title": "Second Post", "authorId": "user-1" }
  ]
}

8.4 belongsToMany (Many-to-Many)

A belongsToMany relationship connects two resources through a junction table. Neither resource holds a FK to the other — the junction table holds both FK columns.

resources:
  Post:
    relationships:
      tags:
        type: belongsToMany
        target: Tag
        through: PostTag
        foreignKey: postId
        otherKey: tagId

Declaration

KeyTypeRequiredDescription
typestringyes"belongsToMany"
targetstringyesName of the related resource.
throughstringnoJunction table/resource name. Auto-derived if omitted.
foreignKeystringnoThis resource’s FK in the junction. Auto-derived as {thisResource}Id.
otherKeystringnoTarget resource’s FK in the junction. Auto-derived as {targetResource}Id.
timestampsbooleannoAdd createdAt/updatedAt to the junction table. Default false.

Auto-Derived Names

If through is omitted, the junction table name is derived by sorting the two resource names alphabetically and joining them. If foreignKey and otherKey are omitted, they are derived from the respective resource names:

# Post belongsToMany Tag
# through defaults to "PostTag" (alphabetical)
# foreignKey defaults to "postId"
# otherKey defaults to "tagId"

Junction Table DDL

CREATE TABLE IF NOT EXISTS blog_post_tags (
    postId TEXT NOT NULL,
    tagId TEXT NOT NULL,
    PRIMARY KEY (postId, tagId),
    FOREIGN KEY (postId) REFERENCES blog_posts(id),
    FOREIGN KEY (tagId) REFERENCES blog_tags(id)
)

When timestamps: true, createdAt TEXT and updatedAt TEXT columns are added.

Generated Endpoints

MethodPathActionDescription
GET/:id/{related}adl.{d}.{r}.{related}.listList linked records
POST/:id/{related}adl.{d}.{r}.{related}.linkLink a record
DELETE/:id/{related}/:otherKeyadl.{d}.{r}.{related}.unlinkUnlink a record

There are no /link or /unlink path segments: linking POSTs to the same collection path the list uses, and unlinking names the target id in the path (:otherKey), not a request body.

Link:

POST /api/v1/blog/posts/post-1/tags
{"tagId": "tag-1"}

The body carries the target id under the relationship’s otherKey (here tagId).

Response (200): { "data": { "linked": true, "postId": "post-1", "tagId": "tag-1" } }

Linking is idempotent — the junction insert uses ON CONFLICT DO NOTHING (PostgreSQL) / INSERT OR IGNORE (SQLite), so linking the same pair twice still returns 200.

Unlink:

DELETE /api/v1/blog/posts/post-1/tags/tag-1

The target id is the final path segment (:otherKey); no request body is sent.

Response (200): { "data": { "unlinked": true, "postId": "post-1", "tagId": "tag-1" } }

Unlinking is idempotent — deleting a non-existent junction row still returns 200.

List:

GET /api/v1/blog/posts/post-1/tags

Returns full target records (Tags), not junction rows. Supports pagination, sorting, and filtering.

Eager Loading

GET /api/v1/blog/posts/post-1?include=tags

Response:

{
  "id": "post-1",
  "title": "My Post",
  "tags": [
    { "id": "tag-1", "name": "engineering" },
    { "id": "tag-2", "name": "tutorial" }
  ]
}

8.5 Foreign Key Configuration

onDelete Cascade Behavior

The onDelete property controls database-level behavior when the referenced parent record is deleted:

ValueSQL ClauseBehavior
cascadeON DELETE CASCADEDelete all child records that reference the parent.
restrictON DELETE RESTRICTPrevent deletion if any child records exist. Returns 409.
nullifyON DELETE SET NULLSet the FK column to NULL on all child records. FK must be nullable.
none(no clause)No database-level enforcement. Orphaned FK values may result.

Choosing a cascade strategy:

Use CaseStrategyRationale
Comments on a PostcascadeComments cannot meaningfully exist without their post.
Posts by a UserrestrictUsers should not be deleted while they have posts.
Optional author on a PostnullifyPost survives; author reference becomes null.
External system referencenoneFK integrity managed by application logic.

FK Nullability

The FK column in the database is always nullable, regardless of the required setting. This is a deliberate design choice: the required constraint is enforced at the API boundary (the action handler rejects creates with a null FK when required: true), not at the database level. This ensures that migration operations — adding a FK column to a table with existing rows — do not fail due to NOT NULL constraints on rows that predate the relationship.

Immutable Foreign Keys

FK fields SHOULD be marked immutable: true when the relationship is permanent:

object:
  postId: { type: uuid, required: true, immutable: true }

relationships:
  post:
    type: belongsTo
    target: Post
    foreignKey: postId
    onDelete: cascade

An update request that attempts to change an immutable field is rejected with 422.


8.6 Self-Referential Relationships

A resource can reference itself to model hierarchies:

resources:
  Category:
    object:
      id: { type: uuid, primaryKey: true }
      name: { type: string, required: true }
      parentId: { type: uuid }
      $merge: Timestamp

    relationships:
      parent:
        type: belongsTo
        target: Category
        foreignKey: parentId
        onDelete: restrict

      children:
        type: hasMany
        target: Category
        foreignKey: parentId

Root nodes have parentId: null. The restrict cascade prevents deleting a category that has subcategories.

?include=children loads one level of children. Recursive loading (children of children) is not supported natively — deep tree traversal requires multiple API calls or a Lua hook.


8.7 Composite Primary Keys

Resources MAY declare a composite primary key for entities whose identity is defined by the combination of two or more fields:

resources:
  OrderItem:
    object:
      orderId: { type: uuid, required: true }
      productId: { type: uuid, required: true }
      quantity: { type: integer, min: 1, required: true }
      unitPrice: { type: decimal, required: true }

    compositePrimaryKey: [orderId, productId]

    relationships:
      order:
        type: belongsTo
        target: Order
        foreignKey: orderId
        onDelete: cascade
      product:
        type: belongsTo
        target: Product
        foreignKey: productId
        onDelete: restrict

DDL

CREATE TABLE orders_order_items (
    orderId TEXT NOT NULL,
    productId TEXT NOT NULL,
    quantity INTEGER NOT NULL DEFAULT 0,
    unitPrice TEXT NOT NULL DEFAULT '',
    PRIMARY KEY (orderId, productId),
    FOREIGN KEY (orderId) REFERENCES orders_orders(id) ON DELETE CASCADE,
    FOREIGN KEY (productId) REFERENCES orders_products(id) ON DELETE RESTRICT
)

Route Pattern

Multi-segment URL paths using the order declared in compositePrimaryKey:

GET    /api/v1/orders/order-items/:orderId/:productId
PATCH  /api/v1/orders/order-items/:orderId/:productId
DELETE /api/v1/orders/order-items/:orderId/:productId

Constraints

  • Composite key fields MUST be declared in the object: block with required: true.
  • When compositePrimaryKey is declared, the resource MUST NOT have a standalone id field with primaryKey: true.
  • Auto-generation does not apply — the client MUST provide all key values on create.

8.8 Relationship Migration

Relationships are subject to the migration system when the domain evolves:

ChangeDetectionMigrationData Impact
belongsTo addedNew FK column in new modelALTER TABLE ADD COLUMN ... REFERENCESNew column is nullable; existing rows have NULL.
belongsTo removedFK column in old model, absent in newDROP COLUMN (if allow_destructive)Column preserved by default (non-destructive).
belongsToMany addedNew junction in new modelCREATE TABLE (junction)Empty junction table created.
belongsToMany removedJunction in old model, absent in newDROP TABLE (if allow_destructive)Junction preserved by default (non-destructive).
Cascade rule changedSame relationship, different onDelete4-step column rebuild (§5.2.5)FK data preserved through rebuild.

When a relationship is removed in non-destructive mode (the default), the database column or junction table is preserved but the corresponding API routes are deregistered. The application layer adapts immediately; the database layer is conservative. The developer controls the timeline of actual data deletion.

Known Limitations

  • FK column rename (M-10): Renaming the FK column (e.g., authorIdwrittenById) is not implemented. Workaround: add new FK, copy data via Lua hook, remove old FK.
  • Relationship type change (M-12): Changing from belongsTo to belongsToMany or vice versa is not implemented. Workaround: add the new relationship structure, migrate data, remove the old structure.

See §30.8 for the complete migration limitation catalog.


End of Chapter 8.

Next: Chapter 9 — Enumerations