You have generated sources on your classpath. This doc covers what to write in
your app to stand up a working GraphQL endpoint against them. Build-time Maven
plugin configuration is out of scope here; see the legacy-plugin docs for
that until the rewrite owns its own Mojo. The dev-loop behaviour the generator
guarantees is documented below in ## Dev loop, because it is part of what a
consumer agrees to when they adopt the rewrite generator.
Every example assumes the emitted output package is com.example.app. Your
generator config determines the real package; substitute it when importing the
generated Graphitron, GraphitronContext, and related classes.
Hello world
Build the schema and engine once per app, then create one ExecutionInput per request via the schema-driven factory:
import com.example.app.Graphitron;
import graphql.ExecutionInput;
import graphql.GraphQL;
import graphql.schema.GraphQLSchema;
import org.jooq.DSLContext;
GraphQLSchema schema = Graphitron.buildSchema(b -> {});
GraphQL engine = GraphQL.newGraphQL(schema).build();
ExecutionInput input = Graphitron.newExecutionInput(dsl)
.query(query)
.build();
var result = engine.execute(input);
Graphitron.newExecutionInput(…) is the one-call factory. Its parameter list is schema-driven: a DSLContext first, then one parameter per contextArgument named in your @service / @tableMethod / @condition directives, in alphabetical order, with the reflected Java type. A schema declaring @service(contextArguments: ["fnr", "userInfo"]) against a method (String fnr, UserInfo userInfo) emits:
public static ExecutionInput.Builder newExecutionInput(
DSLContext defaultDsl,
String fnr,
UserInfo userInfo);
A missing or wrong-typed contextArgument is a compile error at the call site, not a runtime surprise: the factory’s typed parameter list is the load-bearing diagnostic. The body null-checks each parameter (Objects.requireNonNull) and populates the per-request GraphQLContext; generated fetchers read each value back via getContextArgument(env, name) with an explicit Java cast at the call site ((ExpectedType) graphitronContext(env).getContextArgument(env, name)).
If you bypass the factory and hand-roll an ExecutionInput.Builder (for example, to construct a test fixture without Graphitron), and a contextArgument slot ends up missing, the generated fetcher throws at first read:
java.lang.IllegalStateException: context value 'fnr' was not supplied;
call Graphitron.newExecutionInput(...) to populate it
The message names the contextArgument and the factory that supplies it. Going through Graphitron.newExecutionInput(…) makes the same mistake a compile error at the call site instead.
The sealed GraphitronContext interface (under <outputPackage>.schema) is the per-request contract every generated DataFetcher reads from. Apps no longer implement it directly: the factory populates a typed GraphQLContext and stashes the singleton GraphitronContextImpl under GraphitronContext.class. Runtime Extension Points covers the contract and the rationale for sealing it.
For a complete app and the recommended test setup, see the
graphitron-sakila-example
module: a Quarkus + JAX-RS shell over a generated schema, plus in-process
query-to-database tests with match-style and approval-style worked examples.
Custom scalar
Bind a custom scalar to a public static final GraphQLScalarType constant via the @scalarType(scalar:) directive on the SDL declaration. Graphitron loads the named class at codegen, reflects on the constant’s Coercing<I, O> to recover the Java type used for input records, service parameters, and Field<X> projections, and registers the constant on the synthesized schema for you. The buildSchema(…) customizer does not need to wire the scalar.
scalar UUID @scalarType(scalar: "graphql.scalars.ExtendedScalars.UUID")
scalar Date @scalarType(scalar: "graphql.scalars.ExtendedScalars.Date")
If graphql-java-extended-scalars is on the consumer’s compile classpath and the SDL name matches one of the entries in graphitron’s convention table (e.g. UUID, Date, DateTime, BigDecimal, Url, Json), the directive is optional: a bare scalar UUID resolves automatically. The directive wins over the convention layer, so a consumer who imports extended-scalars but wants BigDecimal modeled as their own Money wrapper just writes scalar BigDecimal @scalarType(scalar: "com.example.Scalars.MONEY").
Every scalar reference in the generated schema uses GraphQLTypeReference.typeRef(name); graphql-java resolves the reference against the registered GraphQLScalarType at build time. An unresolved scalar fails the build with a validation error pointing at @scalarType(scalar:) or the extended-scalars convention as the fix; there is no silent fallback to Object.
Migration. Consumers upgrading from earlier rewrite snapshots must remove any manual .additionalType(ExtendedScalars.GraphQLBigDecimal) (or other graphitron-resolved scalar constant) calls from their buildSchema(…) hooks. graphql-java’s GraphQLSchema.Builder.additionalType rejects duplicate type-name registrations at build time, so leaving the manual call in turns the upgrade into a SchemaProblem rather than silent tolerance.
See the @scalarType directive reference for the full surface, including the per-arm rejection reasons (FieldNotFound, CoercingErased, etc.) and the LSP completion behavior on @scalarType(scalar: |).
Federation
When your SDL declares an @link to a federation spec,
Graphitron.buildSchema(…) returns the federation-wrapped schema directly:
GraphQLSchema schema = Graphitron.buildSchema(b -> {});
// Federation directives, `_Service.sdl`, and `_entities` are wired automatically.
Do not wrap the result in Federation.transform(…) yourself; Graphitron has
already done it, and a second wrap would double-add _Service / _Entity and
break composition. Schemas without a @link skip the wrap entirely and behave
exactly as before.
For every type Graphitron classifies, _entities resolution is wired
automatically: @node types resolve via the NodeId path; types with a
@key directive resolve via a column-value lookup. Both share the same
per-type batched SELECT, so no extra wiring is needed beyond the existing
buildSchema(b → {}) call.
If you have entity types Graphitron does not classify (hand-rolled objects, or
types pulled in from a non-Graphitron source), supply your own fetchEntities
via the two-arg form:
GraphQLSchema schema = Graphitron.buildSchema(
b -> {},
fed -> fed.fetchEntities(myCustomFetcher));
The federation builder arrives pre-configured with Graphitron’s defaults; the
customizer replaces on top. Custom fetchers must return entities the default
resolveEntityType can recognise (jOOQ Record`s with a `__typename column);
richer type-resolution shapes are not currently supported.
Build-time federation directives
Two ways to declare federation directives in your SDL:
Option 1, recommended. Open one of your .graphqls files with Apollo
Federation 2’s @link:
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.10",
import: ["@key", "@shareable", "@inaccessible", "@override", "@tag"]
)
Pick whichever v2.x URL covers the directives you import; the generator
delegates to federation-graphql-java-support, which tracks the Apollo spec
and gates directives on their minimum version.
Option 2, manual. If you do not want @link, declare each directive you use:
directive @key(fields: String!, resolvable: Boolean = true) repeatable
on OBJECT | INTERFACE
directive @shareable on OBJECT | FIELD_DEFINITION
Mixing the two on the same directive (importing via @link and redeclaring it)
fails validation; pick one per directive.
<schemaInput tag> is federation-specific. If you set
<schemaInput><tag>…</tag></schemaInput> in the plugin config, the resulting
@tag(name: "…") directives are only meaningful to a federation gateway. The
plugin treats this as an implicit opt-in to Federation 2:
-
If you have not declared
@link, the plugin synthesises one withimport: ["@tag"]for you. Nothing else changes; you can still author@key/@shareable/ etc. with manual declarations if you want. -
If you have declared
@linkbut"@tag"is missing from yourimportlist, the build fails with a fatal error pointing at your@link. Add"@tag"to clear it. -
If
<schemaInput tag>is unset, the plugin makes no federation choice on your behalf.
How this is wired (for contributors)
This subsection is contributor-level material; consumers can skip it. It explains why the consumer-facing surface above is shaped the way it is, what code carries the wrap, and where the seams live for anyone extending federation handling.
@link is the opt-in. Graphitron.buildSchema(…) checks the parsed SDL for an @link to a federation spec; if present, it routes through federation-graphql-java-support to wrap the schema with the _Service.sdl field, the _Entity union, and the _entities resolver. If absent, the build skips the wrap entirely and emits a vanilla schema. A consumer who wants federation just adds the @link; a consumer who doesn’t gets no federation surface, no _entities, no _Service. The opt-in is the SDL declaration, nothing else.
<schemaInput tag> is the second federation entry point. It exists because @tag(name: "…") directives are only meaningful to a federation gateway, and a consumer setting tag values has implicitly committed to federation 2. The plugin synthesises an @link with import: ["@tag"] if none was declared, fails the build if @link is declared but "@tag" is missing from import, and stays out of the way if neither is set. The decision lives in the plugin so it’s visible at build configuration; the runtime never sees the synthesis logic.
Graphitron.buildSchema does the wrap, not the consumer. A second Federation.transform(…) call by the consumer would double-add _Service and _Entity and break composition; the contract is the consumer never wraps. The two-arg form takes a federation customizer (fed → fed.fetchEntities(…)) for cases where a consumer needs to override fetchEntities for hand-rolled entity types.
The fetchEntities seam lives where it does because the default fetcher only knows about Graphitron-classified types: @node types resolve via the NodeId path, types with a @key directive resolve via column-value lookup, and both share the same per-type batched SELECT. Anything outside that classification surface (hand-rolled objects, types from a non-Graphitron source) needs a custom fetcher; the customizer lets the consumer plug one in without touching `buildSchema’s wiring.
sequenceDiagram
participant SDL as .graphqls SDL
participant Build as Graphitron.buildSchema
participant Wrap as federation-graphql-java-support
participant Engine as graphql-java engine
participant Fetch as _entities resolver
participant DB as PostgreSQL
SDL->>Build: parse + classify
alt @link present
Build->>Wrap: wrap(schema, fetchEntities)
Wrap-->>Build: federation-wrapped schema<br/>(adds _Service.sdl, _entities)
else no @link
Build-->>Engine: vanilla schema (no federation surface)
end
Build-->>Engine: ready
Engine->>Fetch: _entities([{__typename, key…}, …])
alt @node type
Fetch->>DB: SELECT by NodeId-decoded keys (batched per type)
else @key type
Fetch->>DB: SELECT by key-column tuples (batched per type)
else custom fetcher (fed.fetchEntities)
Fetch->>Fetch: consumer-supplied resolver
end
DB-->>Fetch: rows
Fetch-->>Engine: typed results
Tenant-scoped DSLContext
Per-tenant DSLContext routing is the subject of R45’s tenant-column work; the recipe is being rewritten alongside the <tenantColumn> Mojo configuration, the @tenantId argument directive, and the byTenant factory overload. Until that lands, build the right DSLContext at request entry (one connection per tenant, session variables, RLS-aware Configuration) and pass it to Graphitron.newExecutionInput(perTenantDsl, …) for the request.
Context arguments from a JWT claim
Directives like @service(contextArguments: ["userId"]) reflect into the schema-driven factory as typed parameters. Pull the claim from the request at servlet bootstrap and pass it as the matching factory slot:
String userId = verifiedClaims.get("sub"); // JAX-RS / Quarkus / servlet entry
ExecutionInput input = Graphitron.newExecutionInput(dsl, userId)
.query(query)
.build();
The factory’s parameter order follows the SDL’s contextArguments list (alphabetical), and each slot’s Java type is reflected from the developer method’s signature. A typo in contextArguments: becomes a compile error at this call site (typed-but-unfamiliar parameter name in the factory’s signature), not a silent null at request time. The generator emits the read-back site ((String) graphitronContext(env).getContextArgument(env, "userId")) so the value flows through to your @service or @condition method unchanged.
Customizer safe surface
Graphitron.buildSchema(Consumer<GraphQLSchema.Builder>) hands you the
underlying builder. Use additive methods only:
-
.additionalType(…)for additional object or input types beyond the reachable set. Custom scalars go through@scalarType(scalar:)on the SDL declaration; Graphitron registers the constant for you, and a manualadditionalTypeof the same scalar is rejected as a duplicate. -
.additionalDirective(…)for directives beyond the schema’s declared survivors. -
.codeRegistry(UnaryOperator<GraphQLCodeRegistry.Builder>)for registering type resolvers on user-defined interfaces / unions.
Avoid:
-
.query(…),.mutation(…),.subscription(…): Graphitron has already set them. -
.clearDirectives(): drops the survivor definitions Graphitron emitted. -
The replace overload
.codeRegistry(GraphQLCodeRegistry): use theUnaryOperatoroverload to add resolvers to the in-flight registry.
Dev loop
What you do
Edit your .graphqls source files, then run mvn generate-sources (or let
your build tool re-trigger it). For a hands-off loop with editor integration,
run mvn graphitron:dev in a side terminal: see Dev loop
below.
What the generator does
Every run walks the full schema, renders each output file, and then:
-
Writes only the files whose rendered content differs from what is already on disk (SHA-256 comparison inside javapoet).
-
Deletes any
*.javafile in the six rewrite-owned sub-packages (<outputPackage>,.util,.schema,.types,.conditions,.fetchers) that this run did not emit.
Both steps happen unconditionally on every run; no flag is required.
What you observe
-
git diffis proportional. A schema edit that touches one type rewrites that type’s files; every other generated file is byte-identical to the previous run and unchanged on disk, sogit diffshows only the types the edit actually touched. -
IDE recompile time is proportional. IntelliJ’s incremental compiler and Quarkus
quarkus:devand Spring Boot DevTools all watchtarget/generated-sources/for mtime changes. Because unchanged files keep their mtimes, only the files that actually changed trigger a recompile. -
Removing a type removes its file. Deleting a type or field from the schema causes the corresponding generated file to disappear on the next generator run. No orphan code, no stale compile errors, no manual
target/cleanup.
These three properties are guaranteed by the generator and pinned by
IdempotentWriterTest and GeneratorDeterminismTest; a future refactor
that breaks any of them fails the test suite.
Tool interop
No Graphitron-specific IDE plugin or tool integration is required. The behaviour composes with standard incremental-compile paths:
-
IntelliJ IDEA: incremental compiler re-examines files whose mtime changed; unchanged generated files are invisible to it.
-
Quarkus
quarkus:dev: detects source-root changes by mtime; only the changed files trigger a reload. -
Spring Boot DevTools: same mtime-based detection; unchanged files are ignored.
Dev loop
Run mvn graphitron:dev in a side terminal. One JVM, one terminal,
one socket: the goal binds an LSP server on localhost:8487 and watches
your .graphqls files. Saving a schema regenerates the affected Java
sources under target/generated-sources/graphitron; only changed files
touch disk. When your editor is attached to the LSP its save event drives
the same regen directly, so on every platform you see the edit reflected
without waiting for the filesystem watcher.
Connect your editor or agent to the LSP by pointing its LSP client at
localhost:8487 (TCP). The default port is the only number you need; it
stays the same across sessions, machines, and projects, so each editor
needs one one-time configuration line.
If 8487 is already in use (another dev session, an unrelated service),
pass -Dgraphitron.dev.port=N to pick a different port, and use the
matching port in the editor config. The Mojo fails fast on bind conflict
with a message naming the override property; no silent rebind to a
different port the editor would not know about.
What the loop does on each schema save:
-
Re-runs the generator. Idempotent writes mean only the files whose rendered content actually changed are written; the IDE recompiles only those classes (same three-clause contract pinned by
IdempotentWriterTestandGeneratorDeterminismTest). -
Validation failures land in two places: the console gets the grouped per-file tree, and the LSP delivers the same errors as editor diagnostics on the open buffer. The loop keeps running so a typo does not kill the session; the editor’s squiggle and the console’s tree carry the same
(file, location, message)triple. The editor softens deferred-variant rejections from error to warning (the schema is valid, the generator just has not shipped the path yet); everything else uses the same severity as the build log. -
Open editor buffers see refreshed diagnostics on the next request.
The dev goal also watches the consumer’s compiled jOOQ output
(target/classes/<jooqPackage>/) for .class changes. Running
mvn compile in another terminal after a jOOQ schema change picks up
new tables and columns automatically; no dev-session restart needed.
The same trigger also refreshes validation: rename a Java class that an
@service directive points at, then mvn compile the consumer, and
the editor surfaces the unresolved-class error without waiting for a
schema save.
Stop the loop with Ctrl+C (a JVM shutdown hook closes the LSP socket, the watch service, and the debounce executor cleanly).
How this is wired (for contributors)
This subsection is contributor-level material; consumers can skip it. It explains the runtime framing of the dev Mojo: which components live in one JVM, what each watches, and why the seams sit where they do for anyone extending the dev-loop surface.
The dev goal runs four cooperating components in one JVM:
-
LSP server binds the TCP port (default
8487) and speaks LSP to whatever editor or agent connects. It serves diagnostics, hover, completion, and go-to-definition off the most recent classifiedGraphitronSchemaand the validator’s report on it. State is in-memory only; no LSP cache lives on disk. ItsdidSavenotification is the primary fast path into the generator dispatch when an editor is attached. -
Schema watcher is a
WatchServiceover the consumer’s.graphqlssource roots; the headless fallback when no editor is attached. On a save, it debounces same-file events that arrive in clusters (some editors emit severalMODIFYevents per save) and signals the generator dispatch. The LSPdidSavepath feeds the same debounce, so the two routes coalesce on a single regen when both fire. -
Classpath watcher is a
WatchServiceover the consumer’s compiled jOOQ output (target/classes/<jooqPackage>/). Whenmvn compilein another terminal lands new.classfiles for jOOQ tables and columns, the watcher signals the generator dispatch the same way a schema save does. This is what lets a jOOQ schema regen pick up automatically without adev-session restart. -
Generator dispatch is the lifecycle thread that consumes wake-up events from either watcher, loads the (possibly-updated) classpath, runs the generator over the (possibly-updated)
.graphqlssources, and feeds the resultingGraphitronSchemaand emit results to the LSP server’s in-memory state and to disk via the idempotent writer.
One JVM, one socket, one process tree. There’s no daemon, no client/server split, no shared cache directory. The Mojo binds, watches, regenerates, and serves; on Ctrl+C the JVM shutdown hook closes the LSP socket, the WatchService instances, and the debounce executor cleanly.
The idempotent-write coupling is what makes the loop usable as an editor backend. Because JavaFile.writeToPath writes only files whose rendered content actually changed (SHA-256 comparison) and deletes orphans in rewrite-owned sub-packages, a schema edit that touches one type rewrites that type’s files and leaves every other generated file byte-identical on disk. The IDE’s incremental compiler, Quarkus quarkus:dev, and Spring Boot DevTools all detect changes by mtime; unchanged files keep their mtimes, so only the actually-changed files trigger an IDE recompile. Two effects fall out: editor latency is proportional to the edit (not to the schema size), and git diff after a dev session shows only what a human-readable summary of the schema edit would predict.
flowchart TD
subgraph JVM["dev JVM (one process)"]
Editor["editor / agent"] -. LSP/TCP :8487 .-> LSP["LSP server"]
LSP -- didSave<br/>(primary) --> Disp["generator dispatch"]
SW["schema watcher<br/>(.graphqls,<br/>headless fallback)"] -- debounced<br/>save events --> Disp
CW["classpath watcher<br/>(target/classes/<jooq>)"] -- .class change<br/>events --> Disp
Disp -- run --> Gen["generator<br/>(classify + emit)"]
Gen -- in-memory<br/>GraphitronSchema --> LSP
Gen -- emit results --> Writer["JavaFile.writeToPath<br/>(idempotent + orphan sweep)"]
Writer -- only-changed<br/>files written --> Sources["target/generated-sources/graphitron"]
end
Sources -- mtime change --> IDE["IDE recompile<br/>(IntelliJ / Quarkus / DevTools)"]
Native runtime dependency
mvn graphitron:dev runs an LSP server backed by a tree-sitter-based GraphQL
parser. The grammar binary itself ships with graphitron-lsp via the
no.sikt:graphitron-tree-sitter-natives jar, but the tree-sitter runtime
(libtree-sitter) is sourced from the consumer’s OS.
Required version: libtree-sitter 0.25.0 or later (jtreesitter 0.26’s
minimum ABI). Anything older — including Debian/Ubuntu apt’s libtree-sitter0
package, which is still pinned to 0.20.x — fails with
Symbol not found: ts_language_abi_version at LSP startup.
graphitron-lsp probes well-known install prefixes at LSP startup (Apple-silicon
Homebrew’s /opt/homebrew/lib, Intel Homebrew’s /usr/local/lib, vcpkg’s
<VCPKG_ROOT>/installed/x64-windows/bin and the default C:\vcpkg... layout,
and Linux /usr/local/lib) and composes a system-installed libtree-sitter
into jtreesitter’s SPI lookup if it finds one. The intent: a brew install
tree-sitter on macOS or vcpkg install tree-sitter:x64-windows on Windows
should just work, no env-var wiring needed. The table below covers each
supported install path plus the fallbacks for non-default layouts where the
probe can’t find the library.
| Platform | Install + library-discovery |
|---|---|
macOS (Apple silicon or Intel, Homebrew) |
|
Windows (vcpkg) |
|
NixOS |
Add |
Linux (Arch, Fedora) |
|
Linux (Debian/Ubuntu) |
apt’s |
Non-default install location (any platform) |
If you installed |
If the runtime is missing when the LSP starts, graphitron-lsp surfaces a
startup error naming the install command rather than an opaque
UnsatisfiedLinkError. The runtime is a one-time install per machine; the
LSP picks up future graphitron-lsp version bumps without further setup.
Supported host architectures are linux-x86_64, linux-aarch64,
macos-aarch64, and windows-x86_64. Intel-Mac (macos-x86_64) is not
shipped.
NixOS shell.nix snippet
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = [ pkgs.maven pkgs.jdk25 pkgs.tree-sitter ];
# graphitron-lsp resolves libtree-sitter via jtreesitter's chain at runtime;
# NixOS doesn't put nix-store libs on the default dynamic-loader path,
# so make the .so discoverable for the JVM in this shell.
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.tree-sitter}/lib:$LD_LIBRARY_PATH
'';
}
Notes
-
The app no longer loads an SDL resource at runtime; everything the engine needs is in the programmatic schema. If you need an SDL string (e.g., for logging or debugging), call
new SchemaPrinter().print(schema). -
DataLoaderRegistryis per-request. graphql-java requires one onExecutionInputeven when no DataLoader is used;Graphitron.newExecutionInput(…)pre-attaches an empty one. Chain.dataLoaderRegistry(custom)to override (graphql-java’s replace overload swaps the factory’s default).