[EN] Spring Boot 4 & GraphQL: High-Performance APIs mit Java Records

Julian | Feb 20, 2026 min read

While REST often provides too much data (overfetching) or gRPC is too complex to handle for web clients, GraphQL has established itself as the standard for flexible front ends. With Spring Boot 4, the integration of spring-graphql is more seamless than ever before. Forget manual controller logic for each field - we harness the power of Declarative Schema Mapping.

Why GraphQL in 2026?

In a world of micro-frontends and mobile apps, we need to give the client control over the data structure. In Spring Boot 4 we benefit from:

  • Auto-Configuration: The scheme is automatically validated against your controllers.
  • Virtual Threads: Every DataFetcher runs efficiently by default, even with blocking DB queries.
  • Native Querydsl Integration: Complex filtering directly from the GraphQL schema.

The scenario: Task management API

We are building an API for a task board. A user wants to access their tasks, but only see the titles and status - without the complete metadata.

1. The schema: schema.graphqls

In Spring Boot 4, we put our schema under src/main/resources/graphql/.

type Query {
    tasks: [Task]
    taskById(id: ID!): Task
}

type Task {
    id: ID
    title: String
    status: String
    assignee: String
}

2. Die Dependencies (Maven)

As with the gRPC article, we’ll stick with Maven. The new starter bundles everything we need.

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

Implementation of the controllers: records & schema mapping

In previous versions, we often had to write complex POJOs using boilerplate code. With Java 21+ and Spring Boot 4 we use Records for our data models. They are immutable, concise, and perfectly fit the structure of GraphQL types.

1. The data model as a record

public record Task(String id, String title, String status, String assignee) {}

2. There TaskController

In Spring Boot 4, we no longer need to manually register special service beans when using the new controller annotations. @QueryMapping connects the method directly to the field of the same name in our Query type in the schema.

@Controller
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @QueryMapping
    public List<Task> tasks() {
        // Dank Virtual Threads (spring.threads.virtual.enabled=true)
        // können wir hier blockierende Repository-Aufrufe machen.
        return taskService.findAllTasks();
    }

    @QueryMapping
    public Task taskById(@Argument String id) {
        return taskService.findTaskById(id);
    }
}

Tip: Use @Argument without an explicit name if your Java parameter has the exact same name as in the GraphQL schema. This saves code and makes the API easier to maintain.

3. Efficient loading with @SchemaMapping

A classic problem in GraphQL is the N+1 problem. If we had to load the assignee from a different service for each task, that would be extremely inefficient. Spring Boot 4 optimizes this with improved @BatchMapping support, but for simple cases we use @SchemaMapping.

@SchemaMapping(typeName = "Task", field = "assignee")
public String getAssigneeName(Task task) {
    // Diese Methode wird nur aufgerufen, wenn der Client das Feld 'assignee' anfragt.
    return "User: " + task.assignee();
}

DataFetcher and solve the N+1 problem

A standard @SchemaMapping is quick to write, but for lists (like our tasks) results in a separate query being fired for the assignee for each task. In Spring Boot 4 we solve this elegantly with @BatchMapping.

1. Efficient batch loading with @BatchMapping

Instead of querying the database or an external service for each task individually, we collect the IDs and fire a batch call. Spring uses the DataLoader internally for this.

@Controller
public class TaskController {

    // Diese Methode ersetzt das einfache @SchemaMapping für 'assignee'
    @BatchMapping
    public Map<Task, String> assignee(List<Task> tasks) {
        // Wir extrahieren alle User-IDs aus den Tasks
        List<String> userIds = tasks.stream()
                .map(Task::assigneeId)
                .toList();

        // Ein einziger Call an den User-Service / DB
        Map<String, String> userNames = userService.getNamesByIds(userIds);

        // Wir mappen die Ergebnisse zurück auf die Task-Objekte
        return tasks.stream()
                .collect(Collectors.toMap(
                    task -> task,
                    task -> userNames.getOrDefault(task.assigneeId(), "Unknown")
                ));
    }
}

2. Custom Exception Handling

Nothing is more unprofessional than a generic Internal Server Error in the GraphQL response. In Spring Boot 4 we use the DataFetcherExceptionResolverAdapter to translate technical exceptions into clean GraphQL errors.

@Component
public class GraphQlExceptionResolver extends DataFetcherExceptionResolverAdapter {

    @Override
    protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
        if (ex instanceof TaskNotFoundException) {
            return GraphQLError.newError()
                    .errorType(ErrorType.NOT_FOUND)
                    .message(ex.getMessage())
                    .path(env.getExecutionStepInfo().getPath())
                    .build();
        }
        return null;
    }
}

Testing: Securing the API

To ensure that our DataFetchers deliver what they are supposed to, we use the GraphQlTester. In Spring Boot 4 this is extremely powerful for slice testing.

@GraphQlTest(TaskController.class)
class TaskControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldGetFirstTaskTitle() {
        this.graphQlTester.document("{ tasks { title } }")
                .execute()
                .errors().verify()
                .path("tasks[0].title")
                .entity(String.class)
                .isEqualTo("GraphQL Artikel schreiben");
    }
}

Conclusion: GraphQL in 2026

With Spring Boot 4, GraphQL has finally grown up. The combination of Java Records, Virtual Threads and BatchMapping allows us to build high-performance and type-safe APIs without getting lost in the N+1 swamp. If you’re building an API that needs to be consumed flexibly by different front ends, this stack is currently unbeatable.