GraphQL with Spring Boot and React: A Comprehensive Guide

Introduction In the evolving world of web development, API design has undergone significant transformation. GraphQL has emerged as a powerful alternative to traditional REST APIs, offering more flexibility, efficiency, and improved developer experience. Created by Facebook in 2015 and now maintained by the GraphQL Foundation, this query language for APIs allows clients to request exactly the data they need—no more, no less. This guide will walk you through implementing a Recipe Management System using GraphQL with Spring Boot on the backend and React on the frontend. We'll cover everything from basic setup to advanced patterns and best practices. Why GraphQL? Before diving into implementation, let's understand why GraphQL has gained such popularity: Precise Data Fetching: Clients specify exactly what data they need, eliminating over-fetching and under-fetching problems common in REST APIs. Single Endpoint: All requests go through a single endpoint, simplifying API management. Strong Typing: GraphQL schemas provide clear contracts between client and server. Introspection: The API is self-documenting, making it easier for developers to understand available data. Versioning: Changes can be made to the API without breaking existing clients. Real-time Updates: Built-in support for subscriptions enables real-time data. Setting Up the Spring Boot Backend Prerequisites JDK 17+ Maven or Gradle Basic knowledge of Spring Boot Step 1: Create a Spring Boot Project Using Spring Initializr (https://start.spring.io/), create a new project with the following dependencies: Spring Web Spring Data JPA H2 Database (for development) Lombok (optional, but helpful) Additionally, we'll need to add GraphQL-specific dependencies to our pom.xml: org.springframework.boot spring-boot-starter-graphql Step 2: Define Your GraphQL Schema Create a file named schema.graphqls in the src/main/resources/graphql directory: type Query { recipeById(id: ID!): Recipe allRecipes: [Recipe!]! recipesByCategory(category: String!): [Recipe!]! } type Mutation { createRecipe(input: RecipeInput!): Recipe! updateRecipe(id: ID!, input: RecipeInput!): Recipe deleteRecipe(id: ID!): Boolean addIngredientToRecipe(recipeId: ID!, ingredientInput: IngredientInput!): Recipe } input RecipeInput { title: String! description: String prepTime: Int cookTime: Int servings: Int category: String! difficulty: Difficulty } input IngredientInput { name: String! amount: Float! unit: String } type Recipe { id: ID! title: String! description: String prepTime: Int cookTime: Int servings: Int category: String! difficulty: Difficulty ingredients: [Ingredient!]! instructions: [Instruction!]! createdAt: String! updatedAt: String } type Ingredient { id: ID! name: String! amount: Float! unit: String } type Instruction { id: ID! stepNumber: Int! description: String! } enum Difficulty { EASY MEDIUM HARD } Step 3: Create the Entity Classes Recipe.java @Entity @Data @NoArgsConstructor @AllArgsConstructor public class Recipe { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String description; private Integer prepTime; private Integer cookTime; private Integer servings; private String category; @Enumerated(EnumType.STRING) private Difficulty difficulty; @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) private List ingredients = new ArrayList(); @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("stepNumber ASC") private List instructions = new ArrayList(); @Column(updatable = false) private LocalDateTime createdAt; private LocalDateTime updatedAt; @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); } @PreUpdate protected void onUpdate() { updatedAt = LocalDateTime.now(); } public void addIngredient(Ingredient ingredient) { ingredients.add(ingredient); ingredient.setRecipe(this); } public void addInstruction(Instruction instruction) { instructions.add(instruction); instruction.setRecipe(this); } } public enum Difficulty { EASY, MEDIUM, HARD } Ingredient.java @Entity @Data @NoArgsConstructor @AllArgsConstructor @ToString(exclude = "recipe") public class Ingredient { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private Float amount; private String unit; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "recipe_id") private Recipe recipe; } Instruction.java @Entity @Data @NoArgsConstruc

May 9, 2025 - 03:17
 0
GraphQL with Spring Boot and React: A Comprehensive Guide

Introduction

In the evolving world of web development, API design has undergone significant transformation. GraphQL has emerged as a powerful alternative to traditional REST APIs, offering more flexibility, efficiency, and improved developer experience. Created by Facebook in 2015 and now maintained by the GraphQL Foundation, this query language for APIs allows clients to request exactly the data they need—no more, no less.

This guide will walk you through implementing a Recipe Management System using GraphQL with Spring Boot on the backend and React on the frontend. We'll cover everything from basic setup to advanced patterns and best practices.

Why GraphQL?

Before diving into implementation, let's understand why GraphQL has gained such popularity:

  1. Precise Data Fetching: Clients specify exactly what data they need, eliminating over-fetching and under-fetching problems common in REST APIs.
  2. Single Endpoint: All requests go through a single endpoint, simplifying API management.
  3. Strong Typing: GraphQL schemas provide clear contracts between client and server.
  4. Introspection: The API is self-documenting, making it easier for developers to understand available data.
  5. Versioning: Changes can be made to the API without breaking existing clients.
  6. Real-time Updates: Built-in support for subscriptions enables real-time data.

Setting Up the Spring Boot Backend

Prerequisites

  • JDK 17+
  • Maven or Gradle
  • Basic knowledge of Spring Boot

Step 1: Create a Spring Boot Project

Using Spring Initializr (https://start.spring.io/), create a new project with the following dependencies:

  • Spring Web
  • Spring Data JPA
  • H2 Database (for development)
  • Lombok (optional, but helpful)

Additionally, we'll need to add GraphQL-specific dependencies to our pom.xml:


    org.springframework.boot
    spring-boot-starter-graphql

Step 2: Define Your GraphQL Schema

Create a file named schema.graphqls in the src/main/resources/graphql directory:

type Query {
    recipeById(id: ID!): Recipe
    allRecipes: [Recipe!]!
    recipesByCategory(category: String!): [Recipe!]!
}

type Mutation {
    createRecipe(input: RecipeInput!): Recipe!
    updateRecipe(id: ID!, input: RecipeInput!): Recipe
    deleteRecipe(id: ID!): Boolean
    addIngredientToRecipe(recipeId: ID!, ingredientInput: IngredientInput!): Recipe
}

input RecipeInput {
    title: String!
    description: String
    prepTime: Int
    cookTime: Int
    servings: Int
    category: String!
    difficulty: Difficulty
}

input IngredientInput {
    name: String!
    amount: Float!
    unit: String
}

type Recipe {
    id: ID!
    title: String!
    description: String
    prepTime: Int
    cookTime: Int
    servings: Int
    category: String!
    difficulty: Difficulty
    ingredients: [Ingredient!]!
    instructions: [Instruction!]!
    createdAt: String!
    updatedAt: String
}

type Ingredient {
    id: ID!
    name: String!
    amount: Float!
    unit: String
}

type Instruction {
    id: ID!
    stepNumber: Int!
    description: String!
}

enum Difficulty {
    EASY
    MEDIUM
    HARD
}

Step 3: Create the Entity Classes

Recipe.java

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Recipe {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String description;
    private Integer prepTime;
    private Integer cookTime;
    private Integer servings;
    private String category;

    @Enumerated(EnumType.STRING)
    private Difficulty difficulty;

    @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Ingredient> ingredients = new ArrayList<>();

    @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
    @OrderBy("stepNumber ASC")
    private List<Instruction> instructions = new ArrayList<>();

    @Column(updatable = false)
    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    public void addIngredient(Ingredient ingredient) {
        ingredients.add(ingredient);
        ingredient.setRecipe(this);
    }

    public void addInstruction(Instruction instruction) {
        instructions.add(instruction);
        instruction.setRecipe(this);
    }
}

public enum Difficulty {
    EASY, MEDIUM, HARD
}

Ingredient.java

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = "recipe")
public class Ingredient {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Float amount;
    private String unit;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "recipe_id")
    private Recipe recipe;
}

