This post is for you if:
- You read (or skimmed) my REST vs GraphQL: Two Philosophies, Two Eras, One Endless Debate article
- You nodded along to "GraphQL thinks in queries, the client knows best"
- Then you thought: "Cool philosophy, but how do I actually build one?"
- You're a Java/Spring developer who learns by doing, not just reading
If that's you, welcome to Part 2. Philosophy time is over. Now we build.
The Context: Where We Left Off
In the previous article, I argued that GraphQL was born from Facebook's 2011 mobile nightmare—the realization that fetching a News Feed story shouldn't require 5+ HTTP round trips. We explored the philosophical difference:
REST asks: "What resource do you want?"
GraphQL asks: "What data do you need?"
We also saw Spring Framework 7 embrace API versioning as a first-class citizen, acknowledging that the real world is messier than academic purity.
Now, let's see what "the client knows best" looks like in actual Java code.
What We're Building
A simple Book API. Nothing fancy—just enough to understand the core concepts:
- Query a book by ID
- Get the book's author (demonstrating nested data fetching)
- See how GraphQL resolves relationships without multiple endpoints
By the end, you'll have a running GraphQL server and understand why the code is structured the way it is.
Prerequisites
Before we start, make sure you have:
- Java 25 (LTS as of September 2025—or Java 21 if you're not ready to upgrade yet)
- Maven 3.9.12 (the current stable release)
- Your favorite IDE (IntelliJ IDEA, VS Code with Java extensions, or even vim if you're that person)
Step 1: Create the Project
Head to Spring Initializr and configure:
| Setting | Value |
|---|---|
| Project | Maven |
| Language | Java |
| Spring Boot | 4.0.1 (latest stable) |
| Group | com.example |
| Artifact | bookstore-graphql |
| Name | bookstore-graphql |
| Packaging | Jar |
| Java | 25 |
Dependencies to add:
- Spring Web
- Spring for GraphQL
Click Generate, unzip, and open in your IDE.
Alternatively, here's the pom.xml you should end up with:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.1</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>bookstore-graphql</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>bookstore-graphql</name>
<description>GraphQL API with Spring Boot 4</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<!-- Spring Web (still needed for HTTP transport) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring for GraphQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<!-- Testing -->
<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>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
A note on versions: Spring Boot 4.0.1 ships with Spring for GraphQL 2.0.1, Spring Framework 7.0.2, and GraphQL Java 25.0. The versions are managed by the parent POM, so you don't need to specify them explicitly. This is one of Spring Boot's gifts to humanity.
Step 2: Define the Schema (Schema-First Development)
Here's something important: GraphQL is schema-first by design.
Unlike REST, where you might design your endpoints as you go, GraphQL forces you to think about your data contract upfront. The schema is the API documentation. The schema is the contract between client and server.
Create the file src/main/resources/graphql/schema.graphqls:
type Query {
bookById(id: ID!): Book
allBooks: [Book!]!
}
type Book {
id: ID!
title: String!
pageCount: Int
author: Author!
}
type Author {
id: ID!
firstName: String!
lastName: String!
}
Let me break down what's happening here:
type Query — This is the entry point. Every GraphQL API has a Query type that defines what clients can ask for. Think of it as your "read operations" menu.
bookById(id: ID!): Book — A query that takes a required (!) ID and returns a Book. The ! means "non-null"—the client must provide this argument.
[Book!]! — This reads as: "a non-null array of non-null Books." Yes, you can have null arrays OR arrays with null elements in GraphQL. The ! placement matters.
Nested types — Notice how Book has an author field of type Author. This is where GraphQL shines. The client can ask for just the book title, or dive deep into author details—same endpoint, same schema, different queries.
Step 3: Create the Domain Models
Now we need Java classes that match our schema. Java records are perfect for this—immutable, concise, and they generate equals(), hashCode(), and toString() for free.
Create src/main/java/com/example/bookstoregraphql/model/Book.java:
package com.example.bookstoregraphql.model;
public record Book(
String id,
String title,
Integer pageCount,
String authorId // Note: we store the ID, not the full Author object
) {
// Sample data - in reality, this would come from a database
private static final java.util.List<Book> BOOKS = java.util.List.of(
new Book("book-1", "Harry Potter and the Philosopher's Stone", 223, "author-1"),
new Book("book-2", "Moby Dick", 635, "author-2"),
new Book("book-3", "Interview with the Vampire", 371, "author-3"),
new Book("book-4", "The Great Gatsby", 180, "author-4"),
new Book("book-5", "Clean Code", 464, "author-5")
);
public static Book getById(String id) {
return BOOKS.stream()
.filter(book -> book.id().equals(id))
.findFirst()
.orElse(null);
}
public static java.util.List<Book> getAll() {
return BOOKS;
}
}
Create src/main/java/com/example/bookstoregraphql/model/Author.java:
package com.example.bookstoregraphql.model;
public record Author(
String id,
String firstName,
String lastName
) {
private static final java.util.List<Author> AUTHORS = java.util.List.of(
new Author("author-1", "Joanne", "Rowling"),
new Author("author-2", "Herman", "Melville"),
new Author("author-3", "Anne", "Rice"),
new Author("author-4", "F. Scott", "Fitzgerald"),
new Author("author-5", "Robert C.", "Martin")
);
public static Author getById(String id) {
return AUTHORS.stream()
.filter(author -> author.id().equals(id))
.findFirst()
.orElse(null);
}
}
💡 Production Note: We're using static lists inside records for simplicity. In a real application, you'd inject
BookRepositoryandAuthorRepository(Spring Data JPA, for example) and fetch data from a database. The GraphQL resolution pattern stays the same—only the data source changes.
Why authorId instead of Author in the Book record?
This is a deliberate design choice. In the GraphQL schema, Book.author is of type Author. But in our Java model, we store authorId as a String.
Why? Because GraphQL resolves fields lazily. If the client only asks for book.title, we never need to fetch the author. If they ask for book.author.firstName, then we resolve it. Storing the full Author object would force eager loading—exactly the N+1 problem we're trying to avoid.
Step 4: Create the Controller (Data Fetchers)
This is where the magic happens. Spring for GraphQL uses annotations that feel familiar if you've written REST controllers, but they work fundamentally differently.
Create src/main/java/com/example/bookstoregraphql/controller/BookController.java:
package com.example.bookstoregraphql.controller;
import com.example.bookstoregraphql.model.Author;
import com.example.bookstoregraphql.model.Book;
import org.springframework.graphql.data.method.annotation.Argument;
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 BookController {
// Maps to: Query.bookById(id: ID!)
@QueryMapping
public Book bookById(@Argument String id) {
return Book.getById(id);
}
// Maps to: Query.allBooks
@QueryMapping
public List<Book> allBooks() {
return Book.getAll();
}
// Maps to: Book.author
// This is called ONLY when the client requests the author field
@SchemaMapping
public Author author(Book book) {
return Author.getById(book.authorId());
}
}
Let me explain these annotations because they're doing a lot of work:
@QueryMapping — Binds this method to a field in the Query type. The method name (bookById) must match the field name in your schema. Spring for GraphQL registers this as a "data fetcher" for that field.
@Argument — Extracts a named argument from the GraphQL query. The parameter name (id) must match the argument name in your schema.
@SchemaMapping — This is the interesting one. It binds to a field on a type (not Query). When a client asks for book.author, GraphQL needs to know how to resolve that. The first parameter (Book book) tells Spring: "When resolving the author field on a Book, give me the parent Book object."
The key insight: @SchemaMapping methods are only called when needed. If the client's query doesn't include author, this method never executes. That's lazy resolution in action.
Checkpoint: What We've Built So Far
Before we test anything, let's make sure the mental model is clear.
Schema → Code Mapping:
| GraphQL Schema | Java Code | Notes |
|---|---|---|
type Query |
@QueryMapping methods |
Entry points for reads |
type Book |
record Book(...) |
Domain model |
author: Author! |
@SchemaMapping Author author(Book book) |
Lazy-resolved field |
id: ID! |
String id |
GraphQL ID maps to String
|
pageCount: Int |
Integer pageCount |
Nullable in schema → Integer (not int) |
The Resolution Flow:
- Client sends a query to
/graphql - Spring matches
bookByIdto@QueryMapping public Book bookById(...) - If the query includes
author, Spring calls@SchemaMapping public Author author(Book book) - If the query omits
author, that method is never invoked
This is the "client knows best" philosophy in action. The server doesn't decide what to return—it reacts to what's requested.
🧪 Quick Check: Before moving on, can you predict what happens if you:
- Query
bookByIdwith an ID that doesn't exist? - Query
allBooksbut only requesttitle—does theauthor()method run?
Hold those predictions. We'll test them in a moment.
Step 5: Enable GraphiQL (The Interactive Playground)
GraphiQL is an in-browser IDE for exploring GraphQL APIs. It provides auto-completion, documentation browsing, and query history. Essential for development.
Add to src/main/resources/application.properties:
# Enable GraphiQL playground
spring.graphql.graphiql.enabled=true
# Optional: customize the path (default is /graphiql)
# spring.graphql.graphiql.path=/playground
# Optional: show schema in GraphiQL
spring.graphql.schema.printer.enabled=true
Step 6: Run and Test
Start the application:
./mvnw spring-boot:run
Or from your IDE, run the main class.
Open your browser to: http://localhost:8080/graphiql
You should see the GraphiQL interface. Now let's run some queries.
Query 1: Get a book with minimal fields
query {
bookById(id: "book-1") {
id
title
}
}
Response:
{
"data": {
"bookById": {
"id": "book-1",
"title": "Harry Potter and the Philosopher's Stone"
}
}
}
Notice: We didn't ask for author, so the author() method in our controller was never called. No unnecessary database hits.
Query 2: Get a book with author details
query {
bookById(id: "book-1") {
id
title
pageCount
author {
firstName
lastName
}
}
}
Response:
{
"data": {
"bookById": {
"id": "book-1",
"title": "Harry Potter and the Philosopher's Stone",
"pageCount": 223,
"author": {
"firstName": "Joanne",
"lastName": "Rowling"
}
}
}
}
Same endpoint. Different data. Client decides.
🧪 Try this yourself: Before moving on, modify the query above to only request pageCount. Notice what happens—you get exactly what you asked for, nothing more. Now add author { firstName } back in. Watch how the response shape changes based purely on your query.
Query 3: Get all books (with selective fields)
query {
allBooks {
title
author {
lastName
}
}
}
Response:
{
"data": {
"allBooks": [
{ "title": "Harry Potter and the Philosopher's Stone", "author": { "lastName": "Rowling" } },
{ "title": "Moby Dick", "author": { "lastName": "Melville" } },
{ "title": "Interview with the Vampire", "author": { "lastName": "Rice" } },
{ "title": "The Great Gatsby", "author": { "lastName": "Fitzgerald" } },
{ "title": "Clean Code", "author": { "lastName": "Martin" } }
]
}
}
Query 4: Multiple queries in one request
query {
potter: bookById(id: "book-1") {
title
}
cleanCode: bookById(id: "book-5") {
title
author {
firstName
lastName
}
}
}
Response:
{
"data": {
"potter": {
"title": "Harry Potter and the Philosopher's Stone"
},
"cleanCode": {
"title": "Clean Code",
"author": {
"firstName": "Robert C.",
"lastName": "Martin"
}
}
}
}
Two queries, one HTTP request. Aliases (potter:, cleanCode:) let you query the same field multiple times with different arguments.
What Just Happened?
Let's reflect on what we built and why it matters.
The REST Equivalent Would Have Been:
GET /api/books/book-1 → Full book object (whether you need it all or not)
GET /api/books/book-1?include=author → Custom query param handling
GET /api/books/book-1/author → Separate endpoint for author
GET /api/books → All books (hope you don't need authors too)
GET /api/books?include=author → More custom handling
Five endpoints (at minimum), plus decisions about query parameters, response shapes, and documentation.
With GraphQL We Have:
POST /graphql
One endpoint. The query itself describes what you want. The schema documents what's possible.
The Tradeoffs (Because There Are Always Tradeoffs):
| Benefit | Cost |
|---|---|
| Client flexibility | Learning curve for clients |
| Self-documenting schema | Schema maintenance burden |
| No over-fetching | Potential for expensive queries |
| Single endpoint | Harder to cache at HTTP level |
| Strong typing | More upfront design work |
The N+1 Problem: A Preview
Remember the N+1 problem from the previous article? Our current implementation has it.
If you call allBooks and request author for each, the author() method gets called once per book. Five books = five Author.getById() calls.
In a real application with a database, that's five SQL queries when one would suffice.
The solution? Batching. Spring for GraphQL provides two options: the traditional DataLoader pattern (from GraphQL Java) or the Spring-native @BatchMapping annotation. Both batch those five requests into a single fetch. We'll cover both approaches in the next article.
For now, just know: production GraphQL requires batching. Don't skip it.
Project Structure Recap
bookstore-graphql/
├── pom.xml
└── src/main/
├── java/com/example/bookstoregraphql/
│ ├── BookstoreGraphqlApplication.java
│ ├── controller/
│ │ └── BookController.java
│ └── model/
│ ├── Book.java
│ └── Author.java
└── resources/
├── application.properties
└── graphql/
└── schema.graphqls
Troubleshooting Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
SchemaResourceResolver: No schema files found |
Schema file in wrong location | Must be at src/main/resources/graphql/schema.graphqls (note the .graphqls extension, not .graphql) |
No DataFetcher for field 'bookById' |
Method name doesn't match schema | Ensure @QueryMapping method name matches the field name in your schema exactly |
Cannot resolve symbol 'QueryMapping' |
Missing import | Add: import org.springframework.graphql.data.method.annotation.*;
|
| GraphiQL shows blank page | GraphiQL not enabled | Ensure spring.graphql.graphiql.enabled=true in application.properties
|
NullPointerException in author() method |
Book has authorId with no matching Author | Add null check: return author != null ? author : null; or ensure all author IDs have matching entries |
| Port 8080 already in use | Another app running | Add server.port=8081 to application.properties or stop the other process |
What's Next?
This tutorial covered the basics: schema definition, data fetchers, and the mental model of GraphQL resolution. But production GraphQL involves more:
- DataLoader and batching — Solving the N+1 problem
- Mutations — Creating, updating, and deleting data
- Subscriptions — Real-time data with WebSocket
- Error handling — Partial responses and error types
- Authentication & Authorization — Securing your schema
- Testing — Spring GraphQL's testing utilities
If there's interest, I'll continue this series. Let me know in the comments which topic you'd want next.
The Takeaway
GraphQL isn't magic. It's a different way of thinking about data access—one where the client describes what it needs rather than hoping the server guessed correctly.
Spring for GraphQL makes this approachable for Java developers. The annotations feel familiar. The integration with Spring Boot is seamless. And with Java records, your domain models stay clean.
But remember what I said in the philosophy article:
Technologies carry the DNA of the problems they solved.
GraphQL carries Facebook's DNA: complex relationships, mobile-first thinking, and client-driven data needs. If your use case matches, GraphQL is powerful. If you're building simple CRUD APIs for internal services, REST might still be the pragmatic choice.
The skill isn't knowing GraphQL. The skill is knowing when to use it.
Resources
- Spring for GraphQL Documentation
- GraphQL Java Book by Andreas Marek (the GraphQL Java maintainer)
- GraphQL Specification
- Source code for this tutorial (coming soon)
Found this useful? Have questions? Drop a comment below. And if you haven't read the philosophy piece yet, start there—it'll make everything here click.
Top comments (0)