Products Consulting About Blog Contact Us Česky
arrow_back Back to blog

Schema-First GraphQL with Netflix DGS

Schema-First GraphQL with Netflix DGS

Part 1 of 7 in the “Production GraphQL with Netflix DGS” series


If you’re building a GraphQL API with Spring Boot, you’ve probably noticed two major frameworks competing for your attention: Netflix DGS and Spring for GraphQL. For years, teams had to pick a side. That choice no longer exists — and the story of how these frameworks merged is worth understanding before you write your first @DgsQuery.

Two Frameworks Become One

Netflix open-sourced the Domain Graph Service (DGS) framework in 2021. It was battle-tested at Netflix scale — hundreds of services, thousands of queries — and it offered something Spring didn’t have at the time: a polished, annotation-driven programming model for GraphQL.

Meanwhile, the Spring team (in collaboration with the GraphQL Java maintainers) built Spring for GraphQL, focusing on HTTP transport, WebSocket subscriptions, and deep integration with the Spring ecosystem.

For a while, these two frameworks evolved independently. Teams chose one or the other. Then, in March 2024, Netflix and the Spring team announced a deep integration: DGS 8.5.0 shipped with opt-in Spring for GraphQL support. By December 2024, DGS 10.0.0 removed all legacy code — Spring for GraphQL became the only execution engine.

Here’s what each framework owns today:

ConcernWho handles it
HTTP transport (WebMVC, WebFlux, RSocket)Spring for GraphQL
Query execution engineSpring for GraphQL
WebSocket / SSE subscriptionsSpring for GraphQL
Schema constructionDGS
Programming model (@DgsQuery, @DgsData, @DgsDataLoader)DGS
Code generationDGS
Test utilities (DgsQueryExecutor)DGS (proxying to Spring)

What this means for you: You get Spring’s robust transport layer and execution engine, combined with DGS’s developer-friendly annotations and code generation. You don’t have to choose — you get both.

The DGS starter to use is:

KOTLIN
implementation("com.netflix.graphql.dgs:graphql-dgs-spring-graphql-starter")

This single dependency gives you the integrated stack. The older graphql-dgs-spring-boot-starter (without “spring-graphql” in the name) is the legacy path and shouldn’t be used for new projects.

Schema-First Development

DGS embraces the schema-first approach: you write your GraphQL schema in .graphqls files, and the framework generates Java types from them. This is the opposite of code-first (where you annotate Java classes and the schema is derived), and it has a significant advantage — the schema becomes the contract.

Your schema is the single source of truth that frontend teams, API consumers, and documentation tools all reference. Code generation ensures your Java types always match.

Organizing Schema Files

A small API might fit in a single schema.graphqls. A production API with hundreds of operations won’t. Here’s a pattern that scales well: one root file for operations, domain-specific files for types.

Text
src/main/resources/schema/
├── schema.graphqls           # All Query and Mutation definitions
├── common_types.graphqls     # Shared scalars, enums, utility types
├── product_types.graphqls    # Product, ProductPage, CreateProductInput
├── order_types.graphqls      # Order, OrderItem, OrderStatus
├── user_types.graphqls       # User, UserSettings, UserPage
├── inventory_types.graphqls  # Stock, Warehouse, StockMovement
└── ...                       # One file per domain

The critical rule: all Query and Mutation definitions live in schema.graphqls only. Do not use extend type Query in domain-specific files. DGS (and graphql-java underneath) will fail at startup with duplicate type definitions if you do. This is a common pitfall that wastes hours of debugging:

GRAPHQL
# schema.graphqls — the ONLY file with Query/Mutation blocks

type Query {
    # Product queries
    product(id: ID!): Product
    products(
        searchText: String,
        category: ProductCategory,
        pageNumber: Int!,
        pageSize: Int!,
        sortBy: String,
        sortOrder: SortOrder
    ): ProductPage

    # Order queries
    order(id: ID!): Order
    myOrders(pageNumber: Int!, pageSize: Int!): OrderPage

    # User queries
    me: User
}

type Mutation {
    createProduct(input: CreateProductInput!): Product
    updateProduct(input: UpdateProductInput!): Product
    placeOrder(input: PlaceOrderInput!): Order
}
GRAPHQL
# product_types.graphqls — ONLY type definitions, no Query/Mutation

type Product {
    id: ID!
    name: String!
    description: String
    price: Float!
    category: ProductCategory!
    createdAt: DateTime
    warehouse: Warehouse      # Resolved by a field resolver
    stockLevel: Int            # Resolved by a data loader
}

type ProductPage {
    items: [Product]
    totalPages: Int
    currentPage: Int
    totalElements: Int
}

input CreateProductInput {
    name: String!
    description: String
    price: Float!
    category: ProductCategory!
}

enum ProductCategory {
    ELECTRONICS
    CLOTHING
    FOOD
    BOOKS
}

This separation keeps each file focused and makes it easy to find where a type is defined — types are always in {domain}_types.graphqls, operations are always in schema.graphqls.

