Course topics

By WebNest Studio

Spring Boot Tutorial

GraphQL APIs with Spring Boot

With REST, the server decides the shape of each response. A mobile screen that needs a course title, its first five lessons and the instructor's name may need three requests — or one endpoint that returns far more data than needed. GraphQL lets the client ask for exactly the fields it wants, across related objects, in a single request, against a strongly typed schema.

Spring for GraphQL, auto-configured by spring-boot-starter-graphql, maps a GraphQL schema to annotated controllers. This lesson covers schema-first design, queries, mutations and nested field resolvers, solving the N+1 problem with @BatchMapping, validation and errors, security, the GraphiQL UI, and testing with GraphQlTester.

Schema First

Define types, queries and mutations in .graphqls files under src/main/resources/graphql. The schema is the contract: clients (and tools) introspect it, and Spring verifies at startup that every field has a data source. Types have fields with scalar types (ID, String, Int, Float, Boolean) or other types; ! means non-null and [Type] means a list.

Controllers and Data Fetchers

A @Controller with @QueryMapping and @MutationMapping methods implements the root fields; method names match field names, and @Argument binds arguments (including input types to records). @SchemaMapping resolves a field on a type — for example Course.instructor — only when the client asks for it.

Avoiding N+1 with @BatchMapping

If a query returns 50 courses and the client asks for each course's instructor, a per-course @SchemaMapping triggers 50 lookups. @BatchMapping receives all 50 courses at once and returns a map of course → instructor, so you load them in one query. Always use batch mappings for nested fields on lists.

Security, Limits and Errors

GraphQL typically uses a single /graphql endpoint, so authorise at the field level with method security (@PreAuthorize on controller or service methods). Because clients control query shape, protect the server against expensive queries with maximum depth and complexity limits and pagination on lists. Errors are returned in an errors array alongside partial data; map your exceptions to GraphQL error types with a DataFetcherExceptionResolver.

Examples

Dependency and schema

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

# application.yml
spring:
  graphql:
    graphiql:
      enabled: true          # browser IDE at /graphiql (development)

# src/main/resources/graphql/schema.graphqls
type Query {
    courses(level: Level): [Course!]!
    course(slug: String!): Course
}

type Mutation {
    enroll(input: EnrollInput!): Enrollment!
}

enum Level { BEGINNER INTERMEDIATE ADVANCED }

type Course {
    id: ID!
    slug: String!
    title: String!
    level: Level!
    lessons(first: Int = 5): [Lesson!]!
    instructor: Instructor!
}

type Lesson { id: ID! title: String! position: Int! }
type Instructor { id: ID! name: String! }
type Enrollment { id: ID! courseSlug: String! studentEmail: String! }

input EnrollInput { courseSlug: String!, studentEmail: String! }
Output
Loaded 1 resource(s) in the GraphQL schema.
GraphiQL available at /graphiql

Query, mutation, field and batch mappings

Java
@Controller
public class CourseGraphController {

    private final CourseService courses;
    private final InstructorRepository instructors;

    public CourseGraphController(CourseService courses, InstructorRepository instructors) {
        this.courses = courses;
        this.instructors = instructors;
    }

    @QueryMapping
    public List<Course> courses(@Argument Level level) {
        return courses.findAll(level);
    }

    @QueryMapping
    public Course course(@Argument String slug) {
        return courses.findBySlug(slug).orElse(null);
    }

    @MutationMapping
    @PreAuthorize("isAuthenticated()")
    public Enrollment enroll(@Argument EnrollInput input) {
        return courses.enroll(input.courseSlug(), input.studentEmail());
    }

    // Resolved only when the client selects Course.lessons
    @SchemaMapping
    public List<Lesson> lessons(Course course, @Argument int first) {
        return courses.lessons(course.id(), first);
    }

    // One query for all instructors of all returned courses (no N+1)
    @BatchMapping
    public Map<Course, Instructor> instructor(List<Course> courseList) {
        Map<Long, Instructor> byId = instructors.findAllById(
                courseList.stream().map(Course::instructorId).collect(Collectors.toSet()))
            .stream().collect(Collectors.toMap(Instructor::id, i -> i));
        return courseList.stream().collect(Collectors.toMap(c -> c, c -> byId.get(c.instructorId())));
    }
}

public record EnrollInput(String courseSlug, String studentEmail) {}
Output
query {
  courses(level: INTERMEDIATE) {
    title
    instructor { name }
    lessons(first: 2) { title }
  }
}

{"data":{"courses":[
  {"title":"Spring Boot","instructor":{"name":"Vansh"},"lessons":[{"title":"Spring vs Spring Boot vs Spring MVC"},{"title":"Spring Boot Project Setup"}]},
  {"title":"Spring Framework","instructor":{"name":"Vansh"},"lessons":[...]}]}}

SQL executed: 1 query for courses, 1 query for all instructors, 1 per course for lessons (only because lessons were requested)

Testing with @GraphQlTest and GraphQlTester

Java
@GraphQlTest(CourseGraphController.class)
class CourseGraphControllerTest {

    @Autowired GraphQlTester graphQlTester;
    @MockitoBean CourseService courses;
    @MockitoBean InstructorRepository instructors;

    @Test
    void returnsCourseTitle() {
        when(courses.findBySlug("spring-boot"))
            .thenReturn(Optional.of(new Course(1L, "spring-boot", "Spring Boot", Level.INTERMEDIATE, 7L)));

        graphQlTester.document("""
                query { course(slug: "spring-boot") { title level } }
                """)
            .execute()
            .path("course.title").entity(String.class).isEqualTo("Spring Boot")
            .path("course.level").entity(String.class).isEqualTo("INTERMEDIATE");
    }
}
Output
CourseGraphControllerTest > returnsCourseTitle() PASSED

Common Mistakes

  • Using @SchemaMapping for nested fields on lists, causing N+1 queries; use @BatchMapping.
  • Exposing unbounded lists without pagination or query depth/complexity limits.
  • Relying on URL-based security for a single /graphql endpoint instead of field/method-level authorization.
  • Leaving GraphiQL and schema introspection open in production for sensitive APIs.
  • Designing the schema as a copy of database tables rather than around client use cases.

Key Points to Remember

  • GraphQL lets clients request exactly the fields they need from a typed schema.
  • spring-boot-starter-graphql maps schema.graphqls to @QueryMapping, @MutationMapping and @SchemaMapping methods.
  • @BatchMapping loads nested data for many parents in one call.
  • Secure with method security and protect the server with depth/complexity limits.
  • Test with @GraphQlTest and GraphQlTester; explore with GraphiQL in development.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.