Skip to main content

Relations (Eager Loading)

The Data Layer resolves FK-based relations automatically. Modules declare foreign keys in their SQL migrations and call include in the SDK — the Data Layer generates an indexed LEFT JOIN at the Go level. No N+1 queries.

Relations are read-only at the include API level. Creating related records requires an explicit transaction.


How Relations Work

1. Module defines FK in its SQL migration:

CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES companies(id),
tenant_id UUID NOT NULL
);
CREATE INDEX ON contacts(company_id); -- required for JOIN eligibility

2. Module calls SDK with include:

kernel.data().list('contacts', { include: ['company'] })

3. Go Data Layer:
→ Reads FK from information_schema (cached at startup)
→ Generates LEFT JOIN (contacts + companies)
→ Returns nested object: { id, name, company: { id, name } }

The Data Layer discovers FK relationships automatically by reading information_schema.KEY_COLUMN_USAGE. The module does not declare relation metadata anywhere else — only the FK constraint in the migration is enough.


retrieve with include

GET https://api.septemcore.com/v1/data/crm/contacts/01j9pa5mz700000000000000?select=name,company(name,industry)
Authorization: Bearer <access_token>

Response 200 OK:

{
"id": "01j9pa5mz700000000000000",
"name": "Alice Chen",
"company": {
"id": "01j9pacomp00000000000000",
"name": "Acme Corp",
"industry": "Technology"
}
}

SDK:

const contact = await kernel.data().retrieve('contacts', id, {
include: ['company'],
});
// contact.company.name → 'Acme Corp'

list with include

GET https://api.septemcore.com/v1/data/crm/contacts?select=name,company(name),tags(label)&status=eq.active
Authorization: Bearer <access_token>

Response 200 OK:

{
"data": [
{
"id": "01j9pa5mz700000000000000",
"name": "Alice Chen",
"company": {
"name": "Acme Corp"
},
"tags": [
{ "label": "enterprise" },
{ "label": "vip" }
]
}
],
"meta": { "cursor": "01j9pa5mz700000000000000", "hasMore": false }
}

SDK:

const { data: contacts } = await kernel.data().list('contacts', {
include: ['company', 'tags'],
filters: { status: 'eq.active' },
order: 'created_at.desc',
limit: 20,
});

Depth Rules

DepthExampleAllowed
1contacts → company
2contacts → company → industry
3contacts → company → industry → sector❌ — 400 Bad Request

Exceeding depth 2 returns 400 Bad Request:

{
"type": "https://api.septemcore.com/problems/validation-error",
"status": 400,
"detail": "include depth exceeds maximum of 2 levels",
"code": "INCLUDE_DEPTH_EXCEEDED"
}

Cross-Module Restriction

A module can only include tables that belong to the same module. Joining tables from another module is not permitted — each module's data is isolated at the schema layer.

// ✅ Allowed — contacts and companies both belong to module 'crm'
await kernel.data().list('contacts', { include: ['company'] });

// ❌ Forbidden — 'orders' belongs to module 'commerce', not 'crm'
await kernel.data().list('contacts', { include: ['orders'] });
// → 403 Forbidden: cross-module include not permitted

For cross-module data composition, modules communicate via the Event Bus or expose a dedicated API endpoint.


include is Read-only

include works only for retrieve and list (read operations). It is not supported for create:

// ❌ This is not supported:
await kernel.data().create('contacts', {
name: 'Alice',
company: { name: 'Acme' }, // ignored / rejected
});

To create related records atomically, use an explicit transaction:

await kernel.data().transaction(async (tx) => {
const company = await tx.create('companies', { name: 'Acme Corp' });
await tx.create('contacts', {
name: 'Alice Chen',
company_id: company.id,
});
// If either create fails → full rollback
});

The transaction uses a single PostgreSQL transaction. On error, both inserts are rolled back automatically.


Performance Guarantees

RuleReason
JOIN only on indexed FKOnly columns with an index are eligible for include. Requesting an unindexed FK returns 400 Bad Request.
No include → no JOINWithout include, the query is a plain SELECT with no joins.
LEFT JOIN semanticsRecords with no matching relation row are still returned; the relation field is null.
Batch loading for listThe Data Layer batches FK resolution across all rows in a single query — no N+1 per row.

Error Reference

ScenarioHTTPtype URI suffix
Include depth > 2400validation-error (code: INCLUDE_DEPTH_EXCEEDED)
Cross-module include403forbidden
Unknown relation name400validation-error (code: UNKNOWN_RELATION)
Unindexed FK400validation-error (code: UNINDEXED_FK)
include on create400validation-error (code: INCLUDE_ON_WRITE_NOT_SUPPORTED)