GraphQL Guide

MaverickMaverick
18 min read

Warning: This blog is going to be longer than a Monday morning standup! ☕️ So grab your favorite beverage, get comfy, and let's embark on a GraphQL adventure together. Whether you're a total newbie or a seasoned dev, this is your one-stop shop for all things GraphQL in Spring Boot!


Table of Contents

  1. Introduction to GraphQL

  2. Core Concepts

  3. Setting Up a GraphQL Server

  4. Deep Dive into GraphQL Syntax

  5. Advanced Concepts

  6. Working with the Frontend

  7. Testing in GraphQL using Spring Boot

  8. Full Use-Case Example: Library Management System

  9. Performance Optimization

  10. Authorization and Authentication

  11. GraphQL Best Practices

  12. FAQs and Common Pitfalls

  13. Conclusion & Further Resources


1. Introduction to GraphQL

What is GraphQL?

GraphQL is a query language for APIs and a runtime for executing those queries by describing your data's shape. Developed by Facebook in 2012 and open-sourced in 2015, GraphQL provides a more efficient, powerful, and flexible alternative to REST.

Key Differences from other protocols

Feature / ProtocolRESTgRPCSOAPODataJSON-RPCGraphQL
Data FormatJSON, XMLProtocol Buffers (binary)XMLJSON, XMLJSONJSON
Schema/ContractNone or OpenAPIProto files (contract-first)WSDL (contract-first)Metadata documentConventionSDL (Schema Definition Language)
EndpointsMultipleMultipleMultipleMultipleSingleSingle
QueryingFixed, via endpointsFixed, via methodsFixed, via operationsQuery via URL paramsMethod callsFlexible, via queries
Over/Under-fetchingCommonUncommonCommonUncommonUncommonAvoided (precise queries)
VersioningVia URL/headerVia proto versionVia namespace/versionVia URLBy conventionSchema evolution, no versioning
Streaming/RealtimeNoYes (bi-directional)NoNoNoSubscriptions (not full duplex)
IntrospectionNoNoNoPartialNoYes
Error HandlingHTTP codesgRPC status codesSOAP FaultsHTTP codesError objectCustom error objects
ToolingMature, broadStrong, codegenEnterprise, legacyMicrosoft, enterpriseSimpleModern, introspective
Browser-nativeYesNo (needs proxy)YesYesYesYes
Client Code GenerationOptionalRequiredOptionalOptionalOptionalNot required
Use CasePublic APIs, webInternal microservicesEnterprise, legacy systemsEnterprise, data APIsLightweight RPCFlexible APIs, frontend-backend

Benefits of Using GraphQL

  • Ask for exactly what you need (no more, no less)

  • Single endpoint for all data

  • Strongly typed schema

  • Real-time data with subscriptions

  • Introspective (self-documenting)


2. Core Concepts

A visual overview of a GraphQL schema:

Schemas

A schema defines the types, queries, mutations, and subscriptions available in your API.

Example: Full Schema with Query, Mutation, and Subscription

type Query {
  hello: String
  bookById(id: ID!): Book
  books: [Book]
}

type Mutation {
  addBook(title: String!, author: String!): Book
  updateBook(id: ID!, title: String, author: String): Book
  deleteBook(id: ID!): Boolean
}

type Subscription {
  bookAdded: Book
  bookUpdated: Book
}

type Book {
  id: ID!
  title: String!
  author: String!
}

Explanation:

  • Query: For fetching data (read operations)

  • Mutation: For modifying data (write operations)

  • Subscription: For real-time updates (push from server to client)

  • Book: A custom object type


GraphQL Query Examples (From Simple to Complex)

1. Simple Query: Fetch a List

query {
  books {
    id
    title
  }
}

Explanation: Fetches all books, returning only their id and title fields.

2. Query with Arguments

query {
  bookById(id: 1) {
    title
    author
  }
}

Explanation: Fetches a single book by its ID, returning its title and author.

3. Query with Aliases