Instruction.java

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = "recipe")
public class Instruction {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Integer stepNumber;
    private String description;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "recipe_id")
    private Recipe recipe;
}

Step 4: Create Repositories

public interface RecipeRepository extends JpaRepository<Recipe, Long> {
    List<Recipe> findByCategory(String category);
}

public interface IngredientRepository extends JpaRepository<Ingredient, Long> {
}

public interface InstructionRepository extends JpaRepository<Instruction, Long> {
}

Step 5: Implement Input Types

@Data
@NoArgsConstructor
@AllArgsConstructor
public class RecipeInput {
    private String title;
    private String description;
    private Integer prepTime;
    private Integer cookTime;
    private Integer servings;
    private String category;
    private Difficulty difficulty;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class IngredientInput {
    private String name;
    private Float amount;
    private String unit;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class InstructionInput {
    private Integer stepNumber;
    private String description;
}

Step 6: Implement the GraphQL Controllers

@Controller
public class RecipeController {

    private final RecipeRepository recipeRepository;
    private final IngredientRepository ingredientRepository;

    public RecipeController(RecipeRepository recipeRepository, IngredientRepository ingredientRepository) {
        this.recipeRepository = recipeRepository;
        this.ingredientRepository = ingredientRepository;
    }

    @QueryMapping
    public Recipe recipeById(@Argument Long id) {
        return recipeRepository.findById(id).orElse(null);
    }

    @QueryMapping
    public List<Recipe> allRecipes() {
        return recipeRepository.findAll();
    }

    @QueryMapping
    public List<Recipe> recipesByCategory(@Argument String category) {
        return recipeRepository.findByCategory(category);
    }

    @MutationMapping
    public Recipe createRecipe(@Argument RecipeInput input) {
        Recipe recipe = new Recipe();
        recipe.setTitle(input.getTitle());
        recipe.setDescription(input.getDescription());
        recipe.setPrepTime(input.getPrepTime());
        recipe.setCookTime(input.getCookTime());
        recipe.setServings(input.getServings());
        recipe.setCategory(input.getCategory());
        recipe.setDifficulty(input.getDifficulty());

        return recipeRepository.save(recipe);
    }

    @MutationMapping
    public Recipe updateRecipe(@Argument Long id, @Argument RecipeInput input) {
        Recipe recipe = recipeRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Recipe not found"));

        recipe.setTitle(input.getTitle());
        if (input.getDescription() != null) recipe.setDescription(input.getDescription());
        if (input.getPrepTime() != null) recipe.setPrepTime(input.getPrepTime());
        if (input.getCookTime() != null) recipe.setCookTime(input.getCookTime());
        if (input.getServings() != null) recipe.setServings(input.getServings());
        recipe.setCategory(input.getCategory());
        if (input.getDifficulty() != null) recipe.setDifficulty(input.getDifficulty());

        return recipeRepository.save(recipe);
    }

    @MutationMapping
    public boolean deleteRecipe(@Argument Long id) {
        if (recipeRepository.existsById(id)) {
            recipeRepository.deleteById(id);
            return true;
        }
        return false;
    }

    @MutationMapping
    public Recipe addIngredientToRecipe(@Argument Long recipeId, @Argument IngredientInput ingredientInput) {
        Recipe recipe = recipeRepository.findById(recipeId)
            .orElseThrow(() -> new RuntimeException("Recipe not found"));

        Ingredient ingredient = new Ingredient();
        ingredient.setName(ingredientInput.getName());
        ingredient.setAmount(ingredientInput.getAmount());
        ingredient.setUnit(ingredientInput.getUnit());

        recipe.addIngredient(ingredient);
        return recipeRepository.save(recipe);
    }
}

Step 7: Configuration

Add the following to your application.properties file:

spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql
spring.graphql.schema.printer.enabled=true
spring.datasource.url=jdbc:h2:mem:recipedb
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true

Setting Up the React Frontend

Prerequisites

  • Node.js and npm/yarn
  • Basic knowledge of React

Step 1: Create a React Application

npx create-react-app recipe-management-client
cd recipe-management-client

Step 2: Install Dependencies

npm install @apollo/client graphql react-router-dom @mui/material @emotion/react @emotion/styled

Step 3: Set Up Apollo Client

Create a file named apollo.js in your src directory:

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

const httpLink = new HttpLink({
  uri: 'http://localhost:8080/graphql',
});

const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
});

