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 with import: ["@tag"] for you. Nothing else changes; you can still author @key / @shareable / etc. with manual declarations if you want.

  • If you have declared @link but "@tag" is missing from your import list, 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 manual additionalType of 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 the UnaryOperator overload 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:

  1. Writes only the files whose rendered content differs from what is already on disk (SHA-256 comparison inside javapoet).

  2. Deletes any *.java file 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 diff is 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, so git diff shows only the types the edit actually touched.

  • IDE recompile time is proportional. IntelliJ’s incremental compiler and Quarkus quarkus:dev and Spring Boot DevTools all watch target/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:

  1. 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 IdempotentWriterTest and GeneratorDeterminismTest).

  2. 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.

  3. 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 classified GraphitronSchema and the validator’s report on it. State is in-memory only; no LSP cache lives on disk. Its didSave notification is the primary fast path into the generator dispatch when an editor is attached.

  • Schema watcher is a WatchService over the consumer’s .graphqls source roots; the headless fallback when no editor is attached. On a save, it debounces same-file events that arrive in clusters (some editors emit several MODIFY events per save) and signals the generator dispatch. The LSP didSave path feeds the same debounce, so the two routes coalesce on a single regen when both fire.

  • Classpath watcher is a WatchService over the consumer’s compiled jOOQ output (target/classes/<jooqPackage>/). When mvn compile in another terminal lands new .class files 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 a dev-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) .graphqls sources, and feeds the resulting GraphitronSchema and 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/&lt;jooq&gt;)"] -- .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)

brew install tree-sitter. graphitron-lsp probes /opt/homebrew/lib and /usr/local/lib automatically, so no env-var wiring is needed if Homebrew installed to its standard location.

Windows (vcpkg)

vcpkg install tree-sitter:x64-windows. graphitron-lsp picks up the DLL from $VCPKG_ROOT/installed/x64-windows/bin/tree-sitter.dll (or the legacy $VCPKG_INSTALLATION_ROOT variable, or the default C:\vcpkg... layout) automatically. If vcpkg’s bin directory is on PATH, that also works through jtreesitter’s normal loader chain.

NixOS

Add tree-sitter to your shell inputs and either export LD_LIBRARY_PATH=$\{pkgs.tree-sitter\}/lib from a shellHook or set JAVA_TOOL_OPTIONS="-Djava.library.path=$\{pkgs.tree-sitter\}/lib"; see NixOS shell.nix below. Nix-store paths are content-hashed so probing isn’t tractable; the shellHook is the standard NixOS pattern. Nixpkgs tree-sitter is 0.25.x (within jtreesitter 0.26’s compat window).

Linux (Arch, Fedora)

pacman -S tree-sitter / dnf install tree-sitter. Both ship 0.26.x and install into the system loader path; no env-var wiring needed.

Linux (Debian/Ubuntu)

apt’s libtree-sitter0 is pinned to 0.20.x, which is too old. Build from source: git clone --depth=1 --branch=v0.26.9 https://github.com/tree-sitter/tree-sitter /tmp/ts && make -C /tmp/ts && sudo make -C /tmp/ts install && sudo ldconfig. /usr/local/lib is both on ldconfig’s cache and on graphitron-lsp’s probe list; no `JAVA_TOOL_OPTIONS wiring needed.

Non-default install location (any platform)

If you installed libtree-sitter somewhere the probe doesn’t cover, set JAVA_TOOL_OPTIONS="-Djava.library.path=/your/lib/dir" in your shell rc. This is the most robust env-var-only escape hatch across platforms because the JVM reads it at startup before any native-lookup work runs — unlike DYLD_LIBRARY_PATH it doesn’t trip macOS’s SIP stripping, and unlike LD_LIBRARY_PATH it survives sudo boundaries.

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).

  • DataLoaderRegistry is per-request. graphql-java requires one on ExecutionInput even 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).