ID |
|
|---|---|
Status |
Spec |
Bucket |
architecture |
Priority |
5 |
Theme |
service |
Updated |
2026-05-20 |
Multi-tenant routing on top of the schema-driven ExecutionInput factory
Motivation
R190 landed the single-tenant slice ; a schema-driven Graphitron.newExecutionInput(DSLContext defaultDsl, …contextArguments) factory, a sealed GraphitronContext consumers cannot implement, typed getContextArgument(env, name, ExpectedType.class) diagnostics, and cross-site contextArgument type-agreement enforced at validate time. R190 deliberately removed getTenantId(env) from the interface, dropped the getTenantId(env) + "/" prefix from all five DataLoader-name emission sites, and collapsed the federation _entities per-tenant grouping at HandleMethodBody.java. R45 layers multi-tenant routing back on top of that landed baseline.
Three additions, all gated on a new <tenantColumn> Mojo element:
-
Tenant-column classification +
byTenantfactory overload. When the consumer configures<tenantColumn>, codegen classifies every catalog table as tenant-scoped (carries the named column) or global (does not). A secondnewExecutionInputoverload appears alongside R190’s single-tenant baseline, taking aFunction<T, DSLContext> byTenantslot so per-request DSLContext routing can dispatch on a tenant id. Absent the Mojo element, the single-tenant shape stays as-shipped. -
Conditional DataLoader name partitioning. The five DataLoader emission sites R190 stripped to path-only names re-add the
getTenantId(env) + "/"prefix selectively: tenant-scoped target types get the prefix back so cross-tenant cache collisions can’t occur; global types stay path-only so loaders for reference data are shared. The federation_entitiesper-tenant grouping atHandleMethodBody.javare-introduces the innerMap<String, …>level when<tenantColumn>is configured, restoring the per-tenant batching shape. -
@tenantIdARGUMENT_DEFINITION directive. A new directive marks the GraphQL argument that drives DSLContext routing for a field; codegen reflects the marked argument’s Java type, validates assignability against the configured tenantT, and emitsbyTenant.apply(…)at the dispatch site. Schemas combining a@tenantIdargument withtenantIdin the same field’scontextArgumentslist reject ; two competing sources of the same value.
The interface widening (getTenantId(env) returns T rather than String, plus a new getDslContext(T tenantId) for the multi-tenant dispatch) is additive on the sealed GraphitronContext R190 shipped; it is not a breaking signature change for code that compiled against R190’s three-method shape.
Design
Tenant column declaration
A single Mojo element declares the app’s tenant column, by name and Java type:
<tenantColumn>
<name>tenant_id</name>
<javaType>java.lang.Long</javaType>
</tenantColumn>
One column per app. <javaType> drives the Java type T everywhere graphitron talks about tenant ids ; the byTenant factory parameter, the configured-into-GraphitronContext return type, the @tenantId arg-type check.
When <tenantColumn> is absent, graphitron runs in single-tenant mode: no multi-tenant factory overload, no DataLoader name partitioning by tenant, no byTenant shape anywhere in the emitted surface.
Tenant-scope classification
At codegen, after the jOOQ catalog is loaded, every table is classified:
-
Tenant-scoped ; contains a column whose name matches
<tenantColumn><name>. -
Global ; does not.
The classification is computed once during catalog loading and consulted at three places: the multi-tenant factory generation (does any reachable graph type sit on a tenant-scoped table?), the DataLoader emission sites (which name prefix do we emit?), and the @tenantId arg-type check (the marked arg must be assignable to the configured T).
Multi-tenant factory overload
Post-R190, GraphitronFacadeGenerator emits a single schema-driven newExecutionInput(DSLContext defaultDsl, …contextArguments) factory whose parameter list reflects the SDL’s contextArguments alphabetically, with reflected Java types pinned by the cross-site type-agreement classifier. R45 adds a second overload that appears IFF <tenantColumn> is configured.
For a schema using @service(contextArguments: ["userInfo", "fnr"]) with no tenant column configured (the R190 baseline, unchanged):
public static ExecutionInput.Builder newExecutionInput(
DSLContext defaultDsl,
String fnr,
UserInfo userInfo);
For the same schema with <tenantColumn> configured:
public static ExecutionInput.Builder newExecutionInput(
DSLContext defaultDsl,
String fnr,
UserInfo userInfo); // single-tenant fallback (unchanged from R190)
public static ExecutionInput.Builder newExecutionInput(
DSLContext defaultDsl,
Function<Long, DSLContext> byTenant,
String fnr,
UserInfo userInfo); // multi-tenant
The byTenant slot sits second, immediately after defaultDsl and before the alphabetical-sorted contextArguments, so the parameter ordering stays predictable across consumers. The multi-tenant overload populates GraphQLContext with byTenant under the typed key byTenant.class (or a generated marker class ; pick during implementation) so the singleton impl’s getDslContext(T tenantId) method can read it back.
The byTenant factory and the per-tenant DSLContext routing are independent of whether tenantId appears in any contextArguments list. They are driven by the Mojo config alone; the schema’s per-field tenant id source is the value fed into byTenant.apply(…) at the dispatch site (see "Sources for the per-field tenant id" below).
Both overloads return graphql-java’s ExecutionInput.Builder unchanged. Internally each factory routes through the same Objects.requireNonNull + graphQLContext.put shape R190 emits; R45 adds the byTenant null-check to the multi-tenant body and stashes the function reference into the per-request context.
Sources for the per-field tenant id
A given field may source its tenant id from either:
-
Request scope, via contextArguments. If
@service(contextArguments: ["tenantId", …])liststenantId, the factory grows aLong tenantIdparameter alongside the others. The consumer extracts it in their HTTP filter (typically from JWT / session) and passes it in. -
GraphQL argument, via
@tenantId. A new ARGUMENT_DEFINITION directive marks the argument that drives DSLContext routing for the field:```graphql findCustomerOrders( customerCompanyId: ID! @tenantId, status: OrderStatus ): [Order!] @table(name: "order") ```
At runtime, the field extracts the marked argument's value and calls `byTenant.apply(value)`. The marked argument's GraphQL type must produce a value assignable to the configured tenant `T`; the codegen rejects mismatches at validation time.
A field carrying both a @tenantId argument and tenantId in its contextArguments is rejected at codegen ; two competing sources of the same value. At most one @tenantId argument per field; the directive applies to ARGUMENT_DEFINITION only.
GraphitronContext method-set widening
R190 shipped the sealed GraphitronContext with three default methods (getDslContext, getContextArgument, getValidator) and a generated GraphitronContextImpl singleton. R45 widens the method set additively on the same sealed surface:
-
T getTenantId(DataFetchingEnvironment env); returns the configured tenantTfromenv.getGraphQlContext()under the typed key the multi-tenant factory populates. Default body:env.getGraphQlContext().get(TenantId.class)(or whatever marker the factory stashes under). Returnsnullwhen the consumer called the single-tenant factory overload (no tenant id supplied); the five DataLoader emission sites null-check the result before composing the prefix. -
DSLContext getDslContext(T tenantId); calls back into thebyTenantfunction the multi-tenant factory stashed onGraphQLContext. Used by the five DataLoader emission sites and the@tenantId-arg dispatch path so they don’t re-readbyTenantfromGraphQLContexton every call.
Both methods are default on the sealed interface; the singleton impl needs no body since the default reads suffice. The interface stays sealed + permits the singleton; consumers still cannot implement it from outside. R190’s three existing methods are unchanged.
R85’s planned host-class helper emission keeps working: graphitronContextCall() is the same call shape; only the method set widens.
DataLoader name partitioning (re-add prefix conditionally)
Post-R190 every DataLoader name is path alone at five emission sites (DataLoaderFetcherEmitter.java:135, TypeFetcherGenerator.java:4553, MultiTablePolymorphicEmitter.java:810 and :876, QueryNodeFetcherClassGenerator.java:161). R45 re-introduces the tenant prefix conditionally:
-
Tenant-scoped target type with
<tenantColumn>configured: name isString.valueOf(getTenantId(env)) + "/" + path; thegetTenantId(env)call returns the configuredT, stringified for the loader-name key. Cross-tenant cache collisions can’t occur because each tenant’s data lives in a distinct loader. -
Global target type: name stays
pathalone, regardless of whether<tenantColumn>is configured. Loaders for reference data shared across tenants stay shared. -
<tenantColumn>unconfigured: every site stays atpath-only, the R190 default; classification treats every type as global by construction.
The five emission sites add a classifier-driven branch: the catalog-loading tenant-scope classification (see "Tenant-scope classification" above) is consulted at emit time to pick prefix vs no-prefix per site. The getTenantId(env) call only appears in the prefix branch; sites emitting global names don’t reference the method at all.
Federation entity dispatch grouping (re-introduce tenant level)
R190 collapsed the federation _entities dispatch grouping at HandleMethodBody.java from Map<Integer, Map<String, List<Object[]>>> to Map<Integer, List<Object[]>> when it removed getTenantId. R45 re-introduces the inner-tenant level when <tenantColumn> is configured:
-
The per-rep DFE construction stays as-shipped (R190 left
repEnv = newDataFetchingEnvironment(env).arguments(rep).build()in place so any in-rep argument reads still work). -
A new
String tenantId = String.valueOf(graphitronContext(repEnv).getTenantId(repEnv))line re-appears atHandleMethodBody.java’s `emitDecodeAndGroupbody. -
The
groupsdeclaration widens back toMap<Integer, Map<String, List<Object[]>>>. -
The per-rep grouping call becomes
groups.computeIfAbsent(altIndex, k → new LinkedHashMap<>()).computeIfAbsent(tenantId, k → new ArrayList<>()).add(…). -
emitGroupDispatchre-adds the inner-entry iteration loop and reads bindings off the inner entry. -
The class-level javadoc at
HandleMethodBody.javaand the sister doc comments atEntityFetcherDispatchClassGenerator.java/QueryNodeFetcherClassGenerator.javare-add thegetTenantId(repEnv)mention in the per-rep DFE rationale.
When <tenantColumn> is unconfigured, the federation grouping stays at R190’s collapsed shape (single-level Map<Integer, List<Object[]>>); the tenant level only re-appears when the Mojo element drives the emit branch.
@tenantId arg validation diagnostics
Rejections specific to the multi-tenant additions, all surfaced through Rejection arms and routed through GraphitronSchemaValidator.validate per R190’s validator-mirrors-classifier pattern:
-
Rejection.tenantIdArgTypeMismatch(ConflictSite site, TypeName declared, TypeName expected); the@tenantId-marked argument’s GraphQL-resolved Java type is not assignable to the configured<tenantColumn><javaType>. -
Rejection.duplicateTenantIdSource(ConflictSite site, String fieldName); the same field carries both a@tenantIdargument and liststenantIdin itscontextArguments. -
Rejection.multipleTenantIdArgs(ConflictSite site, String fieldName, List<String> argNames); more than one argument on the field is marked@tenantId.
All three follow the typed-record-plus-message()-renderer pattern R190 pinned for AuthorError.TypeConflict.
Implementation
Mojo configuration (graphitron-maven/)
-
New
<tenantColumn>element with<name>(column name) and<javaType>(FQN). Both required when the element is present; absence drives the single-tenant fallback path (R190’s shape, unchanged).
Catalog / classification (graphitron/)
-
Tenant-scope classification during catalog loading: produces a
Set<TableName>of tenant-scoped tables, plus the configuredTas aTypeName. Stored on the build result for downstream consumers (the five DataLoader emission sites, the federation grouping site, and the@tenantId-arg dispatcher). -
New rejections (typed records with
message()overrides per R190’sAuthorError.TypeConflictprecedent):Rejection.tenantIdArgTypeMismatch,Rejection.duplicateTenantIdSource,Rejection.multipleTenantIdArgs. -
Validator-mirror surface:
GraphitronSchemaValidator.validategainsvalidateTenantIdArgs(schema, errors), draining the new rejection list intoValidationError`s the same way R190’s `validateContextArgumentTypeAgreementdoes.
Facade generation (generators/schema/GraphitronFacadeGenerator.java)
-
The single-tenant overload R190 emits stays unchanged.
-
When
<tenantColumn>is configured, emit a secondnewExecutionInputoverload withFunction<T, DSLContext> byTenantslotted second (betweenDSLContext defaultDsland the alphabetical contextArguments). Body:Objects.requireNonNull(byTenant, "byTenant")after thedefaultDslnull-check, then stashbyTenantongraphQLContextunder a typed marker key.
GraphitronContext (generators/util/GraphitronContextInterfaceGenerator.java)
-
Widen the sealed interface’s method set additively: add
default T getTenantId(DataFetchingEnvironment env)(reads the typed marker fromenv.getGraphQlContext(), returnsnullwhen the marker is absent) anddefault DSLContext getDslContext(T tenantId)(reads the stashedbyTenantand invokes it). -
Configured
Tis the JavaPoetTypeNamefrom<tenantColumn><javaType>; both new methods are emitted parameterised by it. -
R190’s three existing methods (
getDslContext(env),getContextArgument(env, name, expectedType),getValidator(env)) stay unchanged.
Emission sites
-
Five DataLoader name sites: branch on the target type’s tenant-scope classification at emit time. Tenant-scoped target with
<tenantColumn>configured emitsString.valueOf(graphitronContext(env).getTenantId(env)) + "/" + path; everything else stays path-only (the R190 default). -
Federation entity dispatch grouping (
HandleMethodBody.java): re-add theString tenantId = …line, widen thegroupsdeclaration back to the nested-map shape, restore the inner-entry iteration inemitGroupDispatch, and revise the class-level javadoc to mentiongetTenantId(repEnv)again. Only emitted when<tenantColumn>is configured; the un-tenanted path stays at R190’s collapsed shape. -
@tenantId-arg dispatch (ArgCallEmitter/ the field-fetcher emitter that wiresbyTenant): at the marked argument’s slot, emitgraphitronContext(env).getDslContext(env.getArgument("customerCompanyId"))so the field’s DSLContext is the per-tenant one for the duration of the fetch.
Schema (schema/directives.graphqls)
-
New
directive @tenantId on ARGUMENT_DEFINITION. The shippingdirectives.graphqlsalready auto-injects per R190; the new directive lands on the same file.
Re-enabling commented-out tests
R190 commented out two execution-tier tests in graphitron-sakila-example (querydb/FederationEntitiesDispatchTest.java’s `entities_multiTenancyPartition_oneSelectPerTenant and querydb/GraphQLQueryTest.java’s `nodes_perTenantPartition_separateBatchPerTenant) with a forward-reference comment naming this item. R45 uncomments both methods in the same commit window as the generator changes and reshapes the test fixtures against the new sealed surface: per-tenant DSLContext selection happens at request entry now (Graphitron.newExecutionInput(defaultDsl, byTenant, …)), not through a consumer-supplied GraphitronContext impl. Assertion shapes (QUERY_COUNT == 2 for the partitioning case, the per-rep dispatch invariant for the federation case) stay as the canonical execution-tier proofs.
Tests
-
Catalog (L1/L2). Tenant-column classification: tables with the configured column tag tenant-scoped, others global. Mojo without
<tenantColumn>⇒ all tables global, no multi-tenant overload emitted. -
Classification (L2). New
TenantIdArgClassificationTest: SDL fixtures exercise the three new rejections (tenantIdArgTypeMismatchwith a mismatching arg type,duplicateTenantIdSourcewith@tenantId+tenantIdcontextArgument on the same field,multipleTenantIdArgswith two@tenantId-marked args). Each accept-case fixture asserts the typedRejectioncarrier; the rejected fixture asserts both the typed record and the renderedmessage()shape. -
Validation (L4). New
TenantIdArgValidationTestundergraphitron/src/test/java/no/sikt/graphitron/rewrite/validation/, mirroring R190’sContextArgumentTypeAgreementValidationTest: feeds each newRejectionintovalidateTenantIdArgsand asserts the renderedValidationErrorshape. -
Pipeline (L4). Extend the R190
GraphitronFacadeGeneratorpipeline test (the case that snapshots the single-tenant factory) with a second case: same SDL plus<tenantColumn>configured. Snapshot asserts both overloads emit, thebyTenantparameter sits second on the multi-tenant overload, and the singleton impl’s two new methods (getTenantId(env),getDslContext(T)) appear. Loader-name partitioning snapshot covers both branches (tenant-scoped target ⇒ prefixed, global target ⇒ path-only). Federation grouping snapshot covers the nested-map re-introduction atHandleMethodBody. -
Compile (L5).
graphitron-sakila-examplegains a multi-tenant schema fixture using both factory overloads, the@tenantIdarg directive, and at least one global-scope reference-data fetch. Generated code passesmvn compile -pl :graphitron-sakila-example -Plocal-db. -
Execute (L6). R190’s existing two commented-out tests (
entities_multiTenancyPartition_oneSelectPerTenant,nodes_perTenantPartition_separateBatchPerTenant) uncomment and reshape against the new sealed surface. New execution-tier coverage: single-request multi-tenant routing throughbyTenant, loader-name partitioning verified by query count + theDataLoader.getCacheKeyintrospection,@tenantIdarg routing verified by per-tenant table isolation.
Open questions
-
@tenantIdsyntactic form. Currently drafted as a per-argument directive. Alternatives considered: sigil in argMapping value (argMapping: "customerCompanyId: $tenantId"); top-level directive arg (@service(…, tenantIdArg: "customerCompanyId")). Per-arg directive picked because the marker is a property of the argument, local to its definition, and the existingargMappingslot’s "GraphQL arg → Java param name" semantics stays single-purpose. Revisit if R46’s fan-out cousin (analogous "this arg drives fan-out") finds the sigil shape natural and the per-arg-directive vs sigil divergence becomes friction. -
Multi-tenant overload when the reachable graph has no tenant-scoped types. Currently emitted whenever
<tenantColumn>is configured. Alternative: skip emission if classification finds no tenant-scoped reachable types. The conservative emission is harmless; revisit only if it causes IDE noise. -
Marker key for
byTenantonGraphQLContext. The multi-tenant factory stashes theFunction<T, DSLContext>under a typed key so the singleton impl’sgetDslContext(T)can read it back. Options: a generated marker class adjacent toGraphitronContextImpl, or a structuralClass<? extends Function>literal. Pick during implementation; the consumer-facing API is unchanged either way. Dissolves under Q4’s alternative direction (nobyTenantstash, nogetDslContext(T)method). -
getTenantId/getDslContexton the sealed interface vs per-fetcher emission against a classifiedTenantIdSource. Likely revision target before the next Spec → Ready pass. The current Design adds two methods to the sealedGraphitronContext(T getTenantId(env)andDSLContext getDslContext(T tenantId)) and stashesbyTenantonGraphQLContextunder a typed marker key. An architectural-principles pass on the current draft (2026-05-20) surfaced three composing findings:-
The new
getDslContext(T)is structurally a thunk overbyTenant, not a context method (the singleton impl just readsbyTenantoffGraphQLContextand invokes it; the method’s signature carries no per-request information the call site doesn’t already have). -
The new
getTenantId(env)returningnullon the single-tenant factory path is the trans-axis return shapeSourceKeyexists to prevent ; one method, two return arms gated on Mojo config the consumer can’t see from the type. -
Stashing
byTenantonGraphQLContextunder a typed marker key is the wire-format-leak family the principles call out (per-request routing function, not a per-request value the fetcher walks).All three compose to a single alternative: drop both new interface methods, emit per-fetcher reads against the classified tenant-id source. The natural classifier-side shape is a new sealed sub-taxonomy `TenantIdSource` carrying the per-field decision ; arms like `ArgSource(argName)` for `@tenantId`-marked args, `SourceColumn(colRef)` for a parent row whose backing class carries the tenant column, `ContextArgSource(name)` for the contextArguments path, and `RequestRootArgSource(argName)` for propagation from the operation root through the query tree. Each fetcher reads off its classified arm; the five DataLoader name-emission sites and `HandleMethodBody.java`'s federation grouping consume the same axis.
If this direction wins, the sealed `GraphitronContext` keeps R190's three-method shape unchanged, "additive widening" stops being load-bearing on the R190 → R45 carve, and Q3 dissolves entirely. R190 doesn't change either way ; its removal of `getTenantId(env)` is exactly the ground this direction stands on.
Cost: a new sub-taxonomy on the field model plus a new per-field classifier step (with the validator-mirror surface that goes with any classifier-side decision). Reshapes §"GraphitronContext method-set widening" (deletes), §"DataLoader name partitioning" (reframes around `TenantIdSource`), §"Federation entity dispatch grouping" (reads tenantId from the rep, not from a method), and §"Sources for the per-field tenant id" (classifies into the new sub-taxonomy explicitly).
-
Roadmap entries (siblings / dependencies)
-
Depends on
single-tenant-execution-input-factory.md(R190). The single-tenant factory, sealedGraphitronContext, typedgetContextArgument, cross-site type-agreement check,Rejection.contextArgumentTypeConflict, the validator-mirrors-classifier wiring, the consumer-migration baseline ingraphitron-sakila-example, and the user-doc revision pass all ship under R190. R45 is the strictly-additive multi-tenant slice on top of that baseline. -
Reshapes
service-multi-tenant-fanout.md(R46). R46’s prior design built on a publicly-implementedContextValueRegistration<FanOut>permit ; which R190 already dissolved when it sealed the interface. R46 will be redesigned: the fan-out source becomes a list-typedcontextArgument(e.g.["fnr", "tenantIds"]withList<Long> tenantIdson the factory R190 lands) plus a new directive arg (fanOutOver:) or per-arg directive (@fanOut). R45 leaves the hooks for R46: list-typed factory parameters carry the reflected parameterised type unchanged, and emission makes no scalar-only assumption about contextArguments. -
Affects
helper-emission-non-fetcher-hosts.md(R85). The host-classgraphitronContext(env)helper-emission gate widens cleanly to the new method set:getTenantId(env)andgetDslContext(T)ride the same shim R85 already plans for. -
Coordinates with
dslcontext-on-condition-tablemethod.md: both touch `ArgCallEmitter’s param walk. No shared file edits but adjacent emission paths. -
Independent of
service-short-classname-resolution.md.