DEV Community

Cover image for GraphQL with Spring Boot and React: Recipe Management System
Tanmoy Mandal
Tanmoy Mandal

Posted on • Edited on

GraphQL with Spring Boot and React: Recipe Management System

A comprehensive guide to building a full-stack Recipe Management System using GraphQL, Spring Boot, and React.

๐Ÿ”— Source Code

Why GraphQL?

GraphQL offers significant advantages over REST APIs:

  • Precise Data Fetching: Request exactly the data you need
  • Single Endpoint: All requests through one endpoint
  • Strong Typing: Clear contracts between client and server
  • Self-Documenting: Built-in introspection capabilities
  • Real-time Updates: Native subscription support

Backend Setup (Spring Boot)

Dependencies

Add to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

GraphQL Schema (schema.graphqls)

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
}

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

enum Difficulty {
    EASY
    MEDIUM
    HARD
}
Enter fullscreen mode Exit fullscreen mode

Core Entities

@Entity
@Data
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)
    private List<Ingredient> ingredients = new ArrayList<>();

    @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL)
    @OrderBy("stepNumber ASC")
    private List<Instruction> instructions = new ArrayList<>();
}
Enter fullscreen mode Exit fullscreen mode

GraphQL Controller

@Controller
public class RecipeController {
    private final RecipeRepository recipeRepository;

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

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

    @MutationMapping
    public Recipe createRecipe(@Argument RecipeInput input) {
        Recipe recipe = new Recipe();
        // Map input to entity
        recipe.setTitle(input.getTitle());
        recipe.setDescription(input.getDescription());
        recipe.setCategory(input.getCategory());
        // ... other mappings
        return recipeRepository.save(recipe);
    }

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

Frontend Setup (React)

Dependencies

npm install @apollo/client graphql react-router-dom @mui/material
Enter fullscreen mode Exit fullscreen mode

Apollo Client Setup

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

const client = new ApolloClient({
    link: new HttpLink({ uri: 'http://localhost:8080/graphql' }),
    cache: new InMemoryCache(),
});

export default client;
Enter fullscreen mode Exit fullscreen mode

GraphQL Queries

// queries.js
import { gql } from '@apollo/client';

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

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

React Components

// RecipeList.js
import { useQuery } from '@apollo/client';
import { GET_ALL_RECIPES } from '../graphql/queries';

function RecipeList() {
    const { loading, error, data } = useQuery(GET_ALL_RECIPES);

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

    return (
        <Container>
            <Typography variant="h4">Recipe Collection</Typography>
            <Grid container spacing={3}>
                {data.allRecipes.map(recipe => (
                    <Grid item key={recipe.id} xs={12} sm={6} md={4}>
                        <Card>
                            <CardContent>
                                <Typography variant="h6">{recipe.title}</Typography>
                                <Chip label={recipe.category} color="primary" />
                                <Typography variant="body2">
                                    {recipe.prepTime + recipe.cookTime} mins | {recipe.difficulty}
                                </Typography>
                            </CardContent>
                        </Card>
                    </Grid>
                ))}
            </Grid>
        </Container>
    );
}
Enter fullscreen mode Exit fullscreen mode

Advanced Features

1. Error Handling

Backend:

@Component
public class GraphQLExceptionHandler implements DataFetcherExceptionResolver {
    @Override
    public List<GraphQLError> resolveException(Throwable exception, DataFetchingEnvironment environment) {
        if (exception instanceof EntityNotFoundException) {
            return Collections.singletonList(
                GraphqlErrorBuilder.newError()
                    .message(exception.getMessage())
                    .errorType(ErrorType.DataFetchingException)
                    .build()
            );
        }
        return Collections.emptyList();
    }
}
Enter fullscreen mode Exit fullscreen mode

Frontend:

const { loading, error, data } = useQuery(GET_ALL_RECIPES);

if (error) {
    if (error.networkError) {
        return <Typography color="error">Network error occurred</Typography>;
    }
    return <Typography color="error">Error: {error.message}</Typography>;
}
Enter fullscreen mode Exit fullscreen mode

2. Authentication

Backend Security:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/graphql").authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Frontend Authentication:

const authLink = new ApolloLink((operation, forward) => {
    const token = localStorage.getItem('auth_token');
    operation.setContext({
        headers: {
            Authorization: token ? `Bearer ${token}` : '',
        },
    });
    return forward(operation);
});
Enter fullscreen mode Exit fullscreen mode

3. Real-time Updates with Subscriptions

Backend:

@Controller
public class RecipeSubscriptionController {
    private final Sinks.Many<Recipe> recipeAddedSink = 
        Sinks.many().multicast().onBackpressureBuffer();

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

