GraphQL in Spring Boot

GraphQL Course · lesson 13 of 14 · 6 min read

Schema-first wiring, controllers and testing.

Open this lesson in the learning hub

Key points

  • Spring for GraphQL is schema-first: the .graphqls file under src/main/resources/graphql/ is the contract, and Java code implements it.
  • That direction matters. The schema is what clients depend on, so generating it from Java would make every refactor a potentially breaking API change.
  • @QueryMapping and @MutationMapping bind root fields; @SchemaMapping binds a field on a type, which is where nested resolvers live.
  • @Argument binds an argument, and Spring maps input objects onto records automatically - the natural shape for an input type.
  • GraphiQL is available at /graphiql when enabled. It is a development tool and must be disabled in production, along with introspection.
  • GraphQlTester makes testing pleasant: send a document, then assert on paths in the response rather than parsing JSON by hand.

Example

# src/main/resources/graphql/schema.graphqls
type Query {
  order(id: ID!): Order
  orders(first: Int = 20, after: String): OrderConnection!
}

type Mutation {
  placeOrder(input: PlaceOrderInput!): PlaceOrderPayload!
}

input PlaceOrderInput { customerId: ID!, items: [OrderItemInput!]! }
type PlaceOrderPayload { order: Order, errors: [UserError!]! }
type UserError { field: String, message: String! }

---
@Controller
class OrderGraphQlController {

    @QueryMapping
    public Order order(@Argument String id) {
        return service.find(Long.valueOf(id));
    }

    // A field ON Order - this is the one that needs a DataLoader.
    @SchemaMapping(typeName = "Order", field = "customer")
    public CompletableFuture<Customer> customer(Order order,
            DataLoader<Long, Customer> loader) {
        return loader.load(order.customerId());
    }

    // Input objects bind straight onto a record.
    @MutationMapping
    public PlaceOrderPayload placeOrder(@Argument PlaceOrderInput input) {
        // Expected, user-facing failures belong in the PAYLOAD, not as
        // a thrown error - clients can then handle them per field.
        var violations = validator.validate(input);
        if (!violations.isEmpty()) {
            return new PlaceOrderPayload(null, toUserErrors(violations));
        }
        return new PlaceOrderPayload(service.place(input), List.of());
    }
}

---
# Production settings - GraphiQL and introspection OFF.
spring:
  graphql:
    graphiql:
      enabled: false            # true only in dev
    schema:
      introspection:
        enabled: false          # do not publish the schema publicly

---
@GraphQlTest(OrderGraphQlController.class)
class OrderGraphQlControllerTest {

    @Autowired GraphQlTester tester;
    @MockBean OrderService service;

    @Test
    void fetchesAnOrder() {
        when(service.find(1L)).thenReturn(new Order(1L, "PAID"));

        tester.document("{ order(id: \"1\") { id status } }")
                .execute()
                .path("order.status").entity(String.class).isEqualTo("PAID");
    }
}

Schema-first keeps the contract stable, @SchemaMapping is where nested resolvers and DataLoaders go, and introspection is off in production.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the GraphQL Course course, and every lesson in it is listed on the GraphQL Course contents page.