query {
  first: bookById(id: 1) { title }
  second: bookById(id: 2) { title }
}

Explanation: Fetches two books by different IDs and gives them custom names in the response.

4. Query with Variables

query GetBook($bookId: ID!) {
  bookById(id: $bookId) {
    title
    author
  }
}

Variables:

{
  "bookId": 1
}

Explanation: Makes the query reusable by passing the book ID as a variable.

5. Nested Query (with Relationships)

query {
  books {
    title
    author
    reviews {
      rating
      comment
      reviewer {
        name
      }
    }
  }
}

Explanation: Fetches all books and, for each book, fetches its reviews and the reviewer's name. Demonstrates GraphQL's power to traverse relationships in a single request.

6. Query with Fragments

fragment bookFields on Book {
  id
  title
  author
}

query {
  books {
    ...bookFields
  }
}

Explanation: Defines a reusable fragment for book fields and uses it in the query.

7. Query with Inline Fragments (for Unions/Interfaces)

query {
  search(text: "science") {
    ... on Book {
      title
      author
    }
    ... on Author {
      name
      books { title }
    }
  }
}

Explanation: Handles a search that can return multiple types (e.g., Book or Author) using inline fragments.

8. Query with Directives

query getBooks($showAuthor: Boolean!) {
  books {
    title
    author @include(if: $showAuthor)
  }
}

Explanation: Conditionally includes the author field based on the value of the $showAuthor variable.

9. Complex Query with Pagination and Filtering

query {
  books(page: 2, pageSize: 5, filter: { author: "Asimov" }) {
    id
    title
    author
  }
}

Explanation: Fetches a paginated and filtered list of books, showing how GraphQL can support advanced querying patterns.


Tip: As your queries grow in complexity, use fragments, variables, and directives to keep them maintainable and efficient. Always request only the fields you need!


3. Setting Up a GraphQL Server

3.1. Spring Boot + GraphQL: The Dream Team

Step 1: Add Dependencies

Use Spring Initializr and add:

  • Spring Web

  • Spring Boot Starter GraphQL

  • Spring Boot Starter Data JPA (for database)

  • H2 Database (for demo)

Or add to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Step 2: Create a Spring Boot Project

  • Go to Spring Initializr

  • Choose Java, Maven, and add the dependencies above

  • Download and unzip the project

Step 3: Define Your Schema

Create a file src/main/resources/graphql/schema.graphqls:

# schema.graphqls

type Query {
    hello: String
    books: [Book]
    bookById(id: ID!): Book
}

type Mutation {
    addBook(title: String!, author: String!): Book
}

type Book {
    id: ID!
    title: String!
    author: String!
}

Step 4: Create Entity and Repository

// Book.java
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String author;
    // getters and setters
}

// BookRepository.java
public interface BookRepository extends JpaRepository<Book, Long> {}

Step 5: Create Resolvers

// BookResolver.java
@Component
public class BookResolver {
    @Autowired
    private BookRepository bookRepository;

    @QueryMapping
    public List<Book> books() {
        return bookRepository.findAll();
    }

    @QueryMapping
    public Book bookById(@Argument Long id) {
        return bookRepository.findById(id).orElse(null);
    }

    @MutationMapping
    public Book addBook(@Argument String title, @Argument String author) {
        Book book = new Book();
        book.setTitle(title);
        book.setAuthor(author);
        return bookRepository.save(book);
    }
}

Step 6: Run Your Application


4. Deep Dive into GraphQL Syntax

GraphQL syntax is designed to be intuitive and readable, making it easy for both humans and machines to understand. In this section, we'll break down the key elements of GraphQL syntax, show how to use them, and provide best practices and tips for each.

Writing Queries

