GraphQL Framework Core Concepts
Schema Names
Designing a GraphQL schema involves choosing a lot of names -- all your types must have a name, as must all the fields on those types. Naming is hard. When your schema is dynamic, and type and field names are derived from other systems, it gets even harder. When those dynamic names have the potential to be in conflict with one another, the problem becomes complex enough that a specialized solution is required.
Rather than taking the naive approach of representing a schema name as a String, the framework has an interface called SchemaName. The signature is as follows:
1public interface SchemaName {23String getSchemaNameId();45List<String> getSchemaNames();67default String getAutoIncrementSchemaName();8}
The key change here is a distinction between a schema name and a schema name ID. The schema name ID is and must be a unique identifier within its current scope. Per the GraphQL spec, the scope for type names is the entire schema, in that no two types may have the same name. For field names, they must only be unique within the type in which they were defined. These same constraints apply to the schema name ID returned from the #getSchemaNameId() method, but value of the ID is only used internally and can have any value that you want provided it's unique.
Schema names, however, should have values safe for use within a GraphQL schema. The SchemaName interface supports a list of names rather than a single value as a conflict resolution measure. You can essentially define fallback names in case a name is already taken. The system will loop through the names returned from #getSchemaNames in order, until it finds a name that's available.
When the list of potential names is exhausted, the system will consult the #getAutoIncrementSchemaName() method as a last ditch effort to find a name that is not already taken. If the name returned from that method is null then the system will throw an exception, and the schema will fail to load. If a non-null name is returned, the system will check its availability, and if unavailable, will continually retry by appending an auto-increment number to the end of it until an opening is found. The default implementation returns the first name in the #getSchemaNames list, therefore, by default the schema will never fail to load due to naming conflicts. Implementers can of course create that possibility by overriding it and returning null.
To manage the collection of schema names registered within a particular scope, the system uses a SchemaNameRegistry. It contains APIs for registering and unregistering schema names, and even bulk registering a collection of names at once. There are also APIs for checking to see which names have already been registered, and for doing lookups based on name or ID. The schema name registry also ensures that registered names conform to the GraphQL specification regarding naming. If you attempt to register a name with invalid characters, the registry will strip them out. Given that, it's still possible that even with a non-null, non-empty list returned from #getSchemaNames, registration could still fail if it's unable to convert any of the names into a GraphQL spec compliant name, such as the case with names containing only invalid characters.
For type names, there exists a sub-interface called TypeName that extends SchemaName. While it does contain static utility methods specific to types, it does not introduce any new interface methods, rather it serves as a clear indicator in API signatures that a SchemaName is for a type, not a field.
Schema Loader
As the name suggests, a schema loader is responsible for loading a GraphQL schema into memory. Once complete, a GraphQLApiEndpoint can begin to service requests.
1public interface GraphQLSchemaLoader {23GraphQLSchema loadSchema();4}
Looking at the base GraphQLApiEndpoint class however, it actually makes no reference to the GraphQLSchemaLoader. Its only requirement is to provide a GraphQLSchema.
1public abstract class GraphQLApiEndpoint extends ApiEndpoint {23public abstract GraphQLSchema getSchema();4}
Because loading a schema can be resource and time intensive (order of seconds), we want to make sure that work is not being performed on every request. By extending AbstractGraphQLApiEndpoint instead, the deferred loading and resultant caching of the schema is handled for you, along with a few other helpful functions like tracking schema history, and schema load result metadata (e.g. load time, warnings, errors, etc.).
1public abstract class AbstractGraphQLApiEndpoint extends GraphQLApiEndpoint {23protected abstract GraphQLSchemaLoader getSchemaLoader();45@Override6public final GraphQLSchema getSchema() {7// Gets the schema from the schema loader initially, but returns a8// cached result thereafter.9}
Now, calls to #getSchema on each request are blazing fast, and the GraphQLEndpoint can then delegate to #processQuery to fulfill the request.
1public interface GraphQLSchema {23GraphQLApiResponse processQuery(GraphQLApiRequest request);45String print();6}
Similar to GraphQLApiEndpoint, it's not expected that custom implementations implement GraphQLSchemaLoader directly, rather they should extend GraphQLJavaSchemaLoader which provides a framework for defining your schema. The choice in name stems from the fact that it's built using the graphql-java library, however most implementers do not need to know the specifics of how that library works as its APIs are only exposed in a select few cases (e.g. custom scalars), and the eventual goal is to hide it completely . If you find yourself importing a class from that library (e.g. package graphql.*), it is likely a sign you are doing something wrong!!
The GraphQLJavaSchemaLoader has a few required methods to implement, but the most important one is getRootQueryType.
1public abstract class GraphQLJavaSchemaLoader<C extends SchemaContext>2implements GraphQLSchemaLoader {34protected abstract OutputObjectType<? super C, ?> getRootQueryType();56// ... more required methods ...7}
This is the entry point to schema loading. It starts by getting the root schema's query field return type.
1schema {2query: #getRootQueryType3}
It then asks for all the interfaces implemented by the type, and recurses to create those types. It then asks for the list of fields on the type, and then recurses to create all the types returned by those fields. It follows that process until there are no new types being introduced in the schema. This algorithm for type discovery and inclusion is meant to be deterministic, and that order is reflected in the resulting SDL (the String returned by the GraphQLSchema#print API). This determinism is important for the purposes of tracking schema version history because a hash of that SDL output is used to detect when the schema changes. As such, it's important to define your types and fields in a deterministic way.
Schema Context
The SchemaContext is an object containing additional metadata and business logic related to a specific schema loader instance. It gets passed around to practically all the APIs and hooks that are made available to you as an implementer of a custom schema.
1public interface SchemaContext {23GraphQLJavaSchemaLoader<? extends SchemaContext> getLoader();4}5
The only API defined by default is #getLoader, giving you direct access to the schema loader, and subsequently anything available on the schema loader (e.g. schema settings, the type name registry, etc.).
The schema context is available during schema load, and also during request query execution, so rather than crowding your schema loader implementation with additional methods, define them on the schema context, which creates a clear separation of concerns between the two. In general, you should not need to access the schema loader after the schema has already loaded, but is there if absolutely necessary.
Even if you do not have an immediate need for it, it's recommended to create a custom implementation upfront (unlike the Hello World example) so that it's there when you do. Your custom implementation is also the key to including some of the built-in types in your schema as they have certain requirements that must be satisfied by way of implementing type specific schema context sub-interfaces. For example, if you want built-in Marked Text support, your SchemaContext implementation must implement the MarkedTextSchemaContext interface, which has additional required methods in order for those types to function.
The simplest and most generic SchemaContext implementation might look like the following:
1import com.psddev.graphql.schema.GraphQLJavaSchemaLoader;2import com.psddev.graphql.schema.SchemaContext;34public class MySchemaContext implements SchemaContext {56private final GraphQLJavaSchemaLoader<? extends SchemaContext> loader;78public MySchemaContext(GraphQLJavaSchemaLoader<? extends SchemaContext> loader) {9this.loader = loader;10}1112@Override13public GraphQLJavaSchemaLoader<? extends SchemaContext> getLoader() {14return loader;15}16}
However, you can safely couple your schema context more tightly to your schema loader like this:
1public class MyGraphQLSchemaLoader extends GraphQLJavaSchemaLoader<MySchemaContext> {23@Override4public MySchemaContext getContext() {5return new MySchemaContext(this);6}78// ... more required methods...9}
1import com.psddev.graphql.schema.SchemaContext;23public class MySchemaContext implements SchemaContext {45private final MyGraphQLSchemaLoader loader;67public MySchemaContext(MyGraphQLSchemaLoader loader) {8this.loader = loader;9}1011@Override12public MyGraphQLSchemaLoader getLoader() {13return loader;14}15}
This makes it so if you do need to access your schema loader from your schema context, it's already cast to the correct type.
Schema Load Context
While the SchemaContext is accessible everywhere all the time, the LoadContext is only available during schema load. More importantly, when you implement or override a method in the framework and one of the arguments extends LoadContext (e.g. InterfaceTypeGenerator#getFields(TypeLoadContext<C>)), that is a signal that the method gets executed during schema load. It's possible for a schema to get loaded in a background task so APIs such as the current WebRequest may not be available, so your code should be cognizant of that fact. There is no specific reference to LoadContext in GraphQLJavaSchemaLoader methods because all of them are only executed during schema load.
There are two main subclasses of LoadContext, TypeLoadContext and FieldLoadContext.
TypeLoadContext, as the same suggests, is made available during the load of a specific GraphQL type. In addition to the schema context and schema loader, the TypeLoadContext gives you access to the RegisteredSchemaName of the current type via TypeLoadContext#getRegTypeName. The RegisteredSchemaName is a wrapper around a SchemaName that contains the final, resolved GraphQL Name for that type as it will appear in the schema in addition to the TypeName used with the TypeGenerator. This can be useful for generating dynamic descriptions, or dynamic field names that are derivatives of the parent type.
1public class TypeLoadContext<C extends SchemaContext> {2public C getSchemaContext();3public RegisteredSchemaName getRegTypeName();4public GraphQLJavaSchemaLoader<? extends SchemaContext> getLoader();5}
FieldLoadContext, as the same suggests, is made available during the load of a specific field on a GraphQL type. It provides access to all the same data as TypeLoadContextas well as theRegisteredSchemaName` of the current field.
1public class FieldLoadContext<C extends SchemaContext> {2public C getSchemaContext();3public RegisteredSchemaName getRegTypeName();4public RegisteredSchemaName getRegFieldName();5public GraphQLJavaSchemaLoader<? extends SchemaContext> getLoader();6}
Schema Execution Context
While the SchemaContext is accessible everywhere all the time, the ExecutionContext is only available during the execution of a GraphQL request. At this point the schema is loaded, all types and fields have been registered, and any attempt at altering the schema during this time will result in an exception. When you implement or override a method in the framework and one of the arguments extends ExecutionContext (e.g. InterfaceTypeGenerator#resolveTypeNameId(TypeResolveContext<C> context, S source)), that is a signal that the method gets executed during query execution, and APIs like WebRequest will be available.
There are several different types execution contexts, which will be discussed in various relevant subsequent sections, but the most important one is the DataFetchContext which is available during data fetching operations which will be discussed in the next section.
Data Fetching
At its core, GraphQL is about requesting precisely the data you need. The process of retrieving this data from underlying sources—such as databases, APIs, or other services—is known as data fetching. Every field in a GraphQL query corresponds to a resolver function responsible for fetching and returning the appropriate data. These resolvers form the backbone of any GraphQL execution, enabling flexible, efficient, and structured data retrieval.
You can think about every type in the GraphQL schema as having a corresponding Java type that's required for that type to function. When it's time to fetch the data for each of the type's fields, that Java object is in context to serve as the data source and API. Similarly, when you fetch a field, the value you are returning from the resolver function is the Java object compatible with that field's return type.
Take the following schema as an example:
1schema {2query: Query3}45type Query {6article(id: String): Article7articles: [Article!]!8}910type Article {11headline: String12author: Author13body: String14}1516type Author {17name: Name18address: String19}2021type Name {22first: String23last: String24}
The quick summary of this contrived schema is that the root query type, Query, has two fields, one to fetch a single Article by ID and another to fetch all of them. The Article has a few String fields and an Author field. The Author type has a name field that is yet another type Name that distinguishes the first and last name.
Now imagine you have the Brightspot content types, Story and Person. They are effectively the same as the Article and Author types in the schema, but we've renamed them to make it easier to distinguish between the two.
1public class Story extends Content {2String title;3Person writer;4}
1public class Person extends Content {2String name;3String address;4}
When you are defining a type in your schema, you have to designate some Java type to be paired with it. This is part of the schema design process and is up to you to figure out the best way to go about that. We've repeated the schema below, but added fake directives to each type to conceptualize those pairings. In addition, we've added them to the fields where the value is determined by the field's return type.
12@Java(type: "Object")3schema {4query: Query @Java(type: "Object")5}67@Java(type: "Object")8type Query {9article(id: String): Article @Java(type: "Story")10articles: [Article!]! @Java(type: "List<Story>")11}1213@Java(type: "Story")14type Article {15headline: String @Java(type: "String")16author: Author @Java(type: "Person")17body: String @Java(type: "String")18}1920@Java(type: "Person")21type Author {22name: Name @Java(type: "Person")23address: String @Java(type: "String")24}2526@Java(type: "Person")27type Name {28first: String @Java(type: "String")29last: String @Java(type: "String")30}
Notice how for list fields (e.g. Query.articles) the resulting Java type is is wrapped in a Java List.
Also notice that for type Author and Name they both got assigned the Java type Person. This ensures the necessary data is in context when it comes time to fetch those types' fields.
At the root of the schema, as well as the Query type, the Java type is Object, which is meant to signify that the paired type is undefined at this point. That means the data fetchers for the Query.article and Query.articles field do not have any source object to use to fetch their data, but that's OK! The article field receives a id: String as input, which is enough for the data fetcher to execute Query.from(Story.class).where("_id = ?", id).first() to fetch the Story Java object required by that field. Similarly, the data fetcher for the articles field can just run Query.from(Story.class).selectAll() and get back the required List<Story> type. Now, when you get to the Article.headline field, you have the Story object in context for the data fetcher to call ((Story) source).title. One benefit of the framework is that you do NOT need to cast the source object to a Story. Through Java's generics your data fetcher will already have the source object cast to the correct type.
In this example, we tightly coupled the GraphQL types with the Brightspot types, even if their naming was different. Sometimes this is perfectly fine and the right thing to do for the sake of simplicity. However, as your schema grows and your uses cases broaden, a looser coupling can increase the flexibility of your design. For example, the Name type was paired directly with Person. It's possible in the future for there to be other GraphQL types that need to return a Name where a Person may not be in context, and it's possible for there to be other Brightspot content types that have the concept of a name. To plan for this, you might opt to create a Java interface (or even just a POJO with getters and setters) to directly map to the Name type, e.g.
1public interface Name {2String getFirst();3String getLast();4}
1@Java(type: "Person")2type Author {3name: Name @Java(type: "Name")4address: String @Java(type: "String")5}67@Java(type: "Name")8type Name {9first: String @Java(type: "String")10last: String @Java(type: "String")11}
At that point you have multiple options:
- Have your
Personclass implement theNameinterface, and then theAuthor.namedata fetcher can just return the source object as-is. - you can have the data fetcher create an anonymous instance of the
Nameinterface based on thePersondata (or populate a POJO) and return that instead as one-off business logic that lives solely in the data fetcher - add a helper method to the
Personclass likepublic Name toName();and simply call that from the data fetcher, making that snippet of logic usable in other contexts.
The choice is yours, but thinking about your schema this way will ensure that all the right data is in context at the right place and the right time. The framework assists with this mental model through its use of generics on Type Generators which are used to define your types. You can read more about the exact syntax for defining data fetchers in the Type Generators section below.
Type / Field Builder Pattern
A GraphQL schema can be thought of as just a collection of type and field definitions. Types and fields in GraphQL and be further classified into different groups. For example, some types have fields (e.g. type, interface, input), and some do not have fields (e.g. union, scalar, enum). Some types are used for output (e.g. type, interface, union), some for input (e.g. input), and some can be used for both (e.g. scalar, enum). Similarly, some fields are either used for output or for input. Of those used for input, they can either be defined on an input type, or they can be defined as an argument on an output field. These properties, and more, govern how a GraphQL schema is defined.
The framework mirrors these concepts, and at the highest level, types are represented by SchemaType and fields are represented by SchemaField. Going down one level, input types and output types are represented by InputType and OutputType respectively, and similarly input and output fields are represented by InputField and OutputField respectively. The hierarchy continues down until you reach the most specific types (e.g. type, scalar, input, arguments, etc.) As part of defining your schema, you are responsible for creating these types of objects in various contexts.
For example, when defining a union you need to tell the system what types are a part of it. Or, when defining an interface you need to tell the system the list of OutputFields that must be implemented. Each field then has a number of properties like its name, arguments, return type, and data fetcher function.
The API for creating these types of objects uses a builder pattern. Let's see the API in action:
NOTE: Purely for the sake of brevity in code, the types are cast to use the wildcard ? in place of SchemaContext. Normally, you want to be explicit about this type argument as there are cases where that type matters, but it's not the focus of this discussion.
1import java.util.List;23import com.psddev.graphql.schema.OutputField;4import com.psddev.graphql.schema.OutputFieldNamed;5import com.psddev.graphql.schema.OutputFieldType;6import com.psddev.graphql.schema.OutputType;7import com.psddev.graphql.schema.ScalarType;89public class Code {10public static void main() {1112// Using a static API here to express the need for a String scalar type.13ScalarType<?, String> step1 = ScalarType.ofString();1415// Scalars can be used as outputs OR inputs, so we specifically ask for it to be used16// as output.17OutputType<?, String> step2 = step1.toOutputType();1819// We want to use this scalar as part of an output FIELD definition (as opposed to just20// a type that's part of a union (though technically a scalar can't be a part of a union21// anyway)).22OutputFieldType<?, String> step3 = step2.toFieldType();2324// The field should be required. Note how the return type did not change at this step.25OutputFieldType<?, String> step4 = step3.asNonNull();2627// Actually, we don't want it to just be a String, but an array of them, e.g. [String!].28// Note how while the returned class is the same - it's still a field type - but the29// generic changed from String to List<String>.30OutputFieldType<?, List<String>> step5 = step4.toList();3132// Now, let's give our field a name, 'example'. Note, the returned class changed again,33// but maintained the fact that we're dealing with a List<String>.34OutputFieldNamed<?, List<String>> step6 = step5.named("example");3536// Almost done. Now we need to define a data fetcher. The function must return a37// List<String> or else we will get a compilation error! The resulting object is a38// completed OutputField. Notice how there's an additional generic type argument at39// index 1 of type Object. It is also the 'source' type used as the input to the data40// fetcher function. This type is goverened by the first argument to the fetching41// function (currently null), which accepts a TypeGenerator whose signature will ensure42// the type safety of the call.43OutputField<?, Object, List<String>> step7 = step6.fetching(44null, (Object source) -> List.of("foo", "bar"));4546// Our field was done after the previous step in that it had enough information to be47// included in the schema, however we can still add additional properties to the field48// like a description.49OutputField<?, Object, List<String>> step8 = step7.description("Example output field.");50}51}
When you put it all together, you're left with a clean, concise way of describing a field. Below is the same field definition as above (e.g. example: [String!], but written in its most concise form, and in context of an actual Type Generator that's paired with the Brightspot Site object, with an altered data fetching function that returns the Site's URLs.
1import java.util.List;23import com.psddev.cms.db.Site;4import com.psddev.graphql.schema.OutputField;5import com.psddev.graphql.schema.OutputObjectTypeGenerator;6import com.psddev.graphql.schema.ScalarType;7import com.psddev.graphql.schema.SchemaContext;8import com.psddev.graphql.schema.TypeLoadContext;910public class ExampleTypeGenerator extends OutputObjectTypeGenerator<SchemaContext, Site> {1112@Override13protected List<OutputField<SchemaContext, Site, ?>> getFields(14TypeLoadContext<SchemaContext> context15) {16return List.of(17ScalarType.ofString()18.toOutputFieldType()19.asNonNull().toList()20.named("example")21.fetching(this, Site::getUrls)22.description("Example output field.")23);24}25}26
The beauty and power of this API lies in its type safety. The data fetching call expects a function that takes a Site as input (per the Type Generator declaration) and returns a List<String> (per the field builder API calls), therefore we're able to use Java's method reference syntax to cleanly invoke the getUrls() method on Site which returns the expected List<String>. Anything other than these exact function input and output types will generate a compile error. Similarly, changing the Java type paired with the Type Generator, or changing how the field type is defined will result in a compile error, making it very easy to implement and refactor code, and catch mistakes.
In the next section, we'll take a closer look at Type Generators, which are the cornerstone of using the GraphQL Framework.