← Roadmap

Concept explainer · R45 · theme: runtime-connection

Operation-divined tenant routing

How graphitron picks the right per-tenant database without a tenant parameter, by reading the column mappings the schema already carries. Read this before the R45 plan.

This page builds intuition; the plan is the spec (exact call sites, the sealed arms, rejection wording, slice boundaries). Read this first, then the plan.
The one-sentence version graphitron already knows which database column every argument, input field, node id, federation representation, and parent row binds to; if the deployment additionally declares which column carries the tenant id, graphitron can divine, per field, where the tenant comes from and route each subtree to the correct per-tenant database, with no @tenantId directive and no request-level tenant parameter.

The problem, concretely

Picture a multi-tenant deployment: each customer organisation's data lives in its own database (database-per-tenant), and the same generated API serves all of them. A query arrives:

query { emner(filter: { eierOrganisasjon: 1234 }) { ... } }

Nothing in the transport says "tenant 1234". The tenant is inside the operation: the filter argument eierOrganisasjon maps to a database column that happens to be the tenant key, so the value 1234 both filters the rows and names the database they live in. Graphitron must notice that and acquire the connection for tenant 1234's source, not the default one.

The tempting shape: a request-level tenant

Ask the caller to pass the tenant up front, at request scope, and thread it through everything.

But an API user can legitimately hold data in several tenants at once (a student with results at multiple universities). One request, many tenants: a single request-scope value cannot express it.

The design: divine it from the operation

Every field already knows its column bindings. If one of those columns is the tenant column, the tenant is right there in the field's own inputs or its parent row.

Different fields in one query can divine different tenants, so multi-tenant queries are first-class rather than a special case.

Quiz Why not just add a @tenantId directive to the argument that carries the tenant?

graphitron already resolves each argument/input field to a database column (the same resolution filters and conditions use). Once the deployment says which column is the tenant column, the binding falls out of information the schema already carries. A directive would be redundant surface. (The plan keeps a @tenantId override in its back pocket as an open question, deferred until a real schema shows inference picking the wrong binding.)

One declaration, three table scopes

The whole machinery switches on a single deployment-level element naming the column:

<tenantColumn>eier_organisasjon</tenantColumn>

Absent this element, none of the routing exists and graphitron's single-DataSource world is the whole story. Present, every table classifies into one of three scopes at catalog load:

Tenant-scoped

