Relations
A relation connects two models through a foreign-key column or a junction
table. Relations are declared on the struct — by a field name, a tag, or a
slice — and are populated on demand via the ?include= query parameter.
maniflex recognises three kinds:
| Kind | Direction | Declared by |
|---|---|---|
| BelongsTo | this row holds the FK | an FK field tagged mfx:"relation" (or mfx:"relation:Name") |
| HasMany | the other table holds the FK | a slice field of the related type |
| ManyToMany | a junction table connects both sides | a slice field with mfx:"through:Junction" |
BelongsTo
Relations are opt-in: tag the foreign-key field mfx:"relation". The target
model is inferred from the field name with the trailing ID stripped
(UserID → User).
type Post struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required"`
UserID string `json:"user_id" mfx:"required,filterable,relation"` // → User
}
UserID is a foreign key to User, keyed user (snake-case of the trimmed
field name) — the value used in ?include=, in nested filters
(?filter=user.role:eq:admin), and in nested sorts (?sort=user.name:asc).
The FK field is also a regular column. Tag it filterable if clients need to
query by it, exactly like any scalar.
Note: an
<Name>IDfield is a plain scalar column unless you tag itmfx:"relation". A value column that merely ends inID—ExternalID,CloudEventID, a third-party token — stays a plain column with no relation and no?include=key. (The legacymfx:"norelation"opt-out is now a deprecated no-op, kept only so existing models compile.)
When the field name doesn’t match the target model, name it explicitly with
mfx:"relation:Target". A bare mfx:"relation" on a field that doesn’t end in
ID fails at startup: there’s no suffix to strip, so the target would be
inferred from the whole field name — a guess that is almost never a real model.
A relation whose target model is simply never registered is allowed by default (it may be a plain foreign id that wants no relation tag) and rejected under strict mode.
Including the related row
curl 'localhost:8080/api/posts?include=user'
Each post in the response gains a user object populated from the related
table. Multiple includes are comma-separated:
curl 'localhost:8080/api/posts/<id>?include=user,comments'
BelongsTo (explicit)
When the FK field name does not match the target model — for example, a
ManagerID pointing to User — declare the relation with mfx:"relation:Name"
and add a companion field of the target type:
type Team struct {
maniflex.BaseModel
Name string `json:"name" mfx:"required"`
ManagerID string `json:"manager_id" mfx:"required,filterable,relation:Manager"`
Manager User `json:"manager,omitempty"`
}
ManagerIDis the column that stores the FK.Manageris the companion field: it carries the target type (User) so the framework can resolve the relation. It is not a column itself — its only role is type information for relation scanning.
The relation key here is manager (snake-case of Manager), so the include
becomes ?include=manager.
A relation:Name directive without a matching companion field fails
registration.
HasMany
The inverse side: a slice of the related struct, declared on the model that does not hold the FK.
type User struct {
maniflex.BaseModel
Name string `json:"name"`
Posts []Post `json:"posts,omitempty"` // populated when ?include=posts
}
There is no column on users for this — Posts is purely a relation
declaration. The FK is expected on the related table, named after the parent
model: Post is expected to carry user_id. (That column comes from UserID
on Post, as in the BelongsTo example above.)
The relation key for a slice is the snake-case of the field’s JSON name, here
posts.
curl 'localhost:8080/api/users/<id>?include=posts'
ManyToMany
A many-to-many relation uses a third junction model that carries the two FKs.
Both sides declare a slice with mfx:"through:JunctionModel":
type Product struct {
maniflex.BaseModel
Name string `json:"name"`
Tags []Tag `json:"tags,omitempty" mfx:"through:ProductTag"`
}
type Tag struct {
maniflex.BaseModel
Label string `json:"label"`
Products []Product `json:"products,omitempty" mfx:"through:ProductTag"`
}
// The junction model — register it just like any other.
type ProductTag struct {
maniflex.BaseModel
ProductID string `json:"product_id" mfx:"required,filterable,relation"`
TagID string `json:"tag_id" mfx:"required,filterable,relation"`
}
All three models must be registered. The junction’s FK columns are declared as
BelongsTo relations (mfx:"relation" on ProductID and TagID), so the
framework can resolve which side of the join goes where.
?include=tags on a product follows the junction and returns the related tags
directly.
Junction payload (_through)
A junction often carries data of its own — a role, a position, a date the link
was made. When it does, each included row gains a _through object holding
those columns:
{
"id": "tag-1",
"label": "blue",
"_through": { "position": 2, "linked_at": "2026-07-19T09:00:00Z" }
}
The two foreign keys and the junction’s id are excluded — they say nothing the
response does not already carry.
The junction is a model like any other, and _through honours its tags. A
column marked mfx:"hidden" or mfx:"writeonly" is absent, exactly as it would
be in a response from the junction’s own endpoint:
type ProductTag struct {
maniflex.BaseModel
ProductID string `json:"product_id" mfx:"required,filterable,relation"`
TagID string `json:"tag_id" mfx:"required,filterable,relation"`
Position int `json:"position"` // in _through
InvitedBy string `json:"invited_by" mfx:"hidden"` // not in _through
}
Two further exclusions are worth knowing:
mfx:"encrypted"columns are omitted, along with their_hmaccompanions. The junction payload has no decryption pass, so the alternative would be emitting ciphertext — no use to a client, and it discloses the encryption envelope. Put a value you need to read on the related model, not on the junction.- Columns the junction model does not declare are omitted. A column present in the table but absent from the model is schema drift, and dumping it is what used to make this a leak.
Before v0.2.5
_throughwas copied verbatim from the junction row, so hidden and write-only columns surfaced on every include.
Which models are join tables
A junction can be declared three ways. In order of precedence:
| How | When to use | |
|---|---|---|
| Explicit relation | mfx:"through:Junction" on a slice field | you want the relation named on the model |
| Explicit marker | embed maniflex.JunctionModel | the junction carries columns of its own |
| Auto-detected | two BelongsTo relations, no other columns | a plain link table |
Auto-detection accepts only the unambiguous shape — two foreign keys, an id,
timestamps, and nothing else:
type ProductTag struct { // auto-detected
maniflex.BaseModel
ProductID string `json:"product_id" mfx:"relation"`
TagID string `json:"tag_id" mfx:"relation"`
}
Add a column of its own and detection stops, because that shape is equally what an ordinary entity looks like:
type Order struct { // NOT a junction — nothing is inferred
maniflex.BaseModel
CustomerID string `json:"customer_id" mfx:"relation"`
AddressID string `json:"address_id" mfx:"relation"`
Total int `json:"total"`
}
Before v0.2.6
Orderwas treated as a join table, so Customer and Address silently gained a many-to-many through it. If you relied on detection for a junction that carries payload, embedJunctionModel— see below.
A junction that carries payload says so:
type Enrollment struct {
maniflex.BaseModel
maniflex.JunctionModel
StudentID string `json:"student_id" mfx:"relation"`
CourseID string `json:"course_id" mfx:"relation"`
Term string `json:"term"` // in _through
}
JunctionModel is embedded alongside BaseModel, not instead of it — a
junction is an ordinary model with an id. The model must have exactly two
BelongsTo relations to distinct models; anything else is a registration error.
ModelConfig.DisableAutoJunction opts a model out of detection entirely, for a
link-shaped model that should not gain the relation.
Unique links
Junction pairs are not unique by default. Declare it when they should be:
type ProductTag struct {
maniflex.BaseModel
maniflex.JunctionModel `mfx:"unique"`
ProductID string `json:"product_id" mfx:"relation"`
TagID string `json:"tag_id" mfx:"relation"`
}
This emits a UNIQUE index over the two key columns, so a repeated link is
refused with 409. It also lets an include collapse duplicate pairs: with the
declaration a repeat is corruption, without it a repeat is data.
Off by default because a junction carrying its own attributes may legitimately
repeat a pair — Enrollment{student_id, course_id, term} holds one row per
term, and each carries its own _through payload. A pure link table almost
always wants the tag.
Adding it to a table that already holds duplicates fails the migration until
they are cleaned up. That is why it is separate from the marker: embedding
JunctionModel changes nothing about the schema, so declaring what a model is
never risks a migration.
Junction deletes
A junction’s foreign keys default to ON DELETE CASCADE, so deleting either
endpoint takes its link rows with it — a link to a row that no longer exists
says nothing. An explicit mfx:"on_delete:..." on the column wins, and edges
where either side soft-deletes are handled in maniflex’s own delete path rather
than by a database constraint (see below).
Cascading deletes
A BelongsTo relation may declare what happens to this row when the parent it
points at is deleted, using the onDelete sub-option:
AuthorID string `json:"author_id" mfx:"relation:Author;onDelete:cascade"`
| Action | Effect on this row when the referenced row is deleted |
|---|---|
cascade | this row is deleted too |
setNull | the FK column is set to NULL (the field must be a pointer, so it is nullable — otherwise a registration error) |
restrict | the parent delete is refused with 409 DELETE_RESTRICTED while this row exists |
| omitted | nothing — the delete leaves this row untouched (its FK dangles) |
The action is validated at startup: it must target a registered model, and
setNull needs a nullable FK. cascade recurses — deleting a row deletes its
children, then their children — and reference cycles are handled.
How it is enforced (and soft delete)
The action is enforced one of two ways, chosen automatically per relation. The rule is that maniflex enforces the edge itself whenever it has something to do beyond deleting the row:
- Nothing to do beyond the row → a real database
FOREIGN KEY … ON DELETE …constraint carries it, and the database enforces it natively (and enforces referential integrity on insert: a row naming a non-existent parent is refused with409). - Either side soft-deletes → the database cannot help (a soft delete is an
UPDATE, soON DELETEnever fires, and a DB cascade can only hard-delete), so maniflex enforces it in the delete request’s own transaction instead. A soft-delete child of a cascaded parent is soft-deleted identically — itsdeleted_atis set, the row is not removed. - The child is
Versionedor is a rollup child → maniflex enforces it, and noON DELETEclause is emitted for that edge. A databaseON DELETEremoves the rows and tells nobody: the history would lose itsdeleteentries and the rollup would keep counting children that are gone. On the maniflex path the child keeps both — a cascaded row records the same history a direct delete would, and every rollup it fed is recomputed once at the end of the sweep. The FK constraint is what is dropped, not the integrity: the children are deleted before the parent, in the same transaction.
Either way the whole deletion is atomic: a restrict that fires rolls back any
cascade that ran alongside it.
Large fan-outs
On the maniflex-enforced path the children of a deleted parent are walked in
pages of 500, ordered by id and bounded by the last id seen, and each page is
applied before the next is read. Only the id column is selected, so the memory
a delete needs is set by the page size rather than by how many children the row
has. restrict does not read the children at all — it asks how many there are.
The work is still proportional to the fan-out, and it all happens inside the delete request’s transaction: deleting a row with a million descendants is a long transaction holding locks the whole time. Prefer soft-deleting the parent and reaping in a job when the fan-out is that large.
A child that is Versioned or is a rollup child costs one extra read per row —
the walk selects only id, and the history row’s diff and the rollup’s foreign
key both need the rest of it. That is the price of the bookkeeping; a child with
neither pays none of it.
Existing SQLite tables. New FK constraints are declared when a table is created. SQLite cannot add a foreign key to a table that already exists, so adding
onDeleteto a model with a pre-existing SQLite table does not retroactively add the constraint — recreate the table, or rely on the soft-delete path, which is enforced in the application layer regardless. Postgres adds the constraint on the next migration.
Soft delete and relations
When the related model uses soft delete, rows whose deleted marker is set are
omitted from ?include= results — the same filter the framework applies to
list endpoints. See Soft Delete.
Scoping and ?include=
A request’s forced filters — the scope imposed by db.Tenancy or
db.ForceFilter — are applied to included rows as well as to the primary read,
for every relation kind, wherever the related model carries the filtered column.
For a many-to-many they apply to the junction rows too, on the same terms:
a link row is read only if it is in scope, whenever the junction carries the
column.
This matters because the foreign key is the client’s to set. Without it, a
caller who can write an FK (or a many-to-many junction row) puts their own row
inside another tenant’s ?include=, and an attach pulls another tenant’s record
into their own response.
A scoped write now refuses such a key up front — every BelongsTo key a create
or update sets is read through the request’s scope, and one naming a parent the
caller cannot see is refused with that parent’s 404, junction rows included.
The include scoping here is the second line: it holds for a row that reached the
table by some other road — an unscoped back-office write, an import, a row
predating that check.
// Tenancy on both models; the include is scoped by the same filter.
server.Pipeline.DB.Register(
db.Tenancy("org_id", orgOf),
maniflex.ForModel("Order", "OrderLine"),
)
What is not covered. Be precise about this, because the gap is narrow but real:
- A related model with no such column is not scoped. That is the right answer for the case that produces it — a shared lookup table (currencies, categories, statuses) is not tenant-partitioned and has nothing to scope by — but if a model is partitioned by something the filter cannot name, its includes are unscoped — and so is the key check on the write, which uses the same “does the parent carry this column” rule. Validating that FK is yours.
- Relation-path filters (
db.ForceFilterVia) are skipped: they are written against the primary model’s relations and mean nothing on the related table. - A link between two of your own rows written by another tenant is caught only if the junction carries the scope column. Scoping the related rows alone cannot catch it: both endpoints are yours, so both pass. Put the tenant column on a partitioned junction and register the scope on it, and the write is refused outright; without that column the junction is shared, and the link is neither refused nor hidden.
Before this release the junction was read unscoped and without its soft-delete condition, so a link another tenant wrote between two of your records surfaced in your include with that tenant’s
_throughpayload, and a soft-deleted link kept materialising its related row.
Quick reference
| Goal | Declaration |
|---|---|
Belongs to User (FK column matches) | UserID string |
Belongs to User under a different name | ManagerID string with mfx:"relation:Manager" + Manager User companion |
Has many Post | Posts []Post (other side carries UserID) |
Many-to-many via ProductTag | Tags []Tag with mfx:"through:ProductTag", on both sides |
| Cascade on parent delete | mfx:"...,onDelete:cascade" |
| Populate in a response | ?include=user,comments |