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.
| Type | Cardinality | FK Location | Generated Endpoints |
|---|---|---|---|
belongsTo | Many-to-one | On this resource | None (FK is a regular field) |
hasMany | One-to-many | On the related resource | GET /:id/{related} |
belongsToMany | Many-to-many | Junction table | GET /: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
| Key | Type | Required | Description |
|---|---|---|---|
type | string | yes | "belongsTo" |
target | string | yes | Name of the related resource (PascalCase). |
foreignKey | string | no | FK column name on this resource. Auto-derived as {relationshipName}Id if omitted. |
onDelete | string | no | Cascade behavior: cascade, restrict, nullify, none. Default: none. |
required | boolean | no | If 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
| Key | Type | Required | Description |
|---|---|---|---|
type | string | yes | "hasMany" |
target | string | yes | Name of the related resource. |
foreignKey | string | yes | FK column name on the target resource (not on this resource). |
orderBy | object | no | Default sort for the nested list: { field: desc }. |
where | object | no | Static 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
| Method | Path | Action | Description |
|---|---|---|---|
GET | /api/v1/{d}/{r}/:id/{related} | adl.{d}.{r}.{related}.list | Nested 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
| Key | Type | Required | Description |
|---|---|---|---|
type | string | yes | "belongsToMany" |
target | string | yes | Name of the related resource. |
through | string | no | Junction table/resource name. Auto-derived if omitted. |
foreignKey | string | no | This resource’s FK in the junction. Auto-derived as {thisResource}Id. |
otherKey | string | no | Target resource’s FK in the junction. Auto-derived as {targetResource}Id. |
timestamps | boolean | no | Add 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
| Method | Path | Action | Description |
|---|---|---|---|
GET | /:id/{related} | adl.{d}.{r}.{related}.list | List linked records |
POST | /:id/{related} | adl.{d}.{r}.{related}.link | Link a record |
DELETE | /:id/{related}/:otherKey | adl.{d}.{r}.{related}.unlink | Unlink 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:
| Value | SQL Clause | Behavior |
|---|---|---|
cascade | ON DELETE CASCADE | Delete all child records that reference the parent. |
restrict | ON DELETE RESTRICT | Prevent deletion if any child records exist. Returns 409. |
nullify | ON DELETE SET NULL | Set 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 Case | Strategy | Rationale |
|---|---|---|
| Comments on a Post | cascade | Comments cannot meaningfully exist without their post. |
| Posts by a User | restrict | Users should not be deleted while they have posts. |
| Optional author on a Post | nullify | Post survives; author reference becomes null. |
| External system reference | none | FK 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 withrequired: true. - When
compositePrimaryKeyis declared, the resource MUST NOT have a standaloneidfield withprimaryKey: 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:
| Change | Detection | Migration | Data Impact |
|---|---|---|---|
belongsTo added | New FK column in new model | ALTER TABLE ADD COLUMN ... REFERENCES | New column is nullable; existing rows have NULL. |
belongsTo removed | FK column in old model, absent in new | DROP COLUMN (if allow_destructive) | Column preserved by default (non-destructive). |
belongsToMany added | New junction in new model | CREATE TABLE (junction) | Empty junction table created. |
belongsToMany removed | Junction in old model, absent in new | DROP TABLE (if allow_destructive) | Junction preserved by default (non-destructive). |
| Cascade rule changed | Same relationship, different onDelete | 4-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.,
authorId→writtenById) is not implemented. Workaround: add new FK, copy data via Lua hook, remove old FK. - Relationship type change (M-12): Changing from
belongsTotobelongsToManyor 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