A query is how you ask for data in GraphQL. You specify exactly what fields you want, and the server returns only those fields. Queries can be nested, use arguments, and even include variables for dynamic data fetching.

  • Basic Query:

    • Fetches all books with their id, title, and author fields.
  • Nested Query:

    • You can request related data in a single query (e.g., a book and its reviews).
  • Arguments:

    • Pass arguments to filter or customize the data you want (e.g., bookById(id: 1)).
  • Aliases:

    • Rename fields in the response for clarity or to fetch the same field with different arguments.
  • Variables:

    • Use variables to make queries reusable and dynamic (especially useful for client-side code).

Example: Query with Variables

query GetBook($bookId: ID!) {
  bookById(id: $bookId) {
    title
    author
  }
}

Variables:

{
  "bookId": 1
}

Mutations

Mutations are used to modify data (create, update, delete). They look like queries but use the mutation keyword. Mutations can also return data, so you can get the new or updated object immediately.

  • Input Types:

    • Use input types to group mutation arguments for clarity and validation.
  • Return Types:

    • Mutations can return any type, including custom types or unions for error handling.

Subscriptions

Subscriptions enable real-time data by allowing the server to push updates to the client. They are typically used for notifications, live feeds, or collaborative apps.

  • How it works:

    • The client subscribes to an event (e.g., bookAdded), and the server pushes data whenever that event occurs.
  • Transport:

    • Usually implemented over WebSockets.

Directives

Directives are special instructions in a query that modify execution. The most common are @include and @skip, which conditionally include or skip fields.

  • @include:

    • Includes a field only if the condition is true.
  • @skip:

    • Skips a field if the condition is true.
  • Custom Directives:

    • You can define your own directives for advanced use cases (e.g., formatting, authorization).

Resolvers

Resolvers are functions that provide the instructions for turning a GraphQL operation (query, mutation, or subscription) into data. Every field in a GraphQL schema is backed by a resolver function, which fetches or computes the value for that field.

  • How Resolvers Work:

    • When a query is executed, GraphQL calls the resolver for each field requested.

    • If you don't provide a custom resolver, GraphQL uses a default resolver that simply returns the property of the same name from the parent object.

  • Custom Resolvers:

    • You can write custom logic for fetching data, combining sources, or applying business rules.
  • Example:

      @QueryMapping
      public Book bookById(@Argument Long id) {
          return bookRepository.findById(id).orElse(null);
      }
    
  • Field Resolvers:

    • Used for nested or computed fields (e.g., resolving author on a Book).

Fragments

Fragments allow you to define reusable sets of fields and include them in multiple queries, mutations, or subscriptions. This helps keep your queries DRY and maintainable.

  • Named Fragments:

    • Define a fragment and use it in your queries.

    • Example:

        fragment bookFields on Book {
          id
          title
          author
        }
      
        query {
          books {
            ...bookFields
          }
        }
      
  • Inline Fragments:

    • Used for querying fields on union or interface types.

    • Example:

        query {
          search(text: "GraphQL") {
            ... on Book {
              title
            }
            ... on Author {
              name
            }
          }
        }
      
  • Why Use Fragments?

    • Reduce repetition in queries

    • Make queries easier to maintain and update

Best Practices

  • Always request only the fields you need to minimize over-fetching.

  • Use variables for dynamic queries and to avoid hardcoding values.

  • Use fragments for maintainability and consistency.

  • Leverage aliases to handle multiple fields of the same type or to clarify responses.

  • Use input types for complex mutation arguments.

  • Document your schema and queries for better collaboration.

Tip: GraphQL queries are strongly typed and validated against the schema before execution, so you'll catch errors early and get helpful feedback from your tools and IDEs.


5. Advanced Concepts

Directives

query getBooks($withAuthor: Boolean!) {
  books {
    title
    author @include(if: $withAuthor)
  }
}

Custom Scalars

Define custom types (e.g., DateTime) in your schema and implement them in Java.

Custom scalars allow you to represent data types that are not covered by the default GraphQL scalars (Int, Float, String, Boolean, ID). Common use cases include dates, timestamps, URLs, JSON objects, or any domain-specific value (e.g., Currency, GeoPoint).

