Backend

GraphQL APIs with Spring Boot and Netflix DGS

GraphQL APIs With Spring Boot And Netflix DGS

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.

Leave a Comment