export default client;

Update your index.js to provide the Apollo client to your app:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { ApolloProvider } from '@apollo/client';
import { BrowserRouter } from 'react-router-dom';
import client from './apollo';
import App from './App';
import './index.css';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <ApolloProvider client={client}>
        <App />
      </ApolloProvider>
    </BrowserRouter>
  </React.StrictMode>
);

Step 4: Create GraphQL Queries and Mutations

Create a graphql folder in your src directory and add a file called queries.js:

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

export const GET_ALL_RECIPES = gql`
  query GetAllRecipes {
    allRecipes {
      id
      title
      description
      prepTime
      cookTime
      servings
      category
      difficulty
      createdAt
    }
  }
`;

export const GET_RECIPE_BY_ID = gql`
  query GetRecipeById($id: ID!) {
    recipeById(id: $id) {
      id
      title
      description
      prepTime
      cookTime
      servings
      category
      difficulty
      ingredients {
        id
        name
        amount
        unit
      }
      instructions {
        id
        stepNumber
        description
      }
      createdAt
      updatedAt
    }
  }
`;

export const GET_RECIPES_BY_CATEGORY = gql`
  query GetRecipesByCategory($category: String!) {
    recipesByCategory(category: $category) {
      id
      title
      description
      prepTime
      cookTime
      difficulty
    }
  }
`;

export const CREATE_RECIPE = gql`
  mutation CreateRecipe($input: RecipeInput!) {
    createRecipe(input: $input) {
      id
      title
      category
    }
  }
`;

export const UPDATE_RECIPE = gql`
  mutation UpdateRecipe($id: ID!, $input: RecipeInput!) {
    updateRecipe(id: $id, input: $input) {
      id
      title
      description
      prepTime
      cookTime
      servings
      category
      difficulty
    }
  }
`;

export const DELETE_RECIPE = gql`
  mutation DeleteRecipe($id: ID!) {
    deleteRecipe(id: $id)
  }
`;

export const ADD_INGREDIENT = gql`
  mutation AddIngredient($recipeId: ID!, $ingredientInput: IngredientInput!) {
    addIngredientToRecipe(recipeId: $recipeId, ingredientInput: $ingredientInput) {
      id
      ingredients {
        id
        name
        amount
        unit
      }
    }
  }
`;

Step 5: Create React Components

Let's create components to interact with our GraphQL API:

RecipeList.js

import React, { useState } from 'react';
import { useQuery } from '@apollo/client';
import { Link } from 'react-router-dom';
import { GET_ALL_RECIPES } from '../graphql/queries';
import { 
  Container, Typography, Grid, Card, CardContent, CardActions, 
  Button, Chip, CircularProgress, TextField, MenuItem, Select, FormControl, InputLabel 
} from '@mui/material';

function RecipeList() {
  const { loading, error, data } = useQuery(GET_ALL_RECIPES);
  const [categoryFilter, setCategoryFilter] = useState('');
  const [searchTerm, setSearchTerm] = useState('');

  if (loading) return <CircularProgress />;
  if (error) return <Typography color="error">Error: {error.message}</Typography>;

  // Get unique categories for the filter
  const categories = [...new Set(data.allRecipes.map(recipe => recipe.category))];

  // Filter recipes based on category and search term
  const filteredRecipes = data.allRecipes.filter(recipe => {
    const matchesCategory = categoryFilter ? recipe.category === categoryFilter : true;
    const matchesSearch = searchTerm ? 
      recipe.title.toLowerCase().includes(searchTerm.toLowerCase()) : true;
    return matchesCategory && matchesSearch;
  });

  return (
    <Container>
      <Typography variant="h4" component="h1" gutterBottom>
        Recipe Collection
      </Typography>

      <Grid container spacing={2} sx={{ mb: 4 }}>
        <Grid item xs={12} md={6}>
          <TextField
            label="Search recipes"
            variant="outlined"
            fullWidth
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
          />
        </Grid>
        <Grid item xs={12} md={6}>
          <FormControl fullWidth>
            <InputLabel id="category-filter-label">Filter by Category</InputLabel>
            <Select
              labelId="category-filter-label"
              value={categoryFilter}
              label="Filter by Category"
              onChange={(e) => setCategoryFilter(e.target.value)}
            >
              <MenuItem value="">All Categories</MenuItem>
              {categories.map(category => (
                <MenuItem key={category} value={category}>{category}</MenuItem>
              ))}
            </Select>
          </FormControl>
        </Grid>
      </Grid>

      <Grid container spacing={3}>
        {filteredRecipes.map(recipe => (
          <Grid item key={recipe.id} xs={12} sm={6} md={4}>
            <Card>
              <CardContent>
                <Typography variant="h6" component="h2">
                  {recipe.title}
                </Typography>
                <Typography color="textSecondary" gutterBottom>
                  {recipe.prepTime + recipe.cookTime} mins | {recipe.difficulty}
                </Typography>
                <Chip label={recipe.category} color="primary" size="small" />
                <Typography variant="body2" component="p" sx={{ mt: 2 }}>
                  {recipe.description?.substring(0, 100)}
                  {recipe.description?.length > 100 ? '...' : ''}
                </Typography>
              </CardContent>
              <CardActions>
                <Button size="small" component={Link} to={`/recipe/${recipe.id}`}>
                  View Details
                </Button>
              </CardActions>
            </Card>
          </Grid>
        ))}
      </Grid>

      <Button 
        variant="contained" 
        color="primary" 
        component={Link} 
        to="/add-recipe"
        sx={{ mt: 4 }}
      >
        Add New Recipe
      </Button>
    </Container>
  );
}

export default RecipeList;

RecipeDetail.js

import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@apollo/client';
import { GET_RECIPE_BY_ID, DELETE_RECIPE, GET_ALL_RECIPES } from '../graphql/queries';
import { 
  Container, Typography, Grid, Paper, List, ListItem, ListItemText, 
  Divider, Chip, Box, Button, CircularProgress 
} from '@mui/material';