Why Use Custom Scalars?

  • Enforce domain-specific validation and parsing (e.g., only accept valid ISO8601 dates)

  • Improve schema expressiveness and documentation

  • Prevent misuse of generic types like String for structured data

Example: Defining a Custom Scalar in Schema

scalar DateTime

type Event {
  id: ID!
  name: String!
  start: DateTime!
}

Example: Java Implementation of a Custom Scalar

@Component
public class DateTimeScalar extends GraphQLScalarType {
    public DateTimeScalar() {
        super("DateTime", "Custom DateTime scalar", new Coercing<LocalDateTime, String>() {
            @Override
            public String serialize(Object dataFetcherResult) {
                return ((LocalDateTime) dataFetcherResult).toString();
            }
            @Override
            public LocalDateTime parseValue(Object input) {
                return LocalDateTime.parse(input.toString());
            }
            @Override
            public LocalDateTime parseLiteral(Object input) {
                return LocalDateTime.parse(input.toString());
            }
        });
    }
}

Example: Using a Custom Scalar in a Query

query {
  event(id: "1") {
    name
    start
  }
}

Output:

{
  "data": {
    "event": {
      "name": "GraphQL Summit",
      "start": "2025-07-02T10:00:00"
    }
  }
}

Other Common Custom Scalars

  • URL (validate and parse URLs)

  • JSON (arbitrary JSON objects)

  • BigDecimal (for precise financial data)

  • Email (validate email addresses)

Best Practice: Always document your custom scalars and provide clear error messages for invalid values. Consider using libraries like graphql-java-extended-scalars for common types.

Error Handling

  • Use GraphQLError in resolvers

  • Return meaningful error messages

Example: Throwing a GraphQLError in Java

@QueryMapping
public User user(@Argument String id) {
    return userService.findById(id)
        .orElseThrow(() -> new GraphqlErrorException.Builder()
            .message("User not found")
            .build());
}

Example: Custom Error Extensions

@QueryMapping
public User user(@Argument String id) {
    return userService.findById(id)
        .orElseThrow(() -> GraphqlErrorException.newErrorException()
            .message("User not found")
            .extensions(Map.of("code", "USER_NOT_FOUND", "id", id))
            .build());
}

Example: Error Handling in Schema

type Error {
  message: String!
  code: String
}

union UserResult = User | Error

type Query {
  user(id: ID!): UserResult
}

Example: Handling Validation Errors

@MutationMapping
public User createUser(@Argument String email) {
    if (!email.contains("@")) {
        throw GraphqlErrorException.newErrorException()
            .message("Invalid email address")
            .build();
    }
    return userService.create(email);
}

Combining with Other APIs

  • Call REST/gRPC services inside resolvers

Data Fetchers & Advanced Resolvers

// CustomDataFetcher.java
@Component
public class CustomDataFetcher implements DataFetcher<String> {
    @Override
    public String get(DataFetchingEnvironment env) {
        // custom logic
        return "Hello from DataFetcher!";
    }
}

GraphQL Playground

  • Use /graphiql or /playground endpoint

  • Test queries, mutations, and subscriptions interactively

Optimizing with DataLoader

  • Batch and cache requests to avoid N+1 problems

6. Working with the Frontend

Consuming GraphQL in React (Apollo Client)

import { useQuery, gql } from '@apollo/client';

const GET_BOOKS = gql`
  query {
    books {
      id
      title
      author
    }
  }
`;

function BookList() {
  const { loading, error, data } = useQuery(GET_BOOKS);
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;
  return (
    <ul>
      {data.books.map(book => (
        <li key={book.id}>{book.title} by {book.author}</li>
      ))}
    </ul>
  );
}

Angular & Relay

  • Use Apollo Angular or Relay for integration

Subscriptions

  • Use Apollo Client's useSubscription hook

7. Testing in GraphQL using Spring Boot

GraphQL Test Framework

Add to pom.xml:

<dependency>
    <groupId>org.springframework.graphql</groupId>
    <artifactId>spring-graphql-test</artifactId>
    <scope>test</scope>
