Skip to content

How graphql java tools builds a schema

Oryan edited this page Oct 26, 2026 · 1 revision

graphql-java-tools turns a GraphQL schema written in SDL, plus your own Java or Kotlin classes, into an executable graphql-java GraphQLSchema. You don't write data fetchers or type wiring. The library finds the class that backs each GraphQL type and the method or property that backs each field, then generates the data fetchers for you. All of this happens once, when you build the schema. A field with no matching method, or a type used with two different classes, fails at startup instead of at query time.

The rest of this page follows one small schema through each step:

type Query {
	books: [Book!]!
}

type Book {
	title: String!
	author: Author!
}

type Author {
	name: String!
}
class Query : GraphQLQueryResolver {
	fun books(): List<Book> = bookRepository.findAll()
}

class BookResolver : GraphQLResolver<Book> {
	fun author(book: Book): Author = authorRepository.find(book.authorId)
}

data class Book(val title: String, val authorId: Long)
data class Author(val name: String)

val schema = SchemaParser.newParser()
	.file("schema.graphqls")
	.resolvers(Query(), BookResolver())
	.build()
	.makeExecutableSchema()

1. Parse the SDL

SchemaParserBuilder reads each .file(...) from the classpath and each .schemaString(...), and parses them with graphql-java's parser. Each TypeDefinitionFactory in SchemaParserOptions can then add definitions. The default one, RelayConnectionFactory, generates the Connection and Edge types for fields marked @connection.

2. Start from the root types

SchemaClassScanner discovers classes by following method signatures, so it needs a class to start from. The root resolvers are that start. The scanner reads the root type names from the schema { ... } block, or defaults to Query, Mutation, and Subscription. Resolvers that implement GraphQLQueryResolver, GraphQLMutationResolver, or GraphQLSubscriptionResolver back those types. Every other resolver implements GraphQLResolver<T> and adds fields to the GraphQL type whose class is T.

3. Find a resolver for each field

For each field of a type, FieldResolverScanner looks for a method that can produce its value. It searches the resolvers for the type's class first, then the class itself. For Book.author, it searches BookResolver, then Book.

A method on a GraphQLResolver takes the source object as its first parameter, as in author(book: Book). A method on the class itself does not. The next parameters match the field's GraphQL arguments, in order. A method can end with a DataFetchingEnvironment, a GraphQLContext, or the configured contextClass.

For a field named title, the scanner tries these method names in order:

  1. title
  2. isTitle, only when the field is a Boolean
  3. getTitle
  4. getFieldTitle
  5. get plus the camelCase form of a snake_case name, so the field key_ops matches getKeyOps

If no method matches and the field has no arguments, the scanner looks for a Java field of the same name. A class that implements Map resolves every field by key. If nothing matches, the build fails with a FieldResolverError that lists each signature it tried. A root field that matches on more than one root resolver also fails the build.

4. Match each GraphQL type to a class

Each field resolver from step 3 pairs Java types with GraphQL types. The method's return type pairs with the field's type, and each parameter type pairs with the type of its argument. TypeClassMatcher unwraps both sides of a pair until a named GraphQL type faces a single class:

  • On the Java side, it unwraps CompletableFuture, CompletionStage, Future, Publisher, DataFetcherResult, Optional, and any generic wrapper you register in SchemaParserOptions. It also resolves type variables and boxes primitives.
  • On the GraphQL side, it drops !. A list type [T] needs an Iterable or an array, and the element type then pairs with T.

books(): List<Book> against [Book!]! gives the pair Book and Book. The scanner records each pair in its dictionary. If a GraphQL type turns up with two different classes, the build fails with a SchemaClassScannerError that names both classes and where each came from. Scalars don't get a class. The scanner only checks that you passed a GraphQLScalarType for each custom scalar.

5. Repeat until nothing new turns up

Each newly matched object type goes into a queue. Scanning it runs step 3 on its fields, which can match more types, which join the queue in turn. In the example, Query leads to Book, and BookResolver.author leads to Author. Each newly matched input type gets its fields matched against the properties of its class.

Some classes never appear in a method signature. A field that returns an interface is backed by a method that returns the Java interface, so the classes of the implementing types stay unknown. The members of a union have the same problem. Pass those classes with .dictionary(...), or the build fails and names the type. For a Kotlin sealed class, the scanner finds the subclass whose name matches the GraphQL type on its own.

6. Validate what the scan found

Once the queue is empty, the scanner logs a WARN for each schema type that no field can reach, each dictionary entry it never used, and each resolver whose methods back no field. A custom scalar you didn't pass fails the build.

7. Build the graphql-java schema

SchemaParser.makeExecutableSchema() creates the graphql-java types and registers a data fetcher for every field. Each interface and union gets a type resolver. At query time, that type resolver looks up the class of the returned object in the dictionary, then its superclasses and interfaces, and falls back to the class's simple name. Directive wirings run last, on the assembled types.

What a data fetcher does at query time

Each data fetcher calls the method or reads the property that step 3 found. For a method on a GraphQLResolver, it passes the source object first. It converts each argument to its parameter type with Jackson, then appends the environment or the context. It runs a Kotlin suspend function as a coroutine and returns a CompletableFuture. graphql-java waits on the futures that resolvers return, so you can mix blocking and async methods in one schema.