Graphitron’s multi-tenant model is database-per-tenant: every tenant-scoped table carries a tenant column, each tenant’s rows live in that tenant’s own database, and the generator routes every statement to the right database from what the operation itself says. This page is the wiring recipe; the classification rules (which fields route how, and which schemas are rejected as unroutable) are enforced at build time.
1. Name the tenant column
Point the plugin at the column that scopes your tables:
<configuration>
<tenantColumn>organization_id</tenantColumn>
</configuration>
Tables carrying the column are tenant-scoped; tables without it are global reference data served from the default source. The tenant key’s Java type is read off the catalog (all tables must agree), and it types every tenant-keyed runtime surface, so wiring a map keyed with the wrong type is a compile error.
2. Wire one DataSource per tenant
Construct the generated runtime with the default source plus the per-tenant map:
Map<Integer, DataSource> byTenant = Map.of(
1, tenantOnePool,
2, tenantTwoPool);
var runtime = new GraphitronRuntime(defaultPool, byTenant, SQLDialect.POSTGRES);
var graphql = runtime.newGraphQL(Graphitron.buildSchema(b -> {})).build();
Every request states which tenants it may touch. The generated factories take the set as a parameter ahead of your mount’s payload, typed as a collection of your tenant key type, so leaving it out is a compile error:
Set<Long> permitted = permittedInstitutions(jwt); // from the caller's claims, narrowed to this deployment
ExecutionInput input = Graphitron.newOwnedExecutionInput(permitted, claims)
.query(query).build();
Graphitron checks every tenant an operation routes to against this set before it takes a connection, and no connection to a tenant outside it is ever opened. A field whose tenant is outside the set, whether named by an argument, decoded from a node-id argument or handed down from a parent, fails with a client error. The lookups node, nodes and _entities instead answer an id whose tenant is outside the set with null, exactly as they answer an id that does not exist, and resolve the rest of the batch normally; a client cannot tell the two apart, by design. The same set bounds @tenantFanOut fields (see §3). A caller entitled to everything passes the hosted set, runtime.tenantKeys(); there is no "unrestricted" value, and an ExecutionInput built without the generated factory fails before any field runs.
The set means the tenants this request may touch in this deployment. If a user’s permissions can include tenants another deployment serves, narrow the set before passing it: a tenant in the set that this deployment has no DataSource for fails every @tenantFanOut field, because graphitron cannot tell a tenant served elsewhere from a DataSource that was never wired. Narrow against your own list of this deployment’s tenants to keep that check; narrowing against runtime.tenantKeys() also works, but turns the check off.
Your <sessionState> mount’s payload follows the set on the same factory (the parameter names and types are your mount method’s own). Connections are taken lazily, one per distinct tenant the operation actually touches, and your mount runs on each, so sessions, row-level security, and transaction boundaries behave exactly as in single-tenant operation, per tenant. A $session-bound service parameter likewise receives the handle of the tenant connection its call runs on, never a neighbor’s.
Your mount can learn which tenant it is mounting for. Declare a parameter typed Optional of your tenant key type, anywhere in the signature:
public static RasHandle mount(Configuration cfg, Optional<Long> tenant, Claims claims) {
long institution = tenant.orElseThrow(); // or handle the default source
...
}
A connection routed to a tenant receives that tenant; the default source, which serves your global tables, receives Optional.empty(). The parameter is not part of the payload and does not appear on the factory. Graphitron recognises it by type alone, so any other Optional parameter on a multi-tenant mount is a build error, and a plain Long tenant parameter is ordinary payload that your factory will ask for. Tenant keys that share one DataSource still get one connection and one mount each, each with its own key. A mount that throws fails the request closed and evicts the connection, so it is also the place for any per-tenant check the tenant set cannot express. Your unmount does not receive the tenant; if it needs it, put it in the handle your mount returns.
Routing is divined from the operation: an argument bound to the tenant column routes the statement and hands the tenant down its subtree; global tables read the default source. A tenant-scoped field that nothing routes is a build error, never a silent read of the wrong database.
A @service field routes the same way. When its arguments carry the tenant, through a jOOQ record parameter binding the tenant column or a decoded @nodeId whose key holds it (a bean member typed as the node table’s record, a record parameter, or a top-level argument), the service method runs on that tenant’s connection and the tenant is handed down to the rows it returns, so tenant-scoped fields under a payload wrapper resolve. Every tenant-bearing id in the arguments must name the same tenant: an omitted optional id is skipped, and a batch naming two tenants is refused before the service is called. A @nodeId naming a multi-table interface or union cannot route the call, since its members decode the same id with their own key columns, so it stops the field routing. A service that takes a DSLContext or $session, as a parameter or through its class’s constructor, runs on its parent’s tenant when the parent is tenant-bound. A service returning a @table type over a global table runs on the default source whatever its arguments name, because graphitron re-reads the returned rows there.
A service’s own SQL is opaque to graphitron, so a root service that is handed a DSLContext or $session must name its tenant in its arguments, or the build refuses it rather than run it on the default source. Take the node table’s jOOQ record with @nodeId(typeName:) instead of the encoded id as a String, and the call routes on the id. A service that works only on global tables, or only reads the mounted session handle, says so with @globalData, and runs on the default source. The build rejects @globalData wherever it can see tenant data: a tenant-scoped @table return, an argument that names a tenant, or an argument typed by a tenant-scoped table’s record. The reverse holds too: a service that takes a tenant’s record runs on that tenant’s database, so it should not take one if it only works on global tables.
A node id (and a federation representation) carries its tenant inside the key, and what happens next turns on where it arrives. At a dispatch surface (node, nodes, _entities) the batch partitions per decoded tenant, one statement per tenant. Anywhere else, a write keyed by ids or a filter reading them, the call is one statement on one connection, so every id in it must decode to the same tenant; a call mixing tenants is refused before any SQL runs, naming the disagreement. This includes a reference id on an update input. An update that repoints a row at a parent in the same table, where the parent’s key carries the tenant, is fine as long as the parent lives in the row’s own tenant; if it does not, the call is refused the same way. An update never moves a row between tenants, so an input field that writes the tenant column itself is a build error. The asymmetry is the one between reads and writes: a read can be split across databases, and a write cannot.
3. Fan a field out across tenants
When no single tenant is named ("all results for this student", wherever they live), mark the field with @tenantFanOut: the query runs once per tenant the request may see, in parallel, and the results come back as one list.
The tenants a fanned field runs over come from the same request tenant set every routed field is checked against (§2), derived from your request’s claims and any other request-level policy (operational exclusions, residency, product rules; the seam is source-agnostic). Which tenants actually run is the intersection of that set with the configured tenant map: a hosted tenant outside the set is never queried (the authorization pre-filter), and a tenant in the set that the deployment has no DataSource for fails the field by design, before any SQL runs, because skipping it would return incomplete results presented as complete.
If your claims can name tenants this deployment does not serve, narrow the set at the factory; the narrowing is your explicit statement, not a silent runtime drop. There are two ways to narrow, and they cost different things. Intersecting with your own list of this deployment’s tenants keeps the check meaningful: a tenant you meant to serve but whose DataSource was never wired still fails loudly. Intersecting with runtime.tenantKeys() is accepted too, but it removes exactly the tenants the check exists to catch, so it turns the check off.
Tuning fan-out
Fan-out parallelism is bounded and configurable on the generated runtime: a concurrency cap (default 8 scatter workers in flight, one bounded pool shared by all of the runtime’s requests) and a per-field deadline (default 10 seconds), set as plain constructor values. Supply your own Executor instead if you want to own threading (for example virtual threads); the deadline still applies.
// Explicit cap and deadline over the runtime-owned bounded pool:
new GraphitronRuntime(defaultPool, byTenant, SQLDialect.POSTGRES, 16, Duration.ofSeconds(5));
// Or bring your own executor; you own the concurrency bound, the deadline stays enforced:
new GraphitronRuntime(defaultPool, byTenant, SQLDialect.POSTGRES, myExecutor, Duration.ofSeconds(5));
A tenant that fails or misses the deadline contributes one null element and a path-bearing entry in the response’s errors array; the field’s element nullability chooses whether that stays partial data ([Thing]) or nulls the whole field ([Thing!]). See the directive reference.