function RecipeDetail() {
  const { id } = useParams();
  const navigate = useNavigate();
  const { loading, error, data } = useQuery(GET_RECIPE_BY_ID, {
    variables: { id },
  });

  const [deleteRecipe] = useMutation(DELETE_RECIPE, {
    variables: { id },
    refetchQueries: [{ query: GET_ALL_RECIPES }],
    onCompleted: () => {
      navigate('/');
    }
  });

  if (loading) return <CircularProgress />;
  if (error) return <Typography color="error">Error: {error.message}</Typography>;
  if (!data || !data.recipeById) return <Typography>Recipe not found</Typography>;

  const recipe = data.recipeById;

  const handleDelete = () => {
    if (window.confirm('Are you sure you want to delete this recipe?')) {
      deleteRecipe();
    }
  };

  return (
    <Container>
      <Typography variant="h4" component="h1" gutterBottom>
        {recipe.title}
      </Typography>

      <Chip label={recipe.category} color="primary" sx={{ mb: 2 }} />
      <Chip label={recipe.difficulty} color="secondary" sx={{ ml: 1, mb: 2 }} />

      <Grid container spacing={3}>
        <Grid item xs={12} md={4}>
          <Paper sx={{ p: 2 }}>
            <Typography variant="h6">Details</Typography>
            <List dense>
              <ListItem>
                <ListItemText primary="Prep Time" secondary={`${recipe.prepTime} minutes`} />
              </ListItem>
              <ListItem>
                <ListItemText primary="Cook Time" secondary={`${recipe.cookTime} minutes`} />
              </ListItem>
              <ListItem>
                <ListItemText primary="Servings" secondary={recipe.servings} />
              </ListItem>
            </List>
          </Paper>
        </Grid>

        <Grid item xs={12} md={8}>
          <Paper sx={{ p: 2 }}>
            <Typography variant="h6">Description</Typography>
            <Typography paragraph>
              {recipe.description}
            </Typography>
          </Paper>
        </Grid>

        <Grid item xs={12} md={6}>
          <Paper sx={{ p: 2 }}>
            <Typography variant="h6">Ingredients</Typography>
            <List>
              {recipe.ingredients.map(ingredient => (
                <ListItem key={ingredient.id}>
                  <ListItemText 
                    primary={`${ingredient.name}`} 
                    secondary={`${ingredient.amount} ${ingredient.unit || ''}`} 
                  />
                </ListItem>
              ))}
            </List>
          </Paper>
        </Grid>

        <Grid item xs={12} md={6}>
          <Paper sx={{ p: 2 }}>
            <Typography variant="h6">Instructions</Typography>
            <List>
              {recipe.instructions
                .sort((a, b) => a.stepNumber - b.stepNumber)
                .map(instruction => (
                <React.Fragment key={instruction.id}>
                  <ListItem>
                    <ListItemText 
                      primary={`Step ${instruction.stepNumber}`} 
                      secondary={instruction.description} 
                    />
                  </ListItem>
                  <Divider component="li" />
                </React.Fragment>
              ))}
            </List>
          </Paper>
        </Grid>
      </Grid>

      <Box sx={{ mt: 4, display: 'flex', gap: 2 }}>
        <Button 
          variant="contained" 
          color="primary" 
          onClick={() => navigate(`/edit-recipe/${recipe.id}`)}
        >
          Edit Recipe
        </Button>
        <Button 
          variant="outlined" 
          color="error" 
          onClick={handleDelete}
        >
          Delete Recipe
        </Button>
        <Button 
          variant="outlined" 
          onClick={() => navigate('/')}
        >
          Back to List
        </Button>
      </Box>
    </Container>
  );
}

export default RecipeDetail;

AddRecipe.js

import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@apollo/client';
import { CREATE_RECIPE, GET_ALL_RECIPES } from '../graphql/queries';
import {
  Container, Typography, TextField, Button, Grid, MenuItem,
  FormControl, InputLabel, Select, Box, Paper
} from '@mui/material';

