Spring Boot GraphQL: Building Modern APIs with Spring for GraphQL 1.1+
Learn to build production-grade GraphQL APIs with Spring for GraphQL 1.1+.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17+ (Spring Boot 3.x requires Java 17)
- ✓Maven or Gradle (Gradle 7.5+ recommended)
- ✓Basic knowledge of Spring Boot (see spring-boot-introduction)
- ✓PostgreSQL or H2 for local dev (we'll use H2 for simplicity)
- ✓Postman or GraphiQL (included in dev tools)
• GraphQL is a query language for APIs that lets clients request exactly the data they need, reducing over-fetching and under-fetching. • Spring for GraphQL 1.1+ provides seamless integration with Spring Boot, supporting annotations, schema-first, and reactive stacks. • Key features include @QueryMapping, @MutationMapping, DataLoader for N+1 prevention, and built-in error handling. • Production concerns: secure endpoints with Spring Security, monitor with Actuator, and optimize with batching and caching. • Use GraphiQL or Postman for testing; enable schema introspection only in dev.
Imagine a buffet where instead of a fixed menu (REST), you tell the chef exactly which ingredients you want on your plate. GraphQL is that chef — you ask for 'user name, email, and last 3 orders' and get exactly that, no extra sides. Spring for GraphQL is the kitchen setup that makes this efficient at scale.
If you've been building REST APIs for years, you know the pain: versioning hell, over-fetching on mobile clients, under-fetching on dashboards, and the eternal N+1 query problem. GraphQL, introduced by Facebook in 2015, solves these by giving clients the power to specify their exact data shape. Spring for GraphQL, released as a first-class Spring project in 2022 (1.0.0), brings this to the Java ecosystem with the maturity you expect from Spring. Version 1.1+ (Spring Boot 3.x) adds reactive support, improved error handling, and better integration with Spring Security. In this guide, I'll walk you through building a complete GraphQL API for a payment-processing domain — think invoices, transactions, and merchants. We'll cover schema design, resolver wiring, DataLoader for batching, error handling patterns, and production monitoring. No fluff, just battle-tested patterns from someone who's debugged GraphQL at 10k QPS. By the end, you'll know when GraphQL is a good fit (complex UIs, mobile apps) and when it's overkill (simple CRUD, internal services with stable clients). Let's get our hands dirty.
Setting Up the Project: Spring Boot 3.2 + GraphQL 1.2
Let's start with a Maven project. I'll use Spring Boot 3.2.0 and Spring for GraphQL 1.2.3 — the latest stable as of early 2025. Add the spring-boot-starter-graphql dependency along with web (we'll use Spring MVC for simplicity; reactive works similarly). We'll also include H2 for quick prototyping and Spring Boot Actuator for metrics. The pom.xml snippet below shows the key dependencies. Note: if you're on Gradle, the artifact coordinates are the same. After adding dependencies, create an application.yml with GraphQL settings: enable GraphiQL (the in-browser IDE) for dev, set schema locations, and configure CORS for local frontend testing. We'll use the schema-first approach — define a schema.graphqls file in src/main/resources/graphql. This is the contract between client and server. Start with a simple 'Query' type for fetching invoices. The schema defines the types, queries, and mutations. Spring for GraphQL will automatically scan for @Controller beans that implement these operations.
What the Official Docs Won't Tell You
The official Spring for GraphQL docs are good, but they gloss over the hard parts. First: exception handling. By default, GraphQL returns errors with generic messages. You need a custom DataFetcherExceptionResolver to map domain exceptions (e.g., InvoiceNotFoundException) to proper GraphQL error codes. Second: security. Spring Security annotations (@PreAuthorize) work on resolver methods, but be careful — field-level security is tricky. If a user queries 'invoice { amount }', the resolver for 'amount' must check authorization. I recommend using a ThreadLocal to store the authenticated user and check permissions in each resolver. Third: testing. The docs show @GraphQlTest but don't mention that you must mock every DataLoader. I've spent hours debugging tests that pass locally but fail in CI because DataLoaders weren't registered. Use @MockBean for DataLoaderRegistry and verify batch loading functions. Fourth: schema stitching. Spring for GraphQL doesn't support merging multiple schemas out of the box. For microservices, you'll need Apollo Federation or a gateway. Don't try to hack it with multiple schema files — it won't work. Finally: performance. The docs say 'use DataLoader' but don't explain that each request creates a new DataLoader instance. Cache DataLoader instances per request using DataLoaderRegistry, not as singletons. I've seen teams accidentally share DataLoaders across requests, causing data leaks.
Schema Design: Types, Queries, and Mutations
Designing a GraphQL schema is like designing a database schema — get it wrong and you'll pay forever. For our payment domain, we have three core types: Invoice, Transaction, and Merchant. An Invoice has an id, amount, dueDate, status, and a list of transactions. A Transaction has id, amount, timestamp, and type (CHARGE, REFUND). A Merchant has id, name, and a list of invoices. Define these in schema.graphqls. Use enums for status and transaction type — they're type-safe and self-documenting. Define Query type with 'invoices', 'invoice(id: ID!)', 'merchants', and 'merchant(id: ID!)'. Define Mutation type with 'createInvoice', 'processTransaction', and 'voidInvoice'. Use input types for mutations to avoid cluttering arguments. For example, CreateInvoiceInput includes merchantId, amount, dueDate. Return types from mutations should include the created/modified object plus a 'success' boolean and 'message' for client feedback. Never return just the object — clients need to know if the operation succeeded. Also, add a 'PageInfo' type for pagination: hasNextPage, hasPreviousPage, startCursor, endCursor. Use Relay-style pagination with edges and nodes for consistency. This might feel heavy, but it's the standard and clients expect it.
Implementing Resolvers with @QueryMapping and @MutationMapping
Spring for GraphQL maps schema fields to Java methods using annotations. Create a @Controller class (not @RestController — GraphQL uses its own dispatch). Use @QueryMapping for queries and @MutationMapping for mutations. The method name doesn't have to match the schema field, but it's cleaner if it does. Arguments are automatically resolved by name and type. For example, 'invoice(id: ID!)' maps to a method with '@Argument String id'. Use @SchemaMapping for fields that need custom resolution, like 'merchant' on an Invoice. By default, Spring for GraphQL tries to resolve fields from the returned object's properties. If the field name matches a Java property, no resolver needed. But for associations (e.g., Invoice.merchant), you need a resolver to fetch the merchant. This is where DataLoader comes in — never fetch associations directly in the resolver. Return a CompletableFuture from the resolver and let DataLoader batch the calls. For mutations, use @Transactional to ensure atomicity. GraphQL mutations can trigger multiple database operations; wrap them in a transaction. Also, validate input in the resolver or use a validation framework like Jakarta Validation with @Valid.
DataLoader: The N+1 Problem Solved
The N+1 problem is the silent killer of GraphQL performance. When you query a list of invoices and each invoice resolves its merchant, without batching you execute 1 query for the list + N queries for merchants. With 100 invoices, that's 101 queries. DataLoader solves this by batching all merchant IDs into a single query. Spring for GraphQL integrates with DataLoader via DataLoaderRegistry. Create a @Bean that registers a DataLoader for each association. The DataLoader takes a batch loading function (List<K> -> CompletableFuture<Map<K, V>>) and returns individual CompletableFuture<V> for each key. The batch function collects all keys from the current request and executes one query. For example, MerchantDataLoader fetches all merchants by IDs using an IN clause. The DataLoader is request-scoped — a new instance is created per HTTP request. This ensures batching doesn't leak across requests. In the resolver, call dataLoader.load(merchantId) which returns a CompletableFuture. GraphQL will wait for all futures to complete before sending the response. This is transparent to the client. Always test with a profiler to verify batching is working. I've seen teams implement DataLoader but forget to register it, resulting in no batching.
Error Handling and Validation Patterns
GraphQL returns errors in a standard format, but the default messages are generic. You need to map domain exceptions to user-friendly errors with codes. Implement DataFetcherExceptionResolver as shown earlier. For validation errors from @Valid, Spring for GraphQL throws a ConstraintViolationException. Catch it and return a list of errors, one per violation. Use error extensions to include field name and rejected value. For business logic errors (e.g., 'Cannot void a paid invoice'), throw a custom exception with a code. The resolver should catch it and return a proper error. For partial success — some items in a batch fail — return a union type or a payload with errors array. I prefer the payload approach: mutation returns an object with 'success', 'message', and optionally 'errors' list. This lets clients handle partial failures gracefully. Never throw generic RuntimeException in resolvers; it results in a 500 Internal Server Error with no useful info. Also, set a global query depth limit to prevent deeply nested queries from exhausting resources. Use spring.graphql.schema.introspection.enabled=false in production to hide schema details from attackers.
Security: Spring Security Integration
Securing a GraphQL endpoint is similar to REST, but with nuances. All GraphQL requests go to a single endpoint (e.g., /graphql). Use Spring Security to secure this endpoint with role-based access. For example, @PreAuthorize("hasRole('ADMIN')") on a mutation. However, field-level security is more complex. If a user should only see their own invoices, you need to check authorization in each resolver. I use a custom annotation @CurrentUser that injects the authenticated user from the SecurityContext. Then in the resolver, check if the user has access to the resource. For example, in the invoice resolver, verify that the invoice's merchantId matches the current user's merchantId. If not, return null or throw an AuthorizationException. Be careful with null — GraphQL will return null for that field and include an error in the 'errors' array. This is acceptable for hiding data. For mutations, always verify the user can perform the action. Never trust client-side IDs. Also, disable introspection in production — it reveals the entire schema. Use spring.graphql.schema.introspection.enabled=false. For JWT authentication, see our spring-boot-jwt-authentication guide. The same principles apply: extract JWT from Authorization header, validate, and set SecurityContext.
SecurityContextHolder.getContext() calls.Testing GraphQL APIs with Spring Boot
Testing GraphQL APIs requires a different approach than REST. Spring for GraphQL provides @GraphQlTest annotation for slice tests. It auto-configures the GraphQL infrastructure but not the full application context. Use @Autowired for GraphQlTester, which allows you to execute queries and assert responses. For integration tests, use @SpringBootTest with a web environment. The GraphQlTester can send POST requests to /graphql and parse responses. Always test both success and error paths. For DataLoader tests, mock the DataLoaderRegistry and verify that batch loading functions are called. I've seen tests pass because the DataLoader was mocked to return data directly, but the batch function was never invoked. Use Mockito.verify to ensure the batch function is called with the expected keys. Also, test query depth limits: send a deeply nested query and assert it returns an error. For mutations, test transactional behavior: if a mutation fails mid-way, ensure no partial data is persisted. Use @Transactional in tests to rollback after each test. Finally, test security: send requests without authentication and assert 401. Test with different roles and assert field-level access works.
The Billion-Dollar N+1: How a Missing DataLoader Took Down a Payment Dashboard
- Always profile GraphQL resolvers in staging before going live — use Spring Boot Actuator /metrics or a profiler like Async Profiler.
- DataLoader is not optional for any relationship fetching; treat it as mandatory in code reviews.
- Set up query depth and complexity limits in production to prevent accidental DoS via deeply nested queries.
curl -X POST http://localhost:8080/actuator/metrics/graphql.data.loader.batch.sizetail -f /var/log/app/graphql.log | grep "SELECT.*from merchant"| File | Command / Code | Purpose |
|---|---|---|
| pom.xml (dependencies) | Setting Up the Project | |
| CustomExceptionResolver.java | @Component | What the Official Docs Won't Tell You |
| schema.graphqls | type Invoice { | Schema Design |
| InvoiceController.java | @Controller | Implementing Resolvers with @QueryMapping and @MutationMappi |
| MerchantDataLoader.java | @Component | DataLoader |
| ValidationErrorResolver.java | @Component | Error Handling and Validation Patterns |
| SecurityConfig.java | @Configuration | Security |
| InvoiceControllerTest.java | @GraphQlTest(InvoiceController.class) | Testing GraphQL APIs with Spring Boot |
Key takeaways
Interview Questions on This Topic
Explain the N+1 problem in GraphQL and how DataLoader solves it.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't