Sitemap
Manish Pandey

Manish Pandey

Software Engineer at Mastercard

Spring GraphQL

20 min readSep 22, 2026

--

REST has been the default choice for building APIs for a long time.

For many applications, it works extremely well. You expose endpoints such as:

GET    /api/customers/101
GET /api/customers/101/orders
POST /api/orders
PUT /api/customers/101

As applications grow, however, clients often need different combinations of data.

A mobile application might need only:

customerId
name

while a web application might need:

customerId
name
email
orders
orderItems

With REST, this can lead to multiple endpoints, additional requests, or responses containing more data than the client actually needs.

GraphQL takes a different approach.

Instead of exposing many resource-oriented endpoints, you expose a typed API schema and allow the client to specify the data it wants.

Spring for GraphQL brings this programming model into the Spring ecosystem.

In this article, we’ll start with the GraphQL fundamentals, build a Spring Boot application, connect it to a database, create queries and mutations, understand resolvers, nested objects, arguments, variables, error handling, pagination, DataLoader, N+1 problems, security, testing, and finally look at how GraphQL fits into a production architecture.

1. What is GraphQL?

GraphQL is an API query language and runtime.

The key idea is simple:

The client describes the shape of the data it wants.

Suppose our application contains customers and orders.

A REST API might expose:

GET /customers/101

and return:

{
"id": 101,
"name": "Manish",
"email": "manish@example.com",
"phone": "9999999999",
"address": "Pune"
}

But perhaps the client only needs:

id
name

GraphQL allows the client to ask for exactly that.

query {
customer(id: 101) {
id
name
}
}

The response mirrors the query:

{
"data": {
"customer": {
"id": 101,
"name": "Manish"
}
}
}

If the client needs the email as well:

query {
customer(id: 101) {
id
name
email
}
}

The server can return:

{
"data": {
"customer": {
"id": 101,
"name": "Manish",
"email": "manish@example.com"
}
}
}

The client controls the requested response shape.

2. GraphQL vs REST

Suppose we have:

Customer
|
+--- Orders

In REST, you might have:

GET /customers/101
GET /customers/101/orders

The frontend might need two requests.

With GraphQL:

query {
customer(id: 101) {
id
name
        orders {
id
amount
status
}
}
}

One GraphQL operation can request the related data.

This is one of the reasons GraphQL can be useful for applications where clients have different data requirements.

3. GraphQL has a strongly typed schema

A GraphQL API is described using a schema.

For example:

type Customer {
id: ID!
name: String!
email: String!
}

This says:

  • Customer is an object type.
  • id is an ID.
  • name is a String.
  • email is a String.
  • ! means the field cannot be null.

The schema becomes the contract between the client and server.

4. GraphQL’s main building blocks

There are several concepts you should understand before writing Spring code.

Schema
|
+-- Query
|
+-- Mutation
|
+-- Subscription
|
+-- Types
|
+-- Fields
|
+-- Arguments
|
+-- Input Types
|
+-- Interfaces
|
+-- Unions
|
+-- Enums

The most commonly used ones are:

Query

Used to retrieve data.

query {
customer(id: 101) {
name
}
}

Mutation

Used to change data.

mutation {
createCustomer(
name: "Manish"
email: "manish@example.com"
) {
id
name
}
}

Subscription

Used for real-time updates.

For example:

subscription {
paymentStatusChanged(paymentId: "TX100") {
status
}
}

Subscriptions are useful for things such as live payment status, notifications, dashboards, and real-time monitoring.

5. Understanding the GraphQL schema

Let’s define our application.

We’ll build a small Customer and Order Management API.

Our domain looks like:

Customer
|
+---- Order
|
+---- Order
|
+---- Order

A customer has:

id
name
email

An order has:

id
amount
status

Our schema can be:

type Customer {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
amount: Float!
status: OrderStatus!
}
enum OrderStatus {
CREATED
PAID
CANCELLED
}

6. Query

Now define what clients can request.

type Query {
    customer(id: ID!): Customer    customers: [Customer!]!    orders: [Order!]!
}

This means the API supports:

customer(id: ID!)

and:

customers

and:

orders

7. Mutation