function AddRecipe() {
  const navigate = useNavigate();
  const [formData, setFormData] = useState({
    title: '',
    description: '',
    prepTime: 0,
    cookTime: 0,
    servings: 1,
    category: '',
    difficulty: 'EASY'
  });

  const [createRecipe, { loading }] = useMutation(CREATE_RECIPE, {
    refetchQueries: [{ query: GET_ALL_RECIPES }],
    onCompleted: (data) => {
      navigate(`/recipe/${data.createRecipe.id}`);
    }
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: name === 'prepTime' || name === 'cookTime' || name === 'servings' 
        ? parseInt(value, 10) 
        : value
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    createRecipe({
      variables: {
        input: formData
      }
    });
  };

  return (
    <Container>
      <Typography variant="h4" component="h1" gutterBottom>
        Add New Recipe
      </Typography>

      <Paper sx={{ p: 3 }}>
        <form onSubmit={handleSubmit}>
          <Grid container spacing={3}>
            <Grid item xs={12}>
              <TextField
                name="title"
                label="Recipe Title"
                variant="outlined"
                fullWidth
                required
                value={formData.title}
                onChange={handleChange}
              />
            </Grid>

            <Grid item xs={12}>
              <TextField
                name="description"
                label="Description"
                variant="outlined"
                fullWidth
                multiline
                rows={4}
                value={formData.description}
                onChange={handleChange}
              />
            </Grid>

            <Grid item xs={12} sm={4}>
              <TextField
                name="prepTime"
                label="Prep Time (minutes)"
                type="number"
                variant="outlined"
                fullWidth
                required
                value={formData.prepTime}
                onChange={handleChange}
                InputProps={{ inputProps: { min: 0 } }}
              />
            </Grid>

            <Grid item xs={12} sm={4}>
              <TextField
                name="cookTime"
                label="Cook Time (minutes)"
                type="number"
                variant="outlined"
                fullWidth
                required
                value={formData.cookTime}
                onChange={handleChange}
                InputProps={{ inputProps: { min: 0 } }}
              />
            </Grid>

            <Grid item xs={12} sm={4}>
              <TextField
                name="servings"
                label="Servings"
                type="number"
                variant="outlined"
                fullWidth
                required
                value={formData.servings}
                onChange={handleChange}
                InputProps={{ inputProps: { min: 1 } }}
              />
            </Grid>

            <Grid item xs={12} sm={6}>
              <TextField
                name="category"
                label="Category"
                variant="outlined"
                fullWidth
                required
                value={formData.category}
                onChange={handleChange}
              />
            </Grid>

            <Grid item xs={12} sm={6}>
              <FormControl fullWidth>
                <InputLabel id="difficulty-label">Difficulty</InputLabel>
                <Select
                  labelId="difficulty-label"
                  name="difficulty"
                  value={formData.difficulty}
                  label="Difficulty"
                  onChange={handleChange}
                >
                  <MenuItem value="EASY">Easy</MenuItem>
                  <MenuItem value="MEDIUM">Medium</MenuItem>
                  <MenuItem value="HARD">Hard</MenuItem>
                </Select>
              </FormControl>
            </Grid>
          </Grid>

          <Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
            <Button 
              type="submit" 
              variant="contained" 
              color="primary" 
              disabled={loading}
            >
              {loading ? 'Creating...' : 'Create Recipe'}
            </Button>
            <Button 
              variant="outlined" 
              onClick={() => navigate('/')}
            >
              Cancel
            </Button>
          </Box>
        </form>
      </Paper>
    </Container>
  );
}

export default AddRecipe;

App.js

import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
import { AppBar, Toolbar, Typography, Container, CssBaseline } from '@mui/material';
import RecipeList from './components/RecipeList';
import RecipeDetail from './components/RecipeDetail';
import AddRecipe from './components/AddRecipe';
import './App.css';

function App() {
  return (
    <>
      <CssBaseline />
      <AppBar position="static">
        <Toolbar>
          <Typography variant="h6" component={Link} to="/" sx={{ textDecoration: 'none', color: 'white' }}>
            Recipe Management System
          </Typography>
        </Toolbar>
      </AppBar>
      <Container sx={{ mt: 4, mb: 4 }}>
        <Routes>
          <Route path="/" element={<RecipeList />} />
          <Route path="/recipe/:id" element={<RecipeDetail />} />
          <Route path="/add-recipe" element={<AddRecipe />} />
        </Routes>
      </Container>
    </>
  );
}

export default App;

Advanced Concepts

Error Handling in GraphQL

Backend Error Handling

In Spring Boot, you can create a custom exception handler:

@Component
public class GraphQLExceptionHandler implements DataFetcherExceptionResolver {

    private static final Logger logger = LoggerFactory.getLogger(GraphQLExceptionHandler.class);

    @Override
    public List<GraphQLError> resolveException(Throwable exception, DataFetchingEnvironment environment) {
        if (exception instanceof EntityNotFoundException) {
            return Collections.singletonList(
                GraphqlErrorBuilder.newError()
                    .message(exception.getMessage())
                    .location(environment.getField().getSourceLocation())
                    .path(environment.getExecutionStepInfo().getPath())
                    .errorType(ErrorType.DataFetchingException)
                    .build()
            );
        } else if (exception instanceof ValidationException) {
            // Handle validation errors
            return Collections.singletonList(
                GraphqlErrorBuilder.newError()
                    .message(exception.getMessage())
                    .errorType(ErrorType.ValidationError)
                    .build()
            );
        }

        // Log unexpected errors
        logger.error("Unexpected error during GraphQL execution", exception);
        return Collections.singletonList(
            GraphqlErrorBuilder.newError()
                .message("An unexpected error occurred")
                .errorType(ErrorType.ExecutionAborted)
                .build()
        );
    }
}

Frontend Error Handling

Apollo Client provides error handling capabilities:

function RecipeForm() {
  const [createRecipe, { loading, error }] = useMutation(CREATE_RECIPE);

  // Handle different types of errors
  if (error) {
    if (error.networkError) {
      return <Typography color="error">Network error: Check your connection</Typography>;
    }

    if (error.graphQLErrors) {
      return (
        <Box sx={{ mt: 2 }}>
          <Typography color="error">Errors:</Typography>
          <List>
            {error.graphQLErrors.map(({ message }, i) => (
              <ListItem key={i}>
                <ListItemText primary={message} />
              </ListItem>
            ))}
          </List>
        </Box>
      );
    }

    return <Typography color="error">An error occurred: {error.message}</Typography>;
  }

  // Rest of the component...
}

Authentication and Authorization

Backend Implementation

First, set up a security configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/graphiql/**").permitAll()
                .requestMatchers("/graphql").authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .build();
    }
}

Then, create a GraphQL interceptor to handle authentication:

@Component
public class SecurityGraphQlInterceptor implements WebGraphQlInterceptor {

    @Override
    public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
        Principal principal = request.getPrincipal().orElse(null);

        if (principal == null) {
            throw new AccessDeniedException("Not authenticated");
        }

        // Extract user details from principal
        if (principal instanceof JwtAuthenticationToken jwtToken) {
            Jwt jwt = jwtToken.getToken();

            // Create a GraphQL context with user information
            Map<String, Object> contextMap = new HashMap<>();
            contextMap.put("userId", jwt.getSubject());
            contextMap.put("roles", jwt.getClaimAsStringList("roles"));

            request = request.transform(builder -> builder.contextData(contextMap));
        }

        return chain.next(request);
    }
}

Create a service to check permissions:

@Service
public class RecipePermissionService {

    public boolean canAccessRecipe(Long recipeId, String userId) {
        // Implement your permission logic here
        // e.g., check if the recipe belongs to the user or is public
        return true;
    }

    public boolean canModifyRecipe(Long recipeId, String userId) {
        // More specific permission check for modifications
        return true;
    }
}

Update your controller to use the permission service:

@Controller
public class RecipeController {

    private final RecipeRepository recipeRepository;
    private final RecipePermissionService permissionService;

    // Constructor...

    @QueryMapping
    public Recipe recipeById(@Argument Long id, DataFetchingEnvironment env) {
        Map<String, Object> context = env.getGraphQlContext();
        String userId = (String) context.get("userId");

        Recipe recipe = recipeRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Recipe not found"));

        if (!permissionService.canAccessRecipe(id, userId)) {
            throw new AccessDeniedException("Not authorized to access this recipe");
        }

        return recipe;
    }

    @MutationMapping
    public Recipe updateRecipe(@Argument Long id, @Argument RecipeInput input, DataFetchingEnvironment env) {
        Map<String, Object> context = env.getGraphQlContext();
        String userId = (String) context.get("userId");

        if (!permissionService.canModifyRecipe(id, userId)) {
            throw new AccessDeniedException("Not authorized to modify this recipe");
        }

        // Rest of the update logic...
    }
}

Frontend Implementation

Set up Apollo Client with authentication:

import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client';

const httpLink = new HttpLink({
  uri: 'http://localhost:8080/graphql',
});

// Authentication middleware
const authLink = new ApolloLink((operation, forward) => {
  const token = localStorage.getItem('auth_token');

  operation.setContext({
    headers: {
      Authorization: token ? `Bearer ${token}` : '',
    },
  });

  return forward(operation);
});

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
});

Create a simple authentication context:

import React, { createContext, useState, useContext, useEffect } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check if user is already logged in
    const token = localStorage.getItem('auth_token');
    if (token) {
      // Validate token or fetch user info
      // For demo purposes, we'll just set a user
      setUser({ id: '123', name: 'Demo User' });
    }
    setLoading(false);
  }, []);

  const login = async (credentials) => {
    // Implement login logic
    // Store token in localStorage
    localStorage.setItem('auth_token', 'demo_token');
    setUser({ id: '123', name: 'Demo User' });
    return true;
  };

  const logout = () => {
    localStorage.removeItem('auth_token');
    setUser(null);
  };

  if (loading) {
    return <div>Loading authentication...</div>;
  }

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);

DataLoader for N+1 Query Problem

The N+1 query problem occurs when you fetch a list of entities and then need to fetch related entities for each one. DataLoader helps solve this by batching and caching requests.

@Configuration
public class GraphQlConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            RecipeRepository recipeRepository,
            IngredientRepository ingredientRepository) {
        return new BatchLoaderRegistry() {
            @Override
            public void registerBatchLoaders(BatchLoaderRegistryHelper helper) {
                // DataLoader for ingredients by recipe ID
                helper.forTypePair(Long.class, List.class)
                    .registerMappedBatchLoader((recipeIds, env) -> {
                        List<Ingredient> allIngredients = ingredientRepository.findByRecipeIdIn(recipeIds);

                        // Group ingredients by recipe ID
                        Map<Long, List<Ingredient>> ingredientsByRecipeId = allIngredients.stream()
                            .collect(Collectors.groupingBy(
                                ingredient -> ingredient.getRecipe().getId()));

                        // Ensure every requested recipe ID has an entry, even if empty
                        Map<Long, List<Ingredient>> result = new HashMap<>();
                        recipeIds.forEach(id -> result.put(id, ingredientsByRecipeId.getOrDefault(id, Collections.emptyList())));

                        return Mono.just(result);
                    });
            }
        };
    }
}

And update your GraphQL resolver:

@Controller
public class RecipeFieldResolver {

    @SchemaMapping(typeName = "Recipe", field = "ingredients")
    public CompletableFuture<List<Ingredient>> ingredients(Recipe recipe, DataFetchingEnvironment env) {
        DataLoader<Long, List<Ingredient>> dataLoader = env.getDataLoader("ingredients");
        return dataLoader.load(recipe.getId());
    }
}

Real-time Updates with GraphQL Subscriptions

Backend Implementation

Update your GraphQL schema:

type Subscription {
    recipeAdded: Recipe!
    recipeUpdated: Recipe!
}

Configure WebSocket support in your Spring Boot application:

@Configuration
public class WebSocketConfig {

    @Bean
    public WebSocketHandler webSocketHandler(GraphQlWebSocketHandler graphQlWebSocketHandler) {
        return graphQlWebSocketHandler;
    }

    @Bean
    public HandlerMapping webSocketHandlerMapping(WebSocketHandler webSocketHandler) {
        Map<String, WebSocketHandler> map = new HashMap<>();
        map.put("/graphql-ws", webSocketHandler);

        SimpleUrlHandlerMapping handlerMapping = new SimpleUrlHandlerMapping();
        handlerMapping.setUrlMap(map);
        handlerMapping.setOrder(1);
        return handlerMapping;
    }

    @Bean
    public WebSocketHandlerAdapter webSocketHandlerAdapter() {
        return new WebSocketHandlerAdapter();
    }
}

Implement subscription resolvers:

@Controller
public class RecipeSubscriptionController {

    private final Sinks.Many<Recipe> recipeAddedSink;
    private final Sinks.Many<Recipe> recipeUpdatedSink;

    public RecipeSubscriptionController() {
        this.recipeAddedSink = Sinks.many().multicast().onBackpressureBuffer();
        this.recipeUpdatedSink = Sinks.many().multicast().onBackpressureBuffer();
    }

    @SubscriptionMapping
    public Flux<Recipe> recipeAdded() {
        return recipeAddedSink.asFlux();
    }

    @SubscriptionMapping
    public Flux<Recipe> recipeUpdated() {
        return recipeUpdatedSink.asFlux();
    }

    public void onRecipeAdded(Recipe recipe) {
        recipeAddedSink.tryEmitNext(recipe);
    }

    public void onRecipeUpdated(Recipe recipe) {
        recipeUpdatedSink.tryEmitNext(recipe);
    }
}

Update your service to emit events:

@Service
public class RecipeService {

    private final RecipeRepository recipeRepository;
    private final RecipeSubscriptionController subscriptionController;

    // Constructor...

    public Recipe createRecipe(RecipeInput input) {
        Recipe recipe = mapInputToEntity(input);
        Recipe savedRecipe = recipeRepository.save(recipe);
        subscriptionController.onRecipeAdded(savedRecipe);
        return savedRecipe;
    }

    public Recipe updateRecipe(Long id, RecipeInput input) {
        Recipe recipe = recipeRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Recipe not found"));

        updateRecipeFromInput(recipe, input);
        Recipe updatedRecipe = recipeRepository.save(recipe);
        subscriptionController.onRecipeUpdated(updatedRecipe);
        return updatedRecipe;
    }

    // Helper methods...
}

Frontend Implementation

Set up Apollo Client with subscription support:

import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';

// HTTP link for queries and mutations
const httpLink = new HttpLink({
  uri: 'http://localhost:8080/graphql',
});

// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(createClient({
  url: 'ws://localhost:8080/graphql-ws',
  connectionParams: {
    authToken: localStorage.getItem('auth_token'),
  },
}));

// Split link based on operation type
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink,
);

const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

Create a subscription component:

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

const RECIPE_ADDED_SUBSCRIPTION = gql`
  subscription RecipeAdded {
    recipeAdded {
      id
      title
      category
    }
  }
`;

function RecipeNotifications() {
  const { data, loading } = useSubscription(RECIPE_ADDED_SUBSCRIPTION);

  return (
    <div>
      <h3>Real-time Notifications</h3>
      {loading ? (
        <p>Listening for new recipes...</p>
      ) : data ? (
        <p>New recipe added: "{data.recipeAdded.title}" in {data.recipeAdded.category} category</p>
      ) : null}
    </div>
  );
}

Performance Optimization

Query Complexity Analysis

To prevent resource-intensive queries, you can implement query complexity analysis:

@Configuration
public class GraphQLConfig {

    @Bean
    public QueryComplexityInstrumentation complexityInstrumentation() {
        return new QueryComplexityInstrumentation(100, new DefaultQueryComplexityCalculator() {
            @Override
            public int calculateComplexity(FieldDefinition fieldDefinition, FieldCoordinates coordinates) {
                // Assign higher complexity to recursive fields
                if (fieldDefinition.getName().equals("ingredients") || 
                    fieldDefinition.getName().equals("instructions")) {
                    return 5;
                }
                return 1;
            }
        });
    }
}

Caching with Apollo Client

Apollo Client automatically caches query results. You can configure the cache behavior:

const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache({
    typePolicies: {
      Recipe: {
        keyFields: ['id'],
        fields: {
          ingredients: {
            merge(existing = [], incoming) {
              return [...incoming];
            },
          },
          instructions: {
            merge(existing = [], incoming) {
              return [...incoming];
            },
          },
        },
      },
    },
  }),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      nextFetchPolicy: 'cache-first',
    },
  },
});

Pagination

Update your GraphQL schema:

type Query {
    # Other queries...
    recipesWithPagination(page: Int!, size: Int!): RecipePage!
}

type RecipePage {
    content: [Recipe!]!
    totalElements: Int!
    totalPages: Int!
    hasNext: Boolean!
}

Implement the resolver:

@QueryMapping
public RecipePage recipesWithPagination(@Argument int page, @Argument int size) {
    Pageable pageable = PageRequest.of(page, size);
    Page<Recipe> recipePage = recipeRepository.findAll(pageable);

    return new RecipePage(
        recipePage.getContent(),
        recipePage.getTotalElements(),
        recipePage.getTotalPages(),
        recipePage.hasNext()
    );
}

Client-side implementation:

const RECIPES_WITH_PAGINATION = gql`
  query RecipesWithPagination($page: Int!, $size: Int!) {
    recipesWithPagination(page: $page, size: $size) {
      content {
        id
        title
        description
        category
      }
      totalElements
      totalPages
      hasNext
    }
  }
`;

function PaginatedRecipeList() {
  const [page, setPage] = useState(0);
  const [size] = useState(10);

  const { loading, error, data } = useQuery(RECIPES_WITH_PAGINATION, {
    variables: { page, size },
  });

  if (loading) return <CircularProgress />;
  if (error) return <Typography color="error">Error: {error.message}</Typography>;

  const { content, totalPages, hasNext } = data.recipesWithPagination;

  return (
    <div>
      <Grid container spacing={3}>
        {content.map(recipe => (
          <Grid item key={recipe.id} xs={12} sm={6} md={4}>
            {/* Recipe card */}
          </Grid>
        ))}
      </Grid>

      <Pagination
        count={totalPages}
        page={page + 1}
        onChange={(e, value) => setPage(value - 1)}
        sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}
      />
    </div>
  );
}