    public void onRecipeAdded(Recipe recipe) {
        recipeAddedSink.tryEmitNext(recipe);
    }
}
Enter fullscreen mode Exit fullscreen mode

Frontend:

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

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

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

    return data ? (
        <Alert severity="info">
            New recipe added: {data.recipeAdded.title}
        </Alert>
    ) : null;
}
Enter fullscreen mode Exit fullscreen mode

4. Performance Optimization

Query Complexity Analysis:

@Bean
public QueryComplexityInstrumentation complexityInstrumentation() {
    return new QueryComplexityInstrumentation(100);
}
Enter fullscreen mode Exit fullscreen mode

Apollo Client Caching:

const client = new ApolloClient({
    cache: new InMemoryCache({
        typePolicies: {
            Recipe: {
                keyFields: ['id'],
                fields: {
                    ingredients: {
                        merge: (existing = [], incoming) => [...incoming],
                    },
                },
            },
        },
    }),
});
Enter fullscreen mode Exit fullscreen mode

5. Testing

Backend Testing:

@SpringBootTest
@AutoConfigureGraphQlTester
class RecipeControllerTests {
    @Autowired
    private GraphQlTester graphQlTester;

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

        graphQlTester.document(query)
            .execute()
            .path("allRecipes")
            .entityList(Map.class)
            .hasSize(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

Frontend Testing:

import { MockedProvider } from '@apollo/client/testing';

const mocks = [{
    request: { query: GET_ALL_RECIPES },
    result: { data: { allRecipes: [/* mock data */] } },
}];

test('renders recipe list', async () => {
    render(
        <MockedProvider mocks={mocks}>
            <RecipeList />
        </MockedProvider>
    );

    await waitFor(() => {
        expect(screen.getByText('Recipe Collection')).toBeInTheDocument();
    });
});
Enter fullscreen mode Exit fullscreen mode

Production Considerations

Security Best Practices

  • Input Validation: Validate all GraphQL inputs
  • Rate Limiting: Prevent DOS attacks
  • Depth Limiting: Prevent deeply nested queries
  • CORS Configuration: Properly configure CORS for production

Monitoring & Logging

@Component
public class GraphQLMetricsInstrumentation implements InstrumentationProvider {
    @Override
    public Instrumentation getInstrumentation() {
        return new SimpleInstrumentation() {
            @Override
            public InstrumentationContext<ExecutionResult> beginExecution(
                InstrumentationExecutionParameters parameters) {
                // Add metrics and logging
                return SimpleInstrumentationContext.noOp();
            }
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Benefits

โœ… Type Safety: Strong typing prevents runtime errors

โœ… Efficient Data Fetching: Get exactly what you need

โœ… Real-time Capabilities: Built-in subscription support

โœ… Developer Experience: Self-documenting API with GraphiQL

โœ… Flexible Architecture: Easy to evolve without breaking changes

Conclusion

GraphQL with Spring Boot and React provides a powerful, flexible stack for modern web applications. The Recipe Management System demonstrates how to implement core CRUD operations, real-time updates, authentication, and production-ready optimizations.

This stack is ideal for applications with complex data requirements and rich client interfaces, offering superior developer experience and performance compared to traditional REST APIs.

Top comments (1)

Collapse
 
james_dev_b83870886a profile image
James dev

Thank you for your long explanation.

I'm fullstack developer.
When I drop in backend developing, I used Restful API.....

thank you for your clarification