Now let’s allow clients to create customers.

type Mutation {
    createCustomer(
name: String!
email: String!
): Customer!
}

The client can call:

mutation {
createCustomer(
name: "Manish"
email: "manish@example.com"
) {
id
name
email
}
}

8. Setting up Spring Boot

Let’s build the project.

We’ll use:

Java 17+
Spring Boot
Spring for GraphQL
Spring Data JPA
PostgreSQL
Maven

A basic project structure:

graphql-demo
|
+-- src/main/java/com/example/graphql
| |
| +-- GraphqlDemoApplication.java
| |
| +-- customer
| | |
| | +-- Customer.java
| | +-- CustomerRepository.java
| | +-- CustomerController.java
| |
| +-- order
| |
| +-- Order.java
| +-- OrderRepository.java
|
+-- src/main/resources
|
+-- application.properties
|
+-- graphql
|
+-- schema.graphqls

9. Maven dependencies

For Spring Boot, add:

<dependencies>
    <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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.graphql</groupId>
<artifactId>spring-graphql-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

The exact dependency versions should come from the Spring Boot version you’re using rather than copying arbitrary versions from an old tutorial.

10. Application configuration

For PostgreSQL:

spring.datasource.url=jdbc:postgresql://localhost:5432/graphql_demo
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.graphql.graphiql.enabled=true

The last property enables GraphiQL, a browser-based interface useful for exploring and testing the GraphQL API.

11. Creating the database

Start PostgreSQL and create:

CREATE DATABASE graphql_demo;

You can also run PostgreSQL using Docker.

services:
  postgres:
image: postgres:16
container_name: graphql-postgres
environment:
POSTGRES_DB: graphql_demo
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"

Start it:

docker compose up -d

12. Creating the Customer entity

@Entity
@Table(name = "customers")
public class Customer {
    @Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name; private String email; // constructors public Customer() {
}
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
// getters and setters
}

13. Creating the Order entity

@Entity
@Table(name = "orders")
public class Order {
    @Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Double amount; @Enumerated(EnumType.STRING)
private OrderStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
public Order() {
}
// getters and setters
}

And:

public enum OrderStatus {
    CREATED,
PAID,
CANCELLED
}

14. Repositories

Customer repository:

public interface CustomerRepository
extends JpaRepository<Customer, Long> {
}

Order repository:

public interface OrderRepository
extends JpaRepository<Order, Long> {
    List<Order> findByCustomerId(Long customerId);
}

Now we have our persistence layer.

15. Creating the GraphQL schema

Create:

src/main/resources/graphql/schema.graphqls

Put this inside:

type Customer {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
amount: Float!
status: OrderStatus!
}
enum OrderStatus {
CREATED
PAID
CANCELLED
}
type Query { customer(id: ID!): Customer customers: [Customer!]! orders: [Order!]!
}
type Mutation { createCustomer(
name: String!
email: String!
): Customer!
}

This schema is now the contract for our API.

16. How Spring connects Java to GraphQL

This is one of the most important Spring GraphQL concepts.

In a traditional Spring MVC application you might write:

@GetMapping("/customers/{id}")

GraphQL doesn’t work primarily through one controller method per URL.

Instead, Spring GraphQL maps GraphQL fields to Java methods.

For example:

@QueryMapping
public Customer customer(Long id) {
return customerService.findById(id);
}

The @QueryMapping tells Spring:

This Java method handles the GraphQL customer query.

17. Customer GraphQL controller

Create:

@Controller
public class CustomerController {
    private final CustomerRepository customerRepository;    public CustomerController(
CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@QueryMapping
public Customer customer(@Argument Long id) {
return customerRepository
.findById(id)
.orElse(null);
}
@QueryMapping
public List<Customer> customers() {
return customerRepository.findAll();
}
}

Notice that this is:

@Controller

rather than:

@RestController

because we’re dealing with GraphQL mappings rather than conventional REST endpoint mappings.

18. Calling the query

Start the application.

Open GraphiQL.

You can execute:

query {
customer(id: 1) {
id
name
email
}
}

The response might be:

{
"data": {
"customer": {
"id": "1",
"name": "Manish",
"email": "manish@example.com"
}
}
}

Notice that the response structure follows the query structure.

19. Requesting only selected fields

Suppose the client asks:

query {
customer(id: 1) {
id
name
}
}

The response is:

{
"data": {
"customer": {
"id": "1",
"name": "Manish"
}
}
}

The client didn’t request email, so it doesn’t appear in the response.

This is one of the biggest differences from a traditional REST endpoint returning a fixed DTO.

20. Querying multiple customers

Our schema contains:

customers: [Customer!]!

and our Java method:

@QueryMapping
public List<Customer> customers() {
return customerRepository.findAll();
}

A client can request:

query {
customers {
id
name
email
}
}

Response:

{
"data": {
"customers": [
{
"id": "1",
"name": "Manish",
"email": "manish@example.com"
},
{
"id": "2",
"name": "Rahul",
"email": "rahul@example.com"
}
]
}
}

21. GraphQL arguments

Consider:

customer(id: ID!): Customer

The id is an argument.

Spring maps it using:

@Argument Long id

Example:

@QueryMapping
public Customer customer(@Argument Long id) {
    return customerRepository
.findById(id)
.orElse(null);
}

GraphQL:

query {
customer(id: 1) {
id
name
}
}

22. Mutations

Queries read data.

Mutations change data.

Add this to your controller:

@MutationMapping
public Customer createCustomer(
@Argument String name,
@Argument String email) {
    Customer customer =
new Customer(name, email);
return customerRepository.save(customer);
}

Now execute:

mutation {
createCustomer(
name: "John"
email: "john@example.com"
) {
id
name
email
}
}

GraphQL might return:

{
"data": {
"createCustomer": {
"id": "3",
"name": "John",
"email": "john@example.com"
}
}
}

23. Why GraphQL mutations are different from REST POST

REST usually communicates intent through HTTP methods:

GET
POST
PUT
PATCH
DELETE

GraphQL uses operations such as:

query
mutation
subscription

The transport is commonly HTTP, but GraphQL itself defines the operation semantics.

For example:

mutation {
createCustomer(...)
}

is a GraphQL mutation even though the request itself is typically sent through HTTP.

24. Input types

Passing many arguments individually can become awkward.

Instead of:

createCustomer(
name: String!
email: String!
phone: String!
address: String!
)

we can define an input type:

input CreateCustomerInput {
name: String!
email: String!
phone: String
address: String
}

Then:

type Mutation {
    createCustomer(
input: CreateCustomerInput!
): Customer!
}

The client can send:

mutation {
createCustomer(
input: {
name: "Manish"
email: "manish@example.com"
phone: "9999999999"
address: "Pune"
}
) {
id
name
email
}
}

Input types become especially useful as mutations become more complex.

25. Variables

Hardcoding values directly into a query isn’t ideal.

Instead of:

mutation {
createCustomer(
input: {
name: "Manish"
email: "manish@example.com"
}
) {
id
name
}
}

define variables:

mutation CreateCustomer($input: CreateCustomerInput!) {
    createCustomer(input: $input) {
id
name
email
}
}

Variables:

{
"input": {
"name": "Manish",
"email": "manish@example.com"
}
}

This is much more suitable for real frontend applications.

26. Nested fields

Now let’s request orders along with the customer.

Our schema says:

type Customer {
    id: ID!
name: String!
email: String!
orders: [Order!]!
}

A query can therefore be:

query {
customer(id: 1) {
        id
name
orders {
id
amount
status
}
}
}

This is where GraphQL becomes particularly interesting.

The Customer query returns the customer, but GraphQL also needs to figure out how to resolve:

Customer.orders

27. Field mappings

Spring GraphQL provides @SchemaMapping for resolving fields that aren’t simply direct properties.

For example:

@SchemaMapping
public List<Order> orders(Customer customer) {
    return orderRepository
.findByCustomerId(customer.getId());
}

This tells Spring:

When GraphQL asks for the orders field of a Customer, use this method.

Now the query:

query {
customer(id: 1) {
id
name
        orders {
id
amount
status
}
}
}

can resolve correctly.

28. Understanding the resolver flow

The request looks like:

GraphQL Query
|
↓
customer(id: 1)
|
↓
@QueryMapping
|
↓
Customer
|
↓
orders
|
↓
@SchemaMapping
|
↓
OrderRepository
|
↓
Orders

The GraphQL execution engine walks through the requested fields.

That’s an important mental model.

29. The N+1 problem

Now we hit a classic GraphQL performance problem.

Suppose the client requests:

query {
customers {
id
name
        orders {
id
amount
}
}
}

Suppose there are 100 customers.

Your application could execute:

1 query → fetch 100 customers
100 queries → fetch orders for each customer

Total:

101 database queries

This is called the N+1 problem.

It’s not unique to GraphQL, but nested GraphQL fields make it very easy to create.

30. DataLoader

One common solution is batching.

Instead of:

Customer 1 → query orders
Customer 2 → query orders
Customer 3 → query orders
...
Customer 100 → query orders

you can batch the IDs:

[1, 2, 3, ..., 100]

and execute one query:

SELECT *
FROM orders
WHERE customer_id IN (...)

Conceptually:

GraphQL
↓
100 customers
↓
DataLoader
↓
batch IDs
↓
one database query
↓
group results
↓
return orders

Spring for GraphQL provides integration with DataLoader patterns for this purpose.

This becomes important when you have deeply nested data.

31. Pagination

Returning thousands of records from:

customers

is usually not a good idea.

GraphQL APIs often use cursor-based pagination.

For example:

type CustomerConnection {
edges: [CustomerEdge!]!
pageInfo: PageInfo!
}
type CustomerEdge {
cursor: String!
node: Customer!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}

The query might look like:

query {
customers(first: 20) {
        edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

This approach scales much better than returning an unbounded list.

32. Error handling

Suppose the client asks:

query {
customer(id: 999999) {
id
name
}
}

There may not be a customer with that ID.

GraphQL responses have a standard structure that can contain both:

data
errors

For example:

{
"data": {
"customer": null
},
"errors": [
{
"message": "Customer not found"
}
]
}

This differs somewhat from the traditional REST approach where you might simply return:

404 Not Found

GraphQL has its own error propagation model.

33. Nullability matters

Consider:

name: String!

The ! means:

name cannot be null

Compare:

name: String

with:

name: String!

The first allows:

"name": null

The second does not.

The same concept applies to lists.

[Customer]

and:

[Customer!]!

have different nullability semantics.

Understanding nullability is important when designing a stable GraphQL schema.

34. Interfaces

GraphQL supports interfaces.

Suppose we have:

Payment
|
+-- CardPayment
|
+-- UPIPayment

We could define:

interface Payment {
id: ID!
amount: Float!
}

Then:

type CardPayment implements Payment {
id: ID!
amount: Float!
cardLast4: String!
}
type UpiPayment implements Payment {
id: ID!
amount: Float!
upiId: String!
}

Clients can query:

query {
payments {
id
amount
}
}

and request type-specific fields using fragments.

35. Fragments

Suppose:

type CardPayment implements Payment {
id: ID!
amount: Float!
cardLast4: String!
}

You can write:

query {
payments {
        id
amount
... on CardPayment {
cardLast4
}
}
}

Fragments allow reusable selections of fields.

Get Manish Pandey’s stories in your inbox

Join Medium for free to get updates from this writer.

For example:

fragment CustomerDetails on Customer {
id
name
email
}

Then:

query {
customer(id: 1) {
...CustomerDetails
}
}

36. Enums

We already used:

enum OrderStatus {
CREATED
PAID
CANCELLED
}

Enums restrict a field to known values.

That is much safer than:

status: String

where a client could send arbitrary strings such as:

"paid"
"PAID"
"Payed"
"completed"

A strongly typed enum reduces this class of problem.

37. GraphQL and Spring Security

GraphQL does not remove the need for authentication and authorization.

Suppose:

User A

requests:

customer(id: 100)

You may need to check whether that user is allowed to access customer 100.

Spring Security can still sit in front of the GraphQL endpoint.

A typical architecture is:

Client
↓
Authentication
↓
Spring Security
↓
GraphQL
↓
Authorization
↓
Service
↓
Database

Authentication answers:

Who is this user?

Authorization answers:

Is this user allowed to access this data?

Those are separate concerns.

38. Field-level authorization

GraphQL introduces another interesting problem.

Suppose:

type Customer {
    id: ID!
name: String!
email: String!
internalRiskScore: Int
}

A normal customer might be allowed to request:

id
name
email

but not:

internalRiskScore

This means authorization sometimes needs to be considered at the field level rather than only at the endpoint level.

GraphQL has one endpoint, but potentially many fields with different access requirements.

This is one of the biggest architectural differences compared with traditional REST APIs.

39. GraphQL endpoint

A Spring GraphQL application commonly exposes a GraphQL endpoint such as:

/graphql

The client sends GraphQL operations to that endpoint.

For example:

POST /graphql

with:

{
"query": "query { customers { id name } }"
}

Unlike REST, you don’t normally create:

/customers
/customers/{id}
/customers/{id}/orders

for every data shape.

The schema describes what can be queried.

40. GraphiQL

For development, GraphiQL is extremely useful.

You can write:

query {
customers {
id
name
email
}
}

and immediately see the response.

It also lets you explore the schema and understand available fields.

For developers working on GraphQL APIs, schema exploration becomes an important part of the development workflow.

41. Complete project structure

Let’s bring everything together.

graphql-demo
│
├── pom.xml
│
└── src
└── main
├── java
│ └── com.example.graphql
│ │
│ ├── GraphqlDemoApplication.java
│ │
│ ├── customer
│ │ ├── Customer.java
│ │ ├── CustomerRepository.java
│ │ └── CustomerController.java
│ │
│ └── order
│ ├── Order.java
│ ├── OrderStatus.java
│ └── OrderRepository.java
│
└── resources
│
├── application.properties
│
└── graphql
└── schema.graphqls

42. Main application

package com.example.graphql;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GraphqlDemoApplication {
public static void main(String[] args) { SpringApplication.run(
GraphqlDemoApplication.class,
args
);
}
}

43. Customer entity

package com.example.graphql.customer;
import jakarta.persistence.*;@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name; private String email; public Customer() {
}
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}

44. Order entity

package com.example.graphql.order;
import com.example.graphql.customer.Customer;
import jakarta.persistence.*;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Double amount; @Enumerated(EnumType.STRING)
private OrderStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
public Order() {
}
public Long getId() {
return id;
}
public Double getAmount() {
return amount;
}
public OrderStatus getStatus() {
return status;
}
public Customer getCustomer() {
return customer;
}
public void setAmount(Double amount) {
this.amount = amount;
}
public void setStatus(OrderStatus status) {
this.status = status;
}
public void setCustomer(Customer customer) {
this.customer = customer;
}
}

45. Repositories

package com.example.graphql.customer;
import org.springframework.data.jpa.repository.JpaRepository;public interface CustomerRepository
extends JpaRepository<Customer, Long> {
}

And:

package com.example.graphql.order;
import org.springframework.data.jpa.repository.JpaRepository;import java.util.List;public interface OrderRepository
extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
}

