Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

KindDirectionDeclared by
BelongsTothis row holds the FKan FK field tagged mfx:"relation" (or mfx:"relation:Name")
HasManythe other table holds the FKa slice field of the related type
ManyToManya junction table connects both sidesa 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 (UserIDUser).

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>ID field is a plain scalar column unless you tag it mfx:"relation". A value column that merely ends in IDExternalID, CloudEventID, a third-party token — stays a plain column with no relation and no ?include= key. (The legacy mfx:"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.

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"`
}
  • ManagerID is the column that stores the FK.
  • Manager is 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 _hmac companions. 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 _through was 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:

HowWhen to use
Explicit relationmfx:"through:Junction" on a slice fieldyou want the relation named on the model
Explicit markerembed maniflex.JunctionModelthe junction carries columns of its own
Auto-detectedtwo BelongsTo relations, no other columnsa 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 Order was 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, embed JunctionModel — 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.

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"`
ActionEffect on this row when the referenced row is deleted
cascadethis row is deleted too
setNullthe FK column is set to NULL (the field must be a pointer, so it is nullable — otherwise a registration error)
restrictthe parent delete is refused with 409 DELETE_RESTRICTED while this row exists
omittednothing — 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 with 409).
  • Either side soft-deletes → the database cannot help (a soft delete is an UPDATE, so ON DELETE never 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 — its deleted_at is set, the row is not removed.
  • The child is Versioned or is a rollup child → maniflex enforces it, and no ON DELETE clause is emitted for that edge. A database ON DELETE removes the rows and tells nobody: the history would lose its delete entries 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 onDelete to 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 _through payload, and a soft-deleted link kept materialising its related row.

Quick reference

GoalDeclaration
Belongs to User (FK column matches)UserID string
Belongs to User under a different nameManagerID string with mfx:"relation:Manager" + Manager User companion
Has many PostPosts []Post (other side carries UserID)
Many-to-many via ProductTagTags []Tag with mfx:"through:ProductTag", on both sides
Cascade on parent deletemfx:"...,onDelete:cascade"
Populate in a response?include=user,comments