</dependency>

Example Integration Test

@SpringBootTest
@AutoConfigureMockMvc
public class BookResolverTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    void testBooksQuery() throws Exception {
        String query = "{ books { id title author } }";
        mockMvc.perform(post("/graphql")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"query\":\"" + query + "\"}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.books").isArray());
    }
}

8. Full Use-Case Example: Library Management System

Let's build a more realistic Library Management System using a variety of GraphQL schema types and demonstrate queries from simple to complex.

Domain Model

  • User: id, name, email, role

  • Book: id, title, author, genre, available, reviews

  • Order: id, user, book, date, status

  • Review: id, book, reviewer, rating, comment

Schema (with Interfaces, Enums, Input Types, Subscriptions)

# Enum for user roles
enum Role {
  ADMIN
  MEMBER
}

# Interface for all entities with an ID
interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  name: String!
  email: String!
  role: Role!
  orders: [Order]
}

type Book implements Node {
  id: ID!
  title: String!
  author: String!
  genre: String!
  available: Boolean!
  reviews: [Review]
}

type Order implements Node {
  id: ID!
  user: User!
  book: Book!
  date: String!
  status: String!
}

type Review implements Node {
  id: ID!
  book: Book!
  reviewer: User!
  rating: Int!
  comment: String
}

# Input type for creating a book
input BookInput {
  title: String!
  author: String!
  genre: String!
}

# Union for search results
union SearchResult = User | Book | Order

type Query {
  users: [User]
  books(available: Boolean, genre: String): [Book]
  orders(userId: ID): [Order]
  search(text: String!): [SearchResult]
}

type Mutation {
  addUser(name: String!, email: String!, role: Role!): User
  addBook(input: BookInput!): Book
  placeOrder(userId: ID!, bookId: ID!): Order
  addReview(bookId: ID!, reviewerId: ID!, rating: Int!, comment: String): Review
}

type Subscription {
  bookAdded: Book
  orderPlaced: Order
}

Queries (From Simple to Complex)

Full Use-Case Example: Library Management System

1. Simple Query: Fetch books

query {
  books {
    id
    title
    author
    available
  }
}

Explanation: Fetches all books with their basic details and availability.

Possible Output:

{
  "data": {
    "books": [
      {
        "id": "1",
        "title": "Dune",
        "author": "Frank Herbert",
        "available": true
      },
      {
        "id": "2",
        "title": "1984",
        "author": "George Orwell",
        "available": false
      }
    ]
  }
}

2. Query with Arguments: Filter Books by Genre

query {
  books(genre: "Science Fiction") {
    title
    author
    genre
  }
}

Explanation: Fetches books filtered by the genre argument.

Possible Output:

{
  "data": {
    "books": [
      {
        "title": "Dune",
        "author": "Frank Herbert",
        "genre": "Science Fiction"
      },
      {
        "title": "Foundation",
        "author": "Isaac Asimov",
        "genre": "Science Fiction"
      }
    ]
  }
}

3. Nested Query: User and Their Orders

query {
  users {
    name
    orders {
      book { title }
      date
      status
    }
  }
}

Explanation: Fetches all users and, for each, their orders with book titles, order date, and status.

Possible Output:

{
  "data": {
    "users": [
      {
        "name": "Alice",
        "orders": [
          {
            "book": { "title": "Dune" },
            "date": "2025-07-01",
            "status": "COMPLETED"
          }
        ]
      },
      {
        "name": "Bob",
        "orders": [
          {
            "book": { "title": "1984" },
            "date": "2025-06-28",
            "status": "PENDING"
          }
        ]
      }
    ]
  }
}

4. Query with Fragments: Book Details

fragment bookFields on Book {
  id
  title
  author
  genre
  available
}

query {
  books {
    ...bookFields
  }
}

Explanation: Uses a fragment to avoid repeating the list of book fields.

Possible Output:

{
  "data": {
    "books": [
      {
        "id": "1",
        "title": "Dune",
        "author": "Frank Herbert",
        "genre": "Science Fiction",
        "available": true
      },
      {
        "id": "2",
        "title": "1984",
        "author": "George Orwell",
        "genre": "Dystopian",
        "available": false
      }
    ]
  }
}

5. Inline Fragments: Search Across Types

query {
  search(text: "Alice") {
    ... on User {
      name
      email
    }
    ... on Book {
      title
      author
    }
    ... on Order {
      id
      status
    }
  }
}

Explanation: Handles a search that can return users, books, or orders using inline fragments.

Possible Output:

{
  "data": {
    "search": [
      {
        "name": "Alice",
        "email": "alice@example.com"
      },
      {
        "title": "Alice in Wonderland",
        "author": "Lewis Carroll"
      }
    ]
  }
}

6. Query with Variables: Orders for a Specific User

query GetOrders($userId: ID!) {
  orders(userId: $userId) {
    id
    book { title }
    date
    status
  }
}

Variables:

{
  "userId": "1"
}

Explanation: Fetches all orders for a specific user, using a variable for the user ID.

Possible Output:

{
  "data": {
    "orders": [
      {
        "id": "101",
        "book": { "title": "Dune" },
        "date": "2025-07-01",
        "status": "COMPLETED"
      },
      {
        "id": "102",
        "book": { "title": "Foundation" },
        "date": "2025-07-02",
        "status": "PENDING"
      }
    ]
  }
}

7. Complex Query: Books with Reviews and Reviewers

query {
  books(available: true) {
    title
    reviews {
      rating
      comment
      reviewer {
        name
        role
      }
    }
  }
}

Explanation: Fetches all available books, their reviews, and for each review, the reviewer's name and role.

Possible Output:

{
  "data": {
    "books": [
      {
        "title": "Dune",
        "reviews": [
          {
            "rating": 5,
            "comment": "Epic sci-fi classic!",
            "reviewer": {
              "name": "Alice",
              "role": "MEMBER"
            }
          },
          {
            "rating": 4,
            "comment": "Great world-building.",
            "reviewer": {
              "name": "Bob",
              "role": "ADMIN"
            }
          }
        ]
      }
    ]
  }
}

8. Subscription Example: Listen for New Books

subscription {
  bookAdded {
    id
    title
    author
  }
}

Explanation: Subscribes to real-time notifications when a new book is added to the library.

Possible Output:

{
  "data": {
    "bookAdded": {
      "id": "3",
      "title": "Brave New World",
      "author": "Aldous Huxley"
    }
  }
}

Tip: Use interfaces, unions, enums, and input types to make your schema expressive and robust. Subscriptions are great for real-time features like notifications or live updates!


9. Performance Optimization

DataLoader for Batching & Caching

Example

// In your resolver
DataLoader<Long, Book> bookLoader = ...;

public CompletableFuture<Book> getBook(Long id) {
    return bookLoader.load(id);
}

10. Authorization and Authentication

Securing Your API

  • Use Spring Security

  • Add method-level security annotations

Example

@PreAuthorize("hasRole('ADMIN')")
@MutationMapping
public Book addBook(...) { ... }

11. GraphQL Best Practices

  • Use clear, descriptive names

  • Avoid versioning; evolve your schema

  • Document your schema

  • Use input types for mutations

  • Handle errors gracefully


12. FAQs and Common Pitfalls

Common Mistakes

  • Forgetting to define resolvers for nested fields

  • Not handling errors properly

  • Over-fetching data

Solutions

  • Always define resolvers for complex/nested types

  • Use DataLoader for batching

  • Validate inputs in mutations


13. Conclusion & Further Resources

Congratulations! You made it to the end (phew!). 🎉

Where to Go Next

Remember: GraphQL is powerful, but with great power comes great responsibility (and sometimes, great debugging sessions). Happy coding!

0
Subscribe to my newsletter

Read articles from Maverick directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Maverick
Maverick