46. GraphQL controller

package com.example.graphql.customer;
import com.example.graphql.order.Order;
import com.example.graphql.order.OrderRepository;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;
import java.util.List;@Controller
public class CustomerController {
private final CustomerRepository customerRepository; private final OrderRepository orderRepository; public CustomerController(
CustomerRepository customerRepository,
OrderRepository orderRepository) {
this.customerRepository = customerRepository;
this.orderRepository = orderRepository;
}
@QueryMapping
public Customer customer(
@Argument Long id) {
return customerRepository
.findById(id)
.orElse(null);
}
@QueryMapping
public List<Customer> customers() {
return customerRepository.findAll();
}
@MutationMapping
public Customer createCustomer(
@Argument String name,
@Argument String email) {
Customer customer =
new Customer(name, email);
return customerRepository.save(customer);
}
@SchemaMapping
public List<Order> orders(Customer customer) {
return orderRepository
.findByCustomerId(customer.getId());
}
}

This small controller now handles:

Query:
customer
customers
Mutation:
createCustomer
Nested field:
Customer.orders

47. Schema

type Customer {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
amount: Float!
status: OrderStatus!
}
enum OrderStatus {
CREATED
PAID
CANCELLED
}
type Query { customer(id: ID!): Customer customers: [Customer!]! orders: [Order!]!
}
type Mutation { createCustomer(
name: String!
email: String!
): Customer!
}