Testing

Backend Testing

Testing GraphQL resolvers:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureGraphQlTester
class RecipeControllerTests {

    @Autowired
    private GraphQlTester graphQlTester;

    @Autowired
    private RecipeRepository recipeRepository;

    @BeforeEach
    void setup() {
        recipeRepository.deleteAll();
        Recipe testRecipe = new Recipe();
        testRecipe.setTitle("Test Recipe");
        testRecipe.setDescription("Test Description");
        testRecipe.setPrepTime(10);
        testRecipe.setCookTime(20);
        testRecipe.setServings(4);
        testRecipe.setCategory("Test Category");
        testRecipe.setDifficulty(Difficulty.EASY);
        recipeRepository.save(testRecipe);
    }

    @Test
    void testGetAllRecipes() {
        String query = """
            query {
                allRecipes {
                    id
                    title
                    category
                }
            }
        """;

        graphQlTester.document(query)
            .execute()
            .path("allRecipes")
            .entityList(Map.class)
            .hasSize(1)
            .first()
            .satisfies(recipe -> {
                assertThat(recipe.get("title")).isEqualTo("Test Recipe");
                assertThat(recipe.get("category")).isEqualTo("Test Category");
            });
    }

    @Test
    void testCreateRecipe() {
        String mutation = """
            mutation {
                createRecipe(input: {
                    title: "New Recipe",
                    description: "New Description",
                    prepTime: 15,
                    cookTime: 25,
                    servings: 2,
                    category: "New Category",
                    difficulty: MEDIUM
                }) {
                    id
                    title
                    category
                    difficulty
                }
            }
        """;

        graphQlTester.document(mutation)
            .execute()
            .path("createRecipe")
            .entity(Map.class)
            .satisfies(recipe -> {
                assertThat(recipe.get("title")).isEqualTo("New Recipe");
                assertThat(recipe.get("category")).isEqualTo("New Category");
                assertThat(recipe.get("difficulty")).isEqualTo("MEDIUM");
            });

        // Verify it was added to the database
        assertThat(recipeRepository.count()).isEqualTo(2);
    }
}