Custom Scalars

GraphQL’s built-in scalars (String, Int, Float, Boolean, ID) don’t cover everything. DGS supports extended scalars out of the box:

GRAPHQL
# common_types.graphqls
scalar Date       # java.time.LocalDate
scalar DateTime   # java.time.OffsetDateTime
scalar JSON       # java.util.Map
scalar Long       # java.lang.Long

The mapping between GraphQL scalars and Java types is configured in your build file (more on this in the code generation section below).

Your First Query and Mutation

DGS uses a small set of annotations to wire your Java code to the schema. Let’s build a simple product API.

Queries with @DgsQuery

Java
@DgsComponent
@RequiredArgsConstructor
public class ProductDataFetcher {

    private final ProductService productService;

    @DgsQuery
    public Mono<Product> product(@InputArgument String id) {
        return productService.findById(id);
    }

    @DgsQuery
    public Mono<ProductPage> products(
            @InputArgument String searchText,
            @InputArgument ProductCategory category,
            @InputArgument Integer pageNumber,
            @InputArgument Integer pageSize,
            @InputArgument String sortBy,
            @InputArgument SortOrder sortOrder) {

        int page = pageNumber != null ? pageNumber : 0;
        int size = pageSize != null ? pageSize : 10;

        return productService.search(searchText, category, page, size, sortBy, sortOrder);
    }
}

A few things to note:

  • @DgsComponent marks the class as a DGS data fetcher container. It’s a Spring @Component under the hood, so dependency injection works normally.
  • @DgsQuery maps the method to a field on the Query type. The method name must match the schema field name (product maps to Query.product).
  • @InputArgument binds GraphQL arguments to method parameters. DGS handles the type conversion — GraphQL Int becomes Java Integer, input objects become generated Java types.
  • Return types can be reactive. Mono<T> and CompletableFuture<T> are both supported. Plain synchronous returns work too.

Mutations with @DgsMutation

Java
@DgsComponent
@RequiredArgsConstructor
public class CreateProductMutation {

    private final ProductService productService;

    @DgsMutation
    @PreAuthorize("hasRole('ADMIN')")
    public Mono<Product> createProduct(@InputArgument CreateProductInput input) {
        return productService.create(input);
    }
}

The pattern is identical to queries — the only difference is the annotation. Notice @PreAuthorize from Spring Security works directly on DGS methods. This is one of the benefits of the Spring for GraphQL integration: Spring’s security infrastructure is fully available.

Separating Queries and Mutations

You don’t need to put all operations in one class. In fact, splitting them into focused classes makes the codebase easier to navigate:

Text
com.example.product.graphql/
├── ProductDataFetcher.java       # @DgsQuery methods
├── CreateProductMutation.java    # @DgsMutation for create
├── UpdateProductMutation.java    # @DgsMutation for update
└── ProductStockResolver.java     # @DgsData field resolver

Each class is a @DgsComponent with a single responsibility. At scale, this means you can find any operation by domain and action type without scrolling through a 500-line god class.

Code Generation

One of DGS’s most practical features is its code generation plugin. It reads your .graphqls files and generates:

  • Java types for every GraphQL type, input, and enum
  • Client query classes for type-safe client-side operations
  • Interfaces (optional) for polymorphic types

Gradle Configuration

KOTLIN
plugins {
    id("com.netflix.dgs.codegen") version "8.3.0"
}

tasks.withType<com.netflix.graphql.dgs.codegen.gradle.GenerateJavaTask> {
    generateClient = true
    packageName = "com.example.generated"
    typeMapping = mutableMapOf(
        "Date" to "java.time.LocalDate",
        "DateTime" to "java.time.OffsetDateTime",
        "JSON" to "java.util.Map",
        "Long" to "java.lang.Long"
    )
    generateInterfaces = true
    generateDataTypes = true
    schemaPaths = mutableListOf("${projectDir}/src/main/resources/schema/")
}

Key settings:

  • typeMapping — maps custom scalars to Java types. Without this, DateTime fields would generate as Object.
  • generateClient = true — generates type-safe query builder classes (useful for testing and inter-service calls).
  • generateInterfaces = true — generates interfaces for types, which helps with mapping patterns.
  • schemaPaths — points to your schema directory. The plugin reads all .graphqls files recursively.

After running ./gradlew generateJava, you’ll find generated types in build/generated/sources/dgs-codegen/:

Text
build/generated/sources/dgs-codegen/
└── com/example/generated/
    ├── types/
    │   ├── Product.java           # Full type with builder
    │   ├── ProductPage.java       # Pagination wrapper
    │   ├── CreateProductInput.java # Input type
    │   ├── ProductCategory.java   # Enum
    │   └── SortOrder.java         # Shared enum
    └── client/
        ├── ProductGraphQLQuery.java    # Type-safe query builder
        └── ProductProjectionRoot.java  # Field selection builder

