This module adds support for declarative GraphQL clients using Feign. It provides a GraphqlContract, GraphqlEncoder, and GraphqlDecoder that transform annotated interfaces into fully functional GraphQL clients.
The companion module feign-graphql-apt provides compile-time type generation from GraphQL schemas, producing Java records for query results and input types.
Add both modules to use schema-driven type generation:
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-graphql</artifactId>
<version>${feign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign.experimental</groupId>
<artifactId>feign-graphql-apt</artifactId>
<version>${feign.version}</version>
<scope>provided</scope>
</dependency>Define a GraphQL schema in src/main/resources:
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String
}Annotate your Feign interface with @GraphqlSchema pointing to the schema file and @GraphqlQuery on each method with the GraphQL query string:
@GraphqlSchema("my-schema.graphql")
interface UserApi {
@GraphqlQuery("query { user(id: $id) { id name email } }")
User getUser(@Param("id") String id);
}The annotation processor generates a Java record for User at compile time:
public record User(String id, String name, String email) {}Build the client using GraphqlCapability, which wires the contract, encoder, decoder, and request interceptor automatically. It takes any JsonCodec:
// Using Jackson
UserApi api = Feign.builder()
.addCapability(new GraphqlCapability(new JacksonCodec()))
.target(UserApi.class, "https://api.example.com/graphql");
User user = api.getUser("123");Any JSON codec works the same way:
// Gson
new GraphqlCapability(new GsonCodec())
// Jackson 3
new GraphqlCapability(new Jackson3Codec())
// Fastjson2
new GraphqlCapability(new Fastjson2Codec())Methods with parameters are sent as GraphQL variables:
@GraphqlSchema("my-schema.graphql")
interface UserApi {
@GraphqlQuery("mutation($input: CreateUserInput!) { createUser(input: $input) { id name } }")
User createUser(@Param("input") CreateUserInput input);
}The processor generates a record for the input type as well:
public record CreateUserInput(String name, String email) {}When your schema defines custom scalars, map them to Java types using @Scalar on default methods:
scalar DateTime
type Event {
id: ID!
name: String!
startTime: DateTime!
}@GraphqlSchema("event-schema.graphql")
interface EventApi {
@Scalar("DateTime")
default Instant dateTime() { return null; }
@GraphqlQuery("query { events { id name startTime } }")
List<Event> getEvents();
}The processor maps DateTime fields to java.time.Instant in the generated record:
public record Event(String id, String name, Instant startTime) {}When a GraphQL query returns an array type (e.g. [User!]) but the Java method declares a single return type, the decoder automatically unwraps the first element:
@GraphqlQuery("query topUser($limit: Int!) { topUsers(limit: $limit) { id name email } }")
User topUser(int limit);This is useful when using limit: 1 to fetch a single result from a list query. If the array is empty, null is returned.
Methods can return Optional<T> to safely handle nullable results:
@GraphqlQuery("query getUser($id: String!) { getUser(id: $id) { id name email } }")
Optional<User> findUser(String id);Returns Optional.empty() when the data is null or missing, and Optional.of(value) otherwise. This also works with array unwrapping:
@GraphqlQuery("query topUser($limit: Int!) { topUsers(limit: $limit) { id name email } }")
Optional<User> findTopUser(int limit);When using Optional<> fields in records, your JSON codec must support java.util.Optional. For Jackson, add jackson-datatype-jdk8 and register it:
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
UserApi api = Feign.builder()
.addCapability(new GraphqlCapability(new JacksonCodec(mapper)))
.target(UserApi.class, "https://api.example.com/graphql");By default, nullable GraphQL fields (without !) are wrapped in Optional<> in generated records:
type User {
id: ID! # non-null
name: String! # non-null
email: String # nullable
}public record User(String id, String name, Optional<String> email) {}This is controlled by useOptional on @GraphqlSchema (defaults to true):
@GraphqlSchema(value = "schema.graphql", useOptional = false)Override per method with Toggle:
@GraphqlQuery(value = "...", useOptional = Toggle.FALSE)Add annotations to all generated records using typeAnnotations (no-arg) and rawTypeAnnotations (with args):
@GraphqlSchema(
value = "schema.graphql",
typeAnnotations = {Builder.class, Jacksonized.class},
rawTypeAnnotations = {"@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)"}
)Generates:
@Builder
@Jacksonized
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record User(String id, String name) {}Collision rule: when the same annotation simple name appears in both typeAnnotations and rawTypeAnnotations, the class provides only the import and the raw string is used:
typeAnnotations = {Builder.class},
rawTypeAnnotations = {"@Builder(toBuilder = true)"}
// Result: import lombok.Builder; + @Builder(toBuilder = true)Override per method on @GraphqlQuery — non-empty arrays replace class-level values.
When raw annotations reference classes not in typeAnnotations, use uses to add their imports:
@GraphqlSchema(
value = "schema.graphql",
uses = {Min.class, Max.class, Pattern.class}
)These classes are added as imports to all generated files but no annotations are generated from them.
Automatically annotate all non-null (!) fields with nonNullTypeAnnotations:
@GraphqlSchema(
value = "schema.graphql",
nonNullTypeAnnotations = {NotNull.class}
)For name: String! and email: String, generates:
public record User(@NotNull String name, Optional<String> email) {}Same collision rule applies with nonNullRawTypeAnnotations. Overridable per method on @GraphqlQuery.
Apply annotations or override types on specific fields. Repeatable, works on both the interface (class-level default) and individual methods:
@GraphqlSchema(value = "schema.graphql", useOptional = false)
@GraphqlField(name = "email", typeAnnotations = {Email.class})
interface UserApi {
@GraphqlQuery("{ user(id: \"1\") { id name email } }")
@GraphqlField(name = "name", typeAnnotations = {NotBlank.class})
UserResult getUser();
}Generates:
public record UserResult(String id, @NotBlank String name, @Email String email) {}Use dot notation to target fields in nested records:
@GraphqlField(name = "location.coordinates.latitude", typeAnnotations = {NotNull.class})
@GraphqlField(name = "location.planet", typeAnnotations = {NotBlank.class})Override the Java type for a field — useful when APIs return strings for dates without declaring custom scalars:
@GraphqlField(name = "createdAt", type = ZonedDateTime.class)
@GraphqlField(name = "amount", type = BigDecimal.class)Combines with annotations:
@GraphqlField(name = "createdAt", type = ZonedDateTime.class, typeAnnotations = {NotNull.class})Class-level @GraphqlField applies to all methods; method-level overrides for the same field name.
If you provide your own model classes, disable automatic generation:
@GraphqlSchema(value = "my-schema.graphql", generateTypes = false)
interface UserApi {
// ...
}Queries are still validated against the schema at compile time.
GraphQL errors in the response throw GraphqlErrorException:
try {
User user = api.getUser("invalid-id");
} catch (GraphqlErrorException e) {
String operation = e.operation();
String errors = e.errors();
}