Frontend Testing

Testing React components with Apollo Client:

import { MockedProvider } from '@apollo/client/testing';
import { render, screen, waitFor } from '@testing-library/react';
import RecipeList from './RecipeList';
import { GET_ALL_RECIPES } from '../graphql/queries';

const mocks = [
  {
    request: {
      query: GET_ALL_RECIPES,
    },
    result: {
      data: {
        allRecipes: [
          { 
            id: '1', 
            title: 'Test Recipe', 
            description: 'A test recipe', 
            prepTime: 10, 
            cookTime: 20, 
            servings: 4, 
            category: 'Test', 
            difficulty: 'EASY',
            createdAt: '2023-01-01T12:00:00Z' 
          },
        ],
      },
    },
  },
];

test('renders recipe list when data is fetched', async () => {
  render(
    <MockedProvider mocks={mocks} addTypename={false}>
      <MemoryRouter>
        <RecipeList />
      </MemoryRouter>
    </MockedProvider>
  );

  expect(screen.getByRole('progressbar')).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText('Recipe Collection')).toBeInTheDocument();
    expect(screen.getByText('Test Recipe')).toBeInTheDocument();
    expect(screen.getByText('Test')).toBeInTheDocument();
  });
});

Production Considerations

CORS Configuration

In Spring Boot:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/graphql")
            .allowedOrigins("http://localhost:3000") // Development environment
            .allowedOrigins("https://your-production-domain.com") // Production environment
            .allowedMethods("GET", "POST", "OPTIONS")
            .allowedHeaders("Content-Type", "Authorization")
            .allowCredentials(true);
    }
}