These generated types are what your DGS data fetchers return. You never manually create DTOs that mirror the schema — the codegen does it for you.

The Three-Layer Type System

In a production application, you typically have three layers of types, and it’s important to keep them separate:

graph LR A["GraphQL Schema
product_types.graphqls"] -->|codegen| B["Generated Types
codegen Product.java"] B ---|"ACL"| ACL1["Mapper"] ACL1 --- C["Domain Model
Product record"] C ---|"ACL"| ACL2["Mapper"] ACL2 --- D["Database Entities
ProductNode.java"] style A fill:#4a9eff,stroke:#2171c7,color:#fff style B fill:#7c4dff,stroke:#5e35b1,color:#fff style ACL1 fill:#ffd54f,stroke:#f9a825,color:#333 style C fill:#00bfa5,stroke:#00897b,color:#fff style ACL2 fill:#ffd54f,stroke:#f9a825,color:#333 style D fill:#ff7043,stroke:#e64a19,color:#fff
  1. Generated types — what DGS returns to clients. Auto-generated, never edited by hand.
  2. Domain types — your business model. Plain Java records, framework-agnostic. These are what you pass between services.
  3. Database entities — annotated for your persistence layer (JPA, R2DBC, etc.).

Mappers as Anti-Corruption Layers

The mappers between these layers aren’t just boilerplate — they implement the Anti-Corruption Layer (ACL) pattern from Domain-Driven Design. The idea is straightforward: when two models meet (your domain and an external concern like a database schema or a public API contract), you place a translation layer between them so that changes in one model don’t corrupt the other.

In a DGS application, you have two ACL boundaries:

ACL 1: Domain ↔ GraphQL (the API boundary)

The GraphQL schema is a public contract. Clients depend on field names, types, and structure. Your domain model, on the other hand, should reflect business rules and evolve freely. The mapper between generated types and domain types is the ACL that decouples them:

Java
@Mapper(componentModel = "spring")
public interface ProductMapper {

    // Domain → GraphQL (for responses)
    com.example.generated.types.Product toGraphQL(com.example.domain.Product product);

    // GraphQL input → Domain (for mutations)
    com.example.domain.Product toDomain(CreateProductInput input);
}

Without this ACL, renaming a domain field means renaming a GraphQL field — which is a breaking API change. With the mapper, you rename the domain field and update the mapping. The API contract stays stable.

ACL 2: Domain ↔ Database (the persistence boundary)

Your database schema has its own concerns: column naming conventions, denormalization for query performance, audit columns (created_at, updated_by), soft-delete flags. None of this belongs in your domain model. The persistence mapper absorbs these differences:

Java
@Mapper(componentModel = "spring")
public interface ProductPersistenceMapper {

    // Database entity → Domain
    com.example.domain.Product toDomain(ProductEntity entity);

    // Domain → Database entity
    ProductEntity toEntity(com.example.domain.Product product);
}

This means you can restructure your database (split a table, add a caching layer, migrate from SQL to a document store) without touching a single line of business logic.

Why Not Skip the Domain Layer?

Teams sometimes shortcut by mapping directly from database entities to GraphQL types — two layers instead of three. This works for small APIs, but it creates a hidden coupling: your public API contract is now tied to your database schema. Every column rename, every table restructure, every persistence optimization risks breaking the API.

The domain layer in the middle is the stable core. It doesn’t know about GraphQL annotations or database columns. It’s where your business rules live, where your unit tests assert behavior, and where your event-sourced aggregates (if you use them) operate. The two ACLs on either side protect it from infrastructure churn.

Text
Database changes  →  ACL absorbs  →  Domain unchanged  →  ACL absorbs  →  API unchanged

In practice, the cost of maintaining two mapper interfaces is low — especially with MapStruct generating the implementation at compile time. The cost of not having them surfaces the first time you need to evolve your database or API independently.

Putting It All Together

Here’s the complete flow for a product(id: "123") query:

sequenceDiagram participant Client participant Spring as Spring for GraphQL participant DGS participant Fetcher as ProductDataFetcher participant Service as ProductService participant DB as Database Client->>Spring: GraphQL query Spring->>DGS: Route to resolver DGS->>Fetcher: product(id: "123") Note over DGS,Fetcher: @InputArgument binds "123" Fetcher->>Service: findById("123") Service->>DB: SELECT * FROM products DB-->>Service: ProductEntity Service-->>Fetcher: Domain Product Note over Fetcher: Mapper → Generated Type Fetcher-->>DGS: Generated Product DGS-->>Spring: Serialized JSON Spring-->>Client: HTTP Response

At every step, type safety is enforced — the schema defines the contract, codegen creates the types, and the compiler catches mismatches.

What’s Next

In Part 2, we’ll tackle the most impactful DGS feature for production performance: data loaders and field resolvers. You’ll learn how to prevent N+1 query problems and keep your GraphQL API fast as the schema grows.


Cover photo by Jakub Zerdzicki on Unsplash.

More from the Blog