48. Running the application

Start PostgreSQL:

docker compose up -d

Then run:

./mvnw spring-boot:run

Once Spring Boot starts, open your GraphiQL endpoint.

Depending on your Spring configuration/version, the GraphiQL URL may be:

/graphiql

The GraphQL endpoint itself is:

/graphql

49. Test the first query

Run:

query {
customers {
id
name
email
}
}

If customers exist, you’ll get:

{
"data": {
"customers": [
{
"id": "1",
"name": "Manish",
"email": "manish@example.com"
}
]
}
}

50. Create a customer

Run:

mutation {
createCustomer(
name: "Rahul"
email: "rahul@example.com"
) {
id
name
email
}
}

The response could be:

{
"data": {
"createCustomer": {
"id": "2",
"name": "Rahul",
"email": "rahul@example.com"
}
}
}

51. Query nested orders

Suppose customer 1 has three orders.

The client can ask:

query {
customer(id: 1) {
        id
name
orders {
id
amount
status
}
}
}

The response:

{
"data": {
"customer": {
"id": "1",
"name": "Manish",
"orders": [
{
"id": "101",
"amount": 500.0,
"status": "PAID"
},
{
"id": "102",
"amount": 1200.0,
"status": "CREATED"
}
]
}
}
}

One query produced a nested response.