Carries the tenant column and is partitioned per tenant. Lives in the per-tenant databases. (RESULTAT: a student's grades.)

Tenant index

Carries the tenant column but is not partitioned; lives on the default source and its rows point out into tenants. Must be declared explicitly, because carrying the column cannot tell an index from partitioned data. (STUDENT_ORGANISASJON: which orgs hold a given student's data.)

Global

Does not carry the column. Reference data on the default source. (Country codes, language names.)

The tenant's Java type is not configured; it is read off the jOOQ catalog's column type, and every table carrying the column must agree on that type (disagreement is a build rejection). The type of the tenant is a fact of the database, so graphitron reads it rather than asking.

The idea: a per-field tenant binding

The core model is a sealed per-field axis, TenantBinding, computed only when <tenantColumn> is configured. It is an optional overlay on the existing field-classification axes (the same family as source() / operation() / target()); in a single-tenant build the axis is simply absent, not "everything is Untenanted". Sealed variants are how graphitron models "one of a fixed set of shapes, handled exhaustively"; see Dispatch axes.

Each field lands in exactly one arm, deciding where its tenant comes from:

Where the tenant is a single value for the field

ArgumentBound: an argument or input field maps to the tenant column. The emner(filter: { eierOrganisasjon }) case, and mutations too.

Inherited: a field below a bound ancestor; the divined tenant flows down the subtree as a value hand-down.

Untenanted: touches only global or tenant-index tables; runs on the default source.

The per-row family: one batch spans tenants

NodeIdBound: resolved by node id; the tenant is a decoded-column position of the batch key. Each id carries its own tenant.

EntityRepBound: federation _entities; the tenant is a decoded position of each representation.

ParentRowBound: a child under a tenant-index parent; each parent row names its own tenant. Extracted to R505; R45's first iteration classifies tables two-way (tenant-scoped or global) and this arm arrives with the index scope.

The right column is the subtle half. For a node-id batch or a federation _entities call or the children of an index, a single batch spans many tenants, so the consumer cannot take one value and hand it down; it must partition the batch per tenant. Notice the two decoded arms deliberately share one shape ("a positional slot in a decoded key"): node ids and federation reps both decode at the DataFetcher boundary, and the model carries the decoded position, never a reference back into the raw id string or rep map. Wire format stays a boundary concern.

Quiz A tenant-scoped resultater child sits under a tenant-index parent (organisasjoner). What arm, and why not Inherited?

Inherited applies under a tenant-scoped parent, where the execution context is already tenant-homogeneous (one tenant for the whole subtree) so the tenant is just handed down. A tenant-index parent is the opposite: each of its rows points at a different tenant, so its children's batch is tenant-mixed by construction and each row's tenant must be read off that row. Same child type, different parent scope, different arm.

No binding in scope is a build error, not a fallback

This is the rule the whole item exists to enforce. A field that reaches a tenant-scoped table with no tenant binding in scope is rejected at build time (noTenantBinding), never routed through the default connection as a best guess. Routing tenant data through the default source because nothing named the tenant is exactly the cross-tenant leak the design prevents.

This is graphitron's typed-rejection discipline: a shape the generator cannot resolve safely is a first-class rejection with a message, not a silent default.

Unroutable and unmarked → rejected

A tenant-scoped field with nothing divining its tenant. Build fails, naming the field. Fix the schema or mark it deliberately (below).

Deliberate no-binding → fan out (R46)

When you genuinely mean "look in every tenant and union the results" (the pattern production runs by hand today), an explicit schema marker classifies into a positively-typed fan-out arm. That arm lands in R46 together with its emitters, never as a flag that suppresses the rejection.

Quiz A new field reads a tenant-scoped table but no argument, node id, rep, or parent row binds the tenant. Someone proposes "just default to the primary tenant's connection." Sound?

A silent default connection would serve one tenant's rows to a request that never named that tenant, or leak across tenants depending on deployment. The design forbids the guess entirely: either something in scope divines the tenant, or the author positively opts into fanning across all of them (R46). "Reads only" does not make a cross-tenant read safe.

Worked example: student results across universities (the index shape lands with R505)

A student's grades live in whichever universities hold their data. STUDENT_ORGANISASJON is a declared tenant-index table on the default source; RESULTAT is tenant-scoped. Until R505 ships the index scope, this query shape is answered by R46's fan-out: query every tenant and union the results.

query {
  student(id: "...") {
    organisasjoner {          # index rows, default source; Untenanted
      eierOrganisasjon
      resultater { karakter } # ParentRowBound: routed per row's eierOrganisasjon
    }
  }
}

organisasjoner runs one query on the default source. resultater partitions its loader by each row's eierOrganisasjon and acquires per partition: one query per university that holds data for this student, each in its own read-only transaction. Fields below resultater classify Inherited: the tenant is fixed for that subtree. Per-tenant row-level security composes on top, so a tenant database where this user has no access returns nothing rather than leaking.

Why partitioning is load-bearing, not cosmetic

The per-row arms all end at the same mechanism: partition every batch so each SQL execution touches exactly one tenant. For batched children this is enforced through DataLoader identity: the tenant key joins the path-derived loader name, so a loader is tenant-homogeneous by construction.

Here is why that is not optional. A batch loader resolves one DSLContext from the environment captured when the loader was created. A tenant-mixed loader would run every key against the first key's tenant, silently reading the wrong database for the rest. Two invariants keep it honest: one shared helper composes the loader name at both the registration and lookup sites, and the tenant segment is an opaque partition key, never parsed back to recover the value (the captured environment carries the typed tenant).

Quiz Why does the tenant have to be part of the DataLoader's identity, rather than passed as a per-key argument inside one shared loader?

The connection is resolved once, per loader, from the environment captured at loader creation, not per key. So the tenant must partition which loader you land in, giving each loader a homogeneous tenant and a correctly-routed captured environment. The per-request DataLoaderRegistry means this only matters within a request, which is exactly when node ids and entity reps span tenants.

Where the connection actually comes from

Routing rides the shipped acquisition seam rather than inventing a new one. graphitron already owns connection acquisition and transactions; a database-per-tenant deployment hands it a per-tenant DataSource map on the generated TenantConnections carrier, keyed by an opaque tenant key. R45 does not add a tenant parameter to the request factory (the tenant is not a request-scope fact); it extends that carrier and supplies the divined key at each acquisition. The default source serves Untenanted fields; each divined binding supplies the key for its own acquisition; an unknown divined key errors before any SQL. See the runtime extension points reference for the acquisition and transaction model this builds on.

Where it stands (as of 2026-07-20)