
Introduction
REST has been the default API style for many years, but modern applications increasingly require more flexibility in how clients fetch data. Instead of requesting fixed endpoints that return predetermined payloads, clients want fine-grained control over exactly which fields they receive. That’s where GraphQL shines. When combined with Spring Boot and the Netflix DGS (Domain Graph Service) framework, you can build type-safe, efficient, and developer-friendly GraphQL APIs that scale from simple queries to complex federated architectures. In this comprehensive guide, you will learn how GraphQL works, why the DGS framework has become the preferred choice for Java-based GraphQL servers, and how to build production-ready GraphQL services with proper error handling, authentication, and performance optimization.
What Is GraphQL?
GraphQL is a query language for APIs that lets clients request exactly the data they need—nothing more, nothing less. Unlike REST, which typically returns entire payloads from fixed endpoints, GraphQL resolves fields on demand based on the client’s query. This eliminates both over-fetching (receiving unnecessary data) and under-fetching (needing multiple requests to get all required data).
GraphQL vs REST
| Aspect | REST | GraphQL |
|---|---|---|
| Data fetching | Fixed endpoints return fixed shapes | Clients specify exact fields needed |
| Multiple resources | Multiple requests required | Single request fetches all |
| Versioning | Often requires URL versioning | Schema evolution handles changes |
| Documentation | Separate (OpenAPI/Swagger) | Built into schema |
| Type safety | Varies by implementation | Strong typing by design |
Key GraphQL Concepts
- Schema: Defines available types, queries, and mutations
- Queries: Read operations that retrieve data
- Mutations: Write operations that modify data
- Subscriptions: Real-time updates via WebSocket
- Resolvers: Functions that fetch values for each field
- DataLoader: Batching tool that optimizes repeated data fetching
Why Use Netflix DGS?
Netflix DGS (Domain Graph Service) is a GraphQL framework built specifically for Spring Boot. Created by Netflix for their internal use and open-sourced in 2021, DGS simplifies schema creation, resolver development, and data fetching while providing production-grade features out of the box.
DGS Benefits
- Schema-first development: Define your API contract before implementation
- Code generation: Automatic Java types from GraphQL schemas
- DataLoader integration: Built-in support for eliminating N+1 queries
- Spring Boot native: Works seamlessly with Spring ecosystem
- Testing utilities: First-class testing support with DgsQueryExecutor
- Federation support: Build distributed GraphQL architectures
- Metrics and tracing: Built-in observability features
Project Setup
Gradle Dependencies
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
id 'com.netflix.dgs.codegen' version '6.0.3'
}
java {
sourceCompatibility = '21'
}
dependencies {
// DGS Spring Boot Starter
implementation platform('com.netflix.graphql.dgs:graphql-dgs-platform-dependencies:8.2.0')
implementation 'com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter'
// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Database
runtimeOnly 'org.postgresql:postgresql'
// Testing
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter-test'
}
generateJava {
schemaPaths = ["${projectDir}/src/main/resources/schema"]
packageName = 'com.example.generated'
generateClient = true
}
Maven Dependencies
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.netflix.graphql.dgs</groupId>
<artifactId>graphql-dgs-platform-dependencies</artifactId>
<version>8.2.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.netflix.graphql.dgs</groupId>
<artifactId>graphql-dgs-spring-boot-starter</artifactId>
</dependency>
</dependencies>
Creating the GraphQL Schema
In DGS, you define schemas using .graphqls files in the src/main/resources/schema directory.
Complete Schema Example
# src/main/resources/schema/schema.graphqls
type Query {
product(id: ID!): Product
products(filter: ProductFilter, page: Int, size: Int): ProductConnection!
categories: [Category!]!
}
type Mutation {
createProduct(input: CreateProductInput!): Product!
updateProduct(id: ID!, input: UpdateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
}
type Subscription {
productCreated: Product!
productUpdated(id: ID!): Product!
}
type Product {
id: ID!
name: String!
description: String
price: Float!
category: Category!
reviews: [Review!]!
createdAt: DateTime!
updatedAt: DateTime
}
type Category {
id: ID!
name: String!
products: [Product!]!
}
type Review {
id: ID!
rating: Int!
comment: String
author: String!
createdAt: DateTime!
}
type ProductConnection {
content: [Product!]!
totalElements: Int!
totalPages: Int!
pageNumber: Int!
hasNext: Boolean!
}
input ProductFilter {
name: String
categoryId: ID
minPrice: Float
maxPrice: Float
}
input CreateProductInput {
name: String!
description: String
price: Float!
categoryId: ID!
}
input UpdateProductInput {
name: String
description: String
price: Float
categoryId: ID
}
scalar DateTime
Implementing Resolvers
Resolvers map GraphQL operations to your backend logic. DGS uses annotations to bind methods to schema fields.
Query Resolver
@DgsComponent
@RequiredArgsConstructor
public class ProductQueryResolver {
private final ProductService productService;
@DgsQuery
public Product product(@InputArgument String id) {
return productService.findById(id)
.orElseThrow(() -> new DgsEntityNotFoundException("Product not found: " + id));
}
@DgsQuery
public ProductConnection products(
@InputArgument ProductFilter filter,
@InputArgument Integer page,
@InputArgument Integer size
) {
int pageNum = page != null ? page : 0;
int pageSize = size != null ? size : 20;
Page<Product> result = productService.findAll(filter, PageRequest.of(pageNum, pageSize));
return ProductConnection.builder()
.content(result.getContent())
.totalElements((int) result.getTotalElements())
.totalPages(result.getTotalPages())
.pageNumber(result.getNumber())
.hasNext(result.hasNext())
.build();
}
@DgsQuery
public List<Category> categories() {
return categoryService.findAll();
}
}
Mutation Resolver
@DgsComponent
@RequiredArgsConstructor
public class ProductMutationResolver {
private final ProductService productService;
@DgsMutation
public Product createProduct(@InputArgument CreateProductInput input) {
return productService.create(input);
}
@DgsMutation
public Product updateProduct(
@InputArgument String id,
@InputArgument UpdateProductInput input
) {
return productService.update(id, input);
}
@DgsMutation
public Boolean deleteProduct(@InputArgument String id) {
productService.delete(id);
return true;
}
}
Field Resolvers for Nested Types
@DgsComponent
@RequiredArgsConstructor
public class ProductFieldResolver {
private final CategoryService categoryService;
private final ReviewService reviewService;
@DgsData(parentType = "Product", field = "category")
public Category category(DgsDataFetchingEnvironment dfe) {
Product product = dfe.getSource();
return categoryService.findById(product.getCategoryId()).orElse(null);
}
@DgsData(parentType = "Product", field = "reviews")
public List<Review> reviews(DgsDataFetchingEnvironment dfe) {
Product product = dfe.getSource();
return reviewService.findByProductId(product.getId());
}
}
DataLoader for Performance Optimization
Without DataLoader, fetching nested data causes the N+1 query problem. If you query 100 products, you might execute 100 additional queries for categories. DataLoader batches these into a single query.
Creating a DataLoader
@DgsDataLoader(name = "categories")
@RequiredArgsConstructor
public class CategoryDataLoader implements BatchLoader<String, Category> {
private final CategoryService categoryService;
@Override
public CompletionStage<List<Category>> load(List<String> categoryIds) {
return CompletableFuture.supplyAsync(() -> {
// Single query for all categories
Map<String, Category> categoryMap = categoryService.findByIds(categoryIds)
.stream()
.collect(Collectors.toMap(Category::getId, c -> c));
// Return in same order as input
return categoryIds.stream()
.map(categoryMap::get)
.collect(Collectors.toList());
});
}
}
Using DataLoader in Resolver
@DgsComponent
public class ProductFieldResolverOptimized {
@DgsData(parentType = "Product", field = "category")
public CompletableFuture<Category> category(
DgsDataFetchingEnvironment dfe,
DataLoader<String, Category> categoriesLoader
) {
Product product = dfe.getSource();
return categoriesLoader.load(product.getCategoryId());
}
}
MappedBatchLoader for Sparse Data
@DgsDataLoader(name = "reviews")
@RequiredArgsConstructor
public class ReviewDataLoader implements MappedBatchLoader<String, List<Review>> {
private final ReviewService reviewService;
@Override
public CompletionStage<Map<String, List<Review>>> load(Set<String> productIds) {
return CompletableFuture.supplyAsync(() ->
reviewService.findByProductIds(productIds)
.stream()
.collect(Collectors.groupingBy(Review::getProductId))
);
}
}
Error Handling
DGS provides structured error handling that integrates with GraphQL’s error response format.
Custom Exceptions
public class ProductNotFoundException extends DgsEntityNotFoundException {
public ProductNotFoundException(String id) {
super("Product not found with id: " + id);
}
}
public class ValidationException extends RuntimeException {
private final List<String> errors;
public ValidationException(List<String> errors) {
super("Validation failed");
this.errors = errors;
}
}
Custom Exception Handler
@Component
public class CustomDataFetchingExceptionHandler implements DataFetcherExceptionHandler {
@Override
public CompletableFuture<DataFetcherExceptionHandlerResult> handleException(
DataFetcherExceptionHandlerParameters params
) {
Throwable exception = params.getException();
GraphQLError error;
if (exception instanceof ProductNotFoundException) {
error = TypedGraphQLError.newNotFoundBuilder()
.message(exception.getMessage())
.path(params.getPath())
.build();
} else if (exception instanceof ValidationException ve) {
error = TypedGraphQLError.newBadRequestBuilder()
.message(exception.getMessage())
.extensions(Map.of("errors", ve.getErrors()))
.path(params.getPath())
.build();
} else {
error = TypedGraphQLError.newInternalErrorBuilder()
.message("Internal server error")
.path(params.getPath())
.build();
}
return CompletableFuture.completedFuture(
DataFetcherExceptionHandlerResult.newResult().error(error).build()
);
}
}
Authentication and Authorization
Spring Security Integration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/graphql").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
}
Secured Resolvers
@DgsComponent
@RequiredArgsConstructor
public class AdminMutationResolver {
private final ProductService productService;
@DgsMutation
@PreAuthorize("hasRole('ADMIN')")
public Product createProduct(@InputArgument CreateProductInput input) {
return productService.create(input);
}
@DgsMutation
@PreAuthorize("hasRole('ADMIN')")
public Boolean deleteProduct(@InputArgument String id) {
productService.delete(id);
return true;
}
}
Context-Based Authorization
@DgsComponent
public class UserQueryResolver {
@DgsQuery
public User currentUser(DgsDataFetchingEnvironment dfe) {
SecurityContext context = SecurityContextHolder.getContext();
Authentication auth = context.getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new AccessDeniedException("Not authenticated");
}
return userService.findByUsername(auth.getName());
}
}
Testing GraphQL APIs
DGS provides excellent testing utilities through the DgsQueryExecutor.
Integration Tests
@SpringBootTest
class ProductQueryTest {
@Autowired
DgsQueryExecutor dgsQueryExecutor;
@Test
void testProductQuery() {
String query = """
query {
product(id: "1") {
id
name
price
category {
name
}
}
}
""";
Product product = dgsQueryExecutor.executeAndExtractJsonPathAsObject(
query,
"data.product",
Product.class
);
assertThat(product.getName()).isNotNull();
assertThat(product.getPrice()).isPositive();
}
@Test
void testProductsWithPagination() {
String query = """
query($page: Int, $size: Int) {
products(page: $page, size: $size) {
content {
id
name
}
totalElements
hasNext
}
}
""";
ExecutionResult result = dgsQueryExecutor.execute(
query,
Map.of("page", 0, "size", 10)
);
assertThat(result.getErrors()).isEmpty();
DocumentContext context = JsonPath.parse(result.getData());
List<String> names = context.read("$.products.content[*].name");
assertThat(names).hasSizeLessThanOrEqualTo(10);
}
}
Mutation Tests
@Test
void testCreateProduct() {
String mutation = """
mutation($input: CreateProductInput!) {
createProduct(input: $input) {
id
name
price
}
}
""";
Map<String, Object> input = Map.of(
"name", "New Product",
"description", "Description",
"price", 99.99,
"categoryId", "1"
);
Product created = dgsQueryExecutor.executeAndExtractJsonPathAsObject(
mutation,
"data.createProduct",
new TypeRef<Product>() {},
Map.of("input", input)
);
assertThat(created.getId()).isNotNull();
assertThat(created.getName()).isEqualTo("New Product");
}
Common Mistakes to Avoid
Ignoring N+1 Query Problems
Always use DataLoaders for nested data fetching. Without them, performance degrades exponentially as data grows.
Allowing Overly Complex Queries
Implement query depth limiting and complexity analysis to prevent denial of service attacks.
# application.yml
dgs:
graphql:
max-query-depth: 10
complexity:
max: 100
Exposing Internal Errors
Never expose stack traces or internal details in GraphQL error responses. Use custom exception handlers to sanitize error messages.
Making DataLoaders Synchronous
DataLoaders should return CompletionStage for proper batching. Synchronous implementations defeat the purpose.
Forgetting Nullability in Schema
Be explicit about which fields can be null. Use ! for required fields and design your schema to match actual data constraints.
When to Choose GraphQL
GraphQL works best when:
- Multiple clients (web, mobile, dashboards) need different data shapes
- Frontend teams want to iterate without backend changes
- You need to aggregate data from multiple microservices
- API versioning has become a maintenance burden
- Over-fetching is causing performance issues
REST may be better for simple CRUD, file uploads, or high-frequency streaming workloads.
Conclusion
Building GraphQL APIs with Spring Boot and Netflix DGS gives you a powerful, flexible API layer that scales with your application. The schema-first approach ensures clean API design, while DataLoaders eliminate N+1 queries. Combined with Spring Security for authentication and DGS testing utilities for validation, you can build production-ready GraphQL services with confidence. The result is a fast, structured, and developer-friendly API that evolves without versioning headaches. To learn more about API strategies in Spring systems, read API Routing with Spring Cloud Gateway. For REST API design patterns, explore REST API Design Best Practices. For deeper technical details, visit the Netflix DGS documentation and the GraphQL official learning resources.