52. What happens internally?

When the query arrives:

query {
customer(id: 1) {
id
name
orders {
id
amount
}
}
}

Spring GraphQL roughly follows this path:

HTTP POST /graphql
|
↓
GraphQL Engine
|
↓
Parse query
|
↓
Validate against schema
|
↓
Resolve customer
|
↓
@QueryMapping customer()
|
↓
Customer object
|
↓
Resolve orders
|
↓
@SchemaMapping orders()
|
↓
OrderRepository
|
↓
Response

The schema acts as the contract that determines whether the incoming query is valid.

53. GraphQL execution is field-oriented

This is an important architectural idea.

The GraphQL engine doesn’t simply call one Java method and serialize its result.

It resolves the requested fields.

For:

customer {
name
orders {
amount
}
}

the engine needs to resolve:

customer
├── name
└── orders
└── amount

This field-oriented execution model is why nested resolvers and DataLoader become important.

54. GraphQL schema design matters

One mistake is to think:

“I’ll expose all my database tables directly through GraphQL.”

That’s rarely a good API design.

Your database model and API model don’t have to be identical.

For example, your database may contain:

customer
customer_internal_notes
customer_risk
customer_security_data

but your public GraphQL API might expose only:

type Customer {
id: ID!
name: String!
email: String!
}

The GraphQL schema is an API contract, not a database dump.

55. GraphQL and DTOs

In production applications, you may not want to expose JPA entities directly.

Instead:

GraphQL
↓
DTO
↓
Service
↓
Repository
↓
Database

For example:

public record CustomerResponse(
Long id,
String name,
String email
) {
}

Then your service can convert:

Entity → DTO

This provides more control over the API contract.

It also prevents persistence concerns from leaking into your GraphQL layer.

56. Recommended production structure

For a larger application, I’d use something closer to:

graphql
|
+-- controller
|
+-- resolver
|
+-- dto
customer
|
+-- controller/service
+-- repository
+-- entity
order
|
+-- service
+-- repository
+-- entity
common
|
+-- exception
+-- security
+-- configuration

The exact package structure can vary, but keeping the GraphQL API layer separate from the domain and persistence layers helps as the application grows.

57. GraphQL security concerns

GraphQL introduces some security concerns that are easy to miss.

A malicious or poorly designed query could request deeply nested data:

customer
↓
orders
↓
customer
↓
orders
↓
customer
↓
...

You therefore need to think about:

  • query depth
  • query complexity
  • maximum page size
  • authorization
  • rate limiting
  • introspection policies
  • batching
  • expensive nested queries

