A comprehensive guide to building a full-stack Recipe Management System using GraphQL, Spring Boot, and React.
๐ Source Code
- Backend API: https://github.com/tanmoymandal/recipemanagement
- Frontend Code: https://github.com/tanmoymandal/recipemanagement-ui
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>
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
}
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<>();
}
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;
}
}
Frontend Setup (React)
Dependencies
npm install @apollo/client graphql react-router-dom @mui/material
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;
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
}
}
`;
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>
);
}
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();
}
}
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>;
}
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();
}
}
Frontend Authentication:
const authLink = new ApolloLink((operation, forward) => {
const token = localStorage.getItem('auth_token');
operation.setContext({
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
});
return forward(operation);
});
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);
}
}
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;
}
4. Performance Optimization
Query Complexity Analysis:
@Bean
public QueryComplexityInstrumentation complexityInstrumentation() {
return new QueryComplexityInstrumentation(100);
}
Apollo Client Caching:
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Recipe: {
keyFields: ['id'],
fields: {
ingredients: {
merge: (existing = [], incoming) => [...incoming],
},
},
},
},
}),
});
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);
}
}
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();
});
});
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();
}
};
}
}
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)
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