Security Best Practices

  1. Input Validation: Validate all GraphQL inputs to prevent injection attacks.
@Component
public class InputValidator {

    public void validateRecipeInput(RecipeInput input) {
        if (input.getTitle() == null || input.getTitle().trim().isEmpty()) {
            throw new ValidationException("Recipe title cannot be empty");
        }

        if (input.getTitle().length() > 100) {
            throw new ValidationException("Recipe title cannot exceed 100 characters");
        }

        if (input.getPrepTime() != null && input.getPrepTime() < 0) {
            throw new ValidationException("Prep time cannot be negative");
        }

        // Additional validations...
    }
}
  1. Rate Limiting: Implement rate limiting to prevent DOS attacks.
@Component
public class RateLimitingInterceptor implements WebGraphQlInterceptor {

    private final RateLimiter rateLimiter;

    public RateLimitingInterceptor() {
        // Allow 10 requests per second
        this.rateLimiter = RateLimiter.create(10.0);
    }

    @Override
    public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
        if (!rateLimiter.tryAcquire()) {
            return Mono.error(new TooManyRequestsException("Rate limit exceeded"));
        }
        return chain.next(request);
    }
}
  1. Depth Limiting: Prevent deeply nested queries that could cause performance issues.
@Component
public class QueryDepthInterceptor implements WebGraphQlInterceptor {

    private static final int MAX_DEPTH = 5;

    @Override
    public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
        String query = request.getDocument();
        int depth = calculateQueryDepth(query);

        if (depth > MAX_DEPTH) {
            return Mono.error(new QueryComplexityException("Query exceeds maximum depth of " + MAX_DEPTH));
        }

        return chain.next(request);
    }

    private int calculateQueryDepth(String query) {
        // Simplified implementation
        // In a real application, you would use a proper GraphQL parser
        int maxDepth = 0;
        int currentDepth = 0;

        for (char c : query.toCharArray()) {
            if (c == '{') {
                currentDepth++;
                maxDepth = Math.max(maxDepth, currentDepth);
            } else if (c == '}') {
                currentDepth--;
            }
        }

        return maxDepth;
    }
}

Monitoring and Logging

Use Spring Boot Actuator and custom GraphQL metrics:

@Component
public class GraphQLMetricsInstrumentation implements InstrumentationProvider {

    private final MeterRegistry meterRegistry;
    private final Logger logger = LoggerFactory.getLogger(GraphQLMetricsInstrumentation.class);

    public GraphQLMetricsInstrumentation(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Override
    public Instrumentation getInstrumentation() {
        return new SimpleInstrumentation() {
            @Override
            public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters) {
                Timer.Sample sample = Timer.start(meterRegistry);
                String operationName = parameters.getOperation() != null ? parameters.getOperation() : "unknown";

                logger.info("GraphQL operation started: {}", operationName);

                return new SimpleInstrumentationContext<>() {
                    @Override
                    public void onCompleted(ExecutionResult result, Throwable t) {
                        sample.stop(meterRegistry.timer("graphql.execution", 
                            "operation", operationName, 
                            "success", String.valueOf(t == null)));

                        if (t != null) {
                            logger.error("GraphQL operation failed: {}", operationName, t);
                        } else {
                            logger.info("GraphQL operation completed: {}", operationName);
                        }
                    }
                };
            }
        };
    }
}

Conclusion

GraphQL with Spring Boot and React provides a powerful, flexible, and efficient stack for modern web applications. By building our Recipe Management System, we've learned how to:

  1. Set up a Spring Boot GraphQL API with a comprehensive domain model
  2. Implement advanced features like DataLoader, subscriptions for real-time updates, and pagination
  3. Build a React frontend using Apollo Client that provides an intuitive user experience
  4. Test and optimize your GraphQL application
  5. Deploy it with production-ready configurations including security and monitoring

The combination of strong typing, precise data fetching, and real-time capabilities makes GraphQL an excellent choice for modern applications. As your application grows, you'll appreciate the flexibility and developer experience that GraphQL provides.

Remember that GraphQL is a tool, and like any tool, it should be used when it fits your use case. For simple CRUD applications with few entities, REST might still be simpler. But for complex data requirements, especially when working with a rich client application like our recipe management system, GraphQL truly shines.

Happy coding!