GraphQL gives clients considerable flexibility, which means the server needs appropriate controls.

58. GraphQL query complexity

Imagine an API where a single query can trigger thousands of database operations.

A request such as:

query {
customers {
orders {
payments {
transactions {
merchant
}
}
}
}
}

may look innocent but could result in a huge amount of work.

Production GraphQL APIs often apply limits around query depth, complexity, pagination, and execution cost.

59. GraphQL caching

Caching GraphQL responses isn’t exactly the same as caching simple REST URLs.

A REST cache might see:

GET /customers/101

and cache the response.

GraphQL may have:

query {
customer(id: 101) {
id
name
}
}

and:

query {
customer(id: 101) {
id
name
email
}
}

These are different query documents.

Caching therefore needs to be considered at several levels:

Client cache
↓
DataLoader cache
↓
Application cache
↓
Database cache

The right strategy depends on the application.

60. GraphQL subscriptions

GraphQL also supports subscriptions for real-time updates.

Imagine a payment dashboard.

The client wants:

Payment status changes

A subscription could conceptually look like:

subscription {
paymentStatusChanged(paymentId: "TX100") {
paymentId
status
}
}

The server could push:

{
"paymentId": "TX100",
"status": "COMPLETED"
}

Subscriptions are useful for real-time applications, although their transport and infrastructure requirements need to be considered separately from ordinary query/mutation handling.

61. GraphQL in a microservices architecture

Suppose you have:

Customer Service
Order Service
Payment Service
Notification Service

A frontend may need:

Customer
Orders
Payment status

Without an aggregation layer, the frontend might need to communicate with multiple services.

GraphQL can act as an API composition layer:

Frontend
|
↓
GraphQL Gateway
/ | \
/ | \
↓ ↓ ↓
Customer Order Payment
Service Service Service

The GraphQL layer can compose data from multiple backend services.

This is one of the more interesting production use cases for GraphQL.

62. GraphQL is not automatically a microservices gateway

However, GraphQL doesn’t magically solve distributed-system problems.

If your GraphQL resolver makes:

10 service calls

for every request, your API may become slower rather than faster.

You still need to think about:

  • service latency
  • timeouts
  • retries
  • caching
  • batching
  • circuit breakers
  • authorization
  • partial failures
  • observability

GraphQL changes the API interface. It doesn’t remove distributed-system complexity.

63. GraphQL and observability

For production systems, monitor at least:

GraphQL operation
GraphQL field
Execution time
Database queries
Downstream service calls
Error rate
Query depth
Query complexity

For example:

Operation: CustomerWithOrders
Latency: 82 ms
customer resolver: 10 ms
orders resolver: 55 ms
database: 45 ms

This can help identify expensive resolvers.

64. Testing GraphQL

Spring for GraphQL provides testing support.

You can test an operation rather than calling the HTTP endpoint manually.

For example, a test can execute:

query {
customer(id: 1) {
id
name
}
}

and verify:

customer.id
customer.name

This gives you confidence that both the schema and resolver behavior are working correctly.

A good test suite should cover:

Valid queries
Invalid queries
Mutations
Validation
Authorization
Null handling
Nested fields
Error handling
Pagination

65. A production-minded GraphQL architecture

For a larger Spring Boot application, you might end up with:

Client
|
↓
API Gateway
|
↓
GraphQL API
|
+-----------+-----------+
| | |
↓ ↓ ↓
Customer Order Payment
Service Service Service
| | |
↓ ↓ ↓
DB DB DB

Inside the GraphQL service:

GraphQL Schema
↓
Resolvers
↓
Application Services
↓
DataLoaders
↓
Repositories / Downstream APIs

Security sits across the request path:

Authentication
↓
Authorization
↓
GraphQL
↓
Resolvers
↓
Services

66. REST or GraphQL?

Neither approach is universally better.

GraphQL can be particularly useful when:

  • clients need different data shapes
  • there are many related resources
  • frontend teams need flexibility
  • mobile clients need to minimize requests
  • multiple backend services need to be composed
  • the API has a complex data graph

REST can be simpler when:

  • resources map cleanly to endpoints
  • operations are straightforward
  • HTTP semantics are important
  • caching through HTTP infrastructure is central
  • clients don’t need highly flexible response shapes

Many organizations use both.

For example:

External API
↓
GraphQL
Internal service-to-service
↓
REST / gRPC / messaging

The architecture should follow the use case rather than choosing GraphQL simply because it is newer.

67. Common mistakes when learning GraphQL

Mistake 1: Thinking GraphQL is just another REST framework

It isn’t.

GraphQL introduces a schema and a query execution model.

Mistake 2: Exposing database entities directly

Your API schema should represent business concepts rather than simply mirroring tables.

Mistake 3: Ignoring N+1 queries

Nested fields can produce large numbers of database calls.

DataLoader and batching become important.

Mistake 4: Returning unlimited lists

Always think about pagination for potentially large collections.

Mistake 5: Ignoring authorization at the field level

One GraphQL endpoint can expose many different pieces of data.

Authorization needs to follow the data, not just the URL.

Mistake 6: Allowing unlimited query complexity

A flexible API can also become an expensive API if clients are allowed to request arbitrarily deep or large queries.

68. The complete mental model

If you’re coming from Spring MVC, think about the difference this way.

REST:

HTTP Request
↓
Controller
↓
Service
↓
Repository
↓
Database

GraphQL:

GraphQL Request
↓
Schema Validation
↓
GraphQL Execution
↓
Resolver
↓
Service
↓
Repository / API
↓
Database

The major change is the layer between the request and your business logic.

GraphQL introduces a typed schema and an execution engine that resolves the requested fields.

69. The complete small project

Our project now supports:

Query a customer

query {
customer(id: 1) {
id
name
email
}
}

Query customers

query {
customers {
id
name
email
}
}

Create a customer

mutation {
createCustomer(
name: "Manish"
email: "manish@example.com"
) {
id
name
email
}
}

Query customer orders

query {
customer(id: 1) {
id
name
        orders {
id
amount
status
}
}
}

At this point, you have a working Spring Boot + GraphQL + PostgreSQL application.

70. Where to go next

Once this project is working, I’d extend it in this order:

Phase 1
Basic queries
↓
Phase 2
Mutations
↓
Phase 3
Input types + variables
↓
Phase 4
Nested resolvers
↓
Phase 5
DataLoader
↓
Phase 6
Pagination
↓
Phase 7
Exception handling
↓
Phase 8
Spring Security
↓
Phase 9
Field-level authorization
↓
Phase 10
Caching
↓
Phase 11
Subscriptions
↓
Phase 12
Testing
↓
Phase 13
Observability
↓
Phase 14
GraphQL + microservices
↓
Phase 15
Production hardening

That progression takes you from simply knowing GraphQL syntax to understanding how GraphQL actually fits into a Spring-based production system.

71. Final takeaway

The easiest way to understand Spring for GraphQL is to separate three things.

GraphQL

Defines how clients describe the data they want.

query
mutation
subscription

GraphQL Schema

Defines what the API allows.

type Customer {
id: ID!
name: String!
email: String!
}

Spring for GraphQL

Connects that schema to your Java application.

@QueryMapping
public Customer customer(@Argument Long id) {
...
}

The complete flow is:

Client
|
| GraphQL Query
↓
GraphQL Endpoint
|
↓
Schema Validation
|
↓
GraphQL Execution
|
+---------+---------+
| |
↓ ↓
@QueryMapping @SchemaMapping
| |
+---------+---------+
|
↓
Service Layer
|
+---------+---------+
| |
↓ ↓
Repository Downstream API
| |
↓ ↓
Database Microservice

The most important thing to learn isn’t the annotations.

It is the relationship between:

Schema
↓
Query
↓
Resolver
↓
Service
↓
Data source

Once that becomes natural, concepts such as mutations, nested fields, DataLoader, pagination, authorization, subscriptions, and GraphQL federation become much easier to understand.

And if you’re coming from a Spring Boot + microservices background, GraphQL becomes particularly interesting when you start using it as a composition layer over multiple services rather than treating it as simply another way to expose a CRUD API.

--

--

Manish Pandey
Manish Pandey