Series: GraphQL with .NET & React, From Zero to Production
Welcome to this comprehensive GraphQL tutorial series! By the end of these 5 articles, you'll have a solid understanding of GraphQL and hands-on experience building a full-stack application with React and .NET.
Get your code here: GitHub
This is the one series to take you from GraphQL basics to advanced patterns.
Part 1: Foundations & Library Backend
Part 2: Query (Arguments, Aliases & Fragments)
Part 3: Mutations (Create, Update & Delete)
Part 4: Real-Time Data with web sockets
Part 5: Advanced Patterns (Pagination, Errors, JWT & Testing)
Welcome to the final part of our GraphQL series! You've learned queries, mutations, and subscriptions. Now, let's master the advanced concepts that set great GraphQL implementations apart.
Where We Left Off
Our Library Management System is fully functional with:
Complex queries with filtering and sorting
Mutations for all CRUD operations
Real-time subscriptions for new books and reviews
Now let's add the polish: proper pagination, robust error handling, authentication patterns, and performance optimization.
Understanding Pagination
Why Pagination Matters
Imagine querying 10,000 books:
# ❌ Bad: Returns EVERYTHINGquery {
books { id title }
}
Problems:
Slow response (transferring tons of data)
High memory usage (server and client)
Poor user experience (loading spinner forever)
Potentially crashes the server or client
Pagination solves this by fetching data in chunks.
Pagination Strategies
| Strategy | How it works | Best for |
|---|
| Offset-based | Skip N items, take M | Simple lists, known total |
| Cursor-based | Use opaque cursor to find position | Infinite scroll, changing data |
| Page-based | Page number + page size | Traditional pagination UI |
Offset-Based Pagination
Simple but has issues with changing data:
query GetBooks($skip: Int!, $take: Int!) {
books(skip: $skip, take: $take) {
id
title
}}
The problem: If new items are added while paginating, you might miss or duplicate items.
Cursor-Based Pagination (Relay-Style)
The GraphQL standard, used by HotChocolate by default:
query GetBooks($first: Int, $after: String) {
books(first: $first, after: $after) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
title
author { name }
}
}
nodes {
id
title
}
totalCount
}}
Let's break this down:
| Field | Purpose |
|---|
| first / last | How many items to fetch |
| after / before | Cursor to paginate from |
| pageInfo | Navigation metadata |
| edges | Items with their cursors |
| nodes | Items without cursors (shortcut) |
| totalCount | Total items (optional) |
What Are Cursors?
A cursor is an opaque string that identifies a position in the dataset. You don't need to understand what's inside - just pass it back to get the next page.
Example cursor (base64 encoded): YXJyYXljb25uZWN0aW9uOjk=
Why opaque?
Server can change implementation without breaking clients
Prevents clients from manipulating pagination logic
More secure than exposing database IDs
HotChocolate Pagination Setup
public class Query
{
[UsePaging(
IncludeTotalCount = true, // Enable totalCount field
MaxPageSize = 50, // Prevent fetching too many
DefaultPageSize = 10 // Default if not specified
)]
[UseFiltering]
[UseSorting]
public IQueryable<Book> GetBooks(LibraryDbContext context)
{
return context.Books.AsNoTracking();
}
}
React Pagination Component
import { useQuery, gql } from '@apollo/client';
const GET_BOOKS_PAGINATED = gql`
query GetBooks($first: Int, $after: String) {
books(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
title
author { name }
}
totalCount
}
}
`;
function BookListPaginated() {
const { data, loading, fetchMore } = useQuery(GET_BOOKS_PAGINATED, {
variables: { first: 10 }
});
const loadMore = () => {
if (!data?.books.pageInfo.hasNextPage) return;
fetchMore({
variables: {
after: data.books.pageInfo.endCursor, // Pass the cursor
},
// updateQuery merges new results with existing
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
books: {
...fetchMoreResult.books,
nodes: [
...prev.books.nodes,
...fetchMoreResult.books.nodes,
],
},
};
},
});
};
if (loading && !data) return <p>Loading...</p>;
return (
<div>
<p>Showing {data.books.nodes.length} of {data.books.totalCount} books</p>
{data.books.nodes.map(book => (
<div key={book.id}>
<strong>{book.title}</strong> by {book.author?.name}
</div>
))}
{data.books.pageInfo.hasNextPage && (
<button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Key concepts:
fetchMore - Apollo function to load additional pages
endCursor - Pass this to get the next page
hasNextPage - Boolean indicating if more data exists
updateQuery - Merges new data with existing cache
Error Handling
Types of GraphQL Errors
| Type | When it happens | How to handle |
|---|
| Network error | No connection, timeout | Apollo error field |
| GraphQL error | Query syntax, validation | Apollo error field |
| Business error | Invalid input, not found | Payload error field |
Strategy 1: Errors in Response (Recommended)
Return errors as part of the payload:
public record AddBookPayload(Book? Book, string? Error);
public async Task<AddBookPayload> AddBook(AddBookInput input)
{
if (string.IsNullOrWhiteSpace(input.Title))
return new AddBookPayload(null, "Title is required");
if (input.Price < 0)
return new AddBookPayload(null, "Price cannot be negative");
// ... create book
return new AddBookPayload(book, null);
}
Frontend handling:
const [addBook] = useMutation(ADD_BOOK, {
onCompleted: (data) => {
if (data.addBook.error) {
// Business logic error - show user-friendly message
toast.error(data.addBook.error);
} else {
toast.success(`Added: ${data.addBook.book.title}`);
}
},
onError: (error) => {
// Network/GraphQL error - unexpected, log it
console.error('Mutation failed:', error);
toast.error('Something went wrong. Please try again.');
}
});
Strategy 2: Union Types for Multiple Error Types
When you need structured errors with different types:
// Different error typespublic record ValidationError(string Field, string Message);
public record NotFoundError(string Entity, int Id);
// Union type: Result is either Book OR an error
[UnionType("AddBookResult")]
public interface IAddBookResult { }
public class AddBookSuccess : IAddBookResult
{
public required Book Book { get; init; }
}
public class AddBookValidationError : IAddBookResult
{
public required List<ValidationError> Errors { get; init; }
}
public class AddBookNotFoundError : IAddBookResult
{
public required string Message { get; init; }
}
GraphQL query with union:
mutation AddBook($input: AddBookInput!) {
addBook(input: $input) {
... on AddBookSuccess {
book { id title }
}
... on AddBookValidationError {
errors { field message }
}
... on AddBookNotFoundError {
message
}}}
Strategy 3: Error Extensions
Add metadata to errors:
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Book not found")
.SetCode("BOOK_NOT_FOUND")
.SetExtension("bookId", id)
.Build());
Response:
{"errors": [{
"message": "Book not found",
"extensions": {
"code": "BOOK_NOT_FOUND",
"bookId": 123
}}]}
Authentication and Authorization
Concepts
| Concept | Question it answers |
|---|
| Authentication | "Who are you?" |
| Authorization | "What can you do?" |
JWT Authentication Overview
JWT (JSON Web Token) is a common way to authenticate API requests:
User logs in → Server returns a token
Client includes token in all requests
Server validates token and identifies user
Backend Setup
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "your-issuer",
ValidAudience = "your-audience",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("your-secret-key"))
};
});
builder.Services.AddAuthorization();
// Add authorization to GraphQL
builder.Services
.AddGraphQLServer()
.AddAuthorization() // Enable GraphQL authorization
// ... other configurations
Protecting Mutations
public class Mutation
{
[Authorize] // Requires authentication
public async Task<AddBookPayload> AddBook(
[Service] IHttpContextAccessor contextAccessor,
AddBookInput input)
{
// Get user from token
var user = contextAccessor.HttpContext?.User;
var userId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// ... create book with userId as creator
}
[Authorize(Roles = new[] { "Admin" })] // Requires admin role
public async Task<DeletePayload> DeleteBook(int id)
{
// Only admins can delete
}
}
Frontend: Including Token
// client.tsconst httpLink = new HttpLink({
uri: 'http://localhost:5000/graphql',
headers: {
authorization: localStorage.getItem('token')
? `Bearer ${localStorage.getItem('token')}`
: '',
},
});
// Or dynamically with setContext:import { setContext } from '@apollo/client/link/context';
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
}
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
Performance Optimization
The N+1 Problem
Consider this query:
query {
books {
nodes {
title
author { name } # Fetches author for EACH book!
}
}
}
Without optimization, this causes:
1 query to get all books
N queries to get each author (one per book!)
Result: 10 books = 11 database queries!
Solution: DataLoaders
DataLoaders batch multiple requests into one:
Without DataLoader:
Book 1 → Get Author 1
Book 2 → Get Author 2
Book 3 → Get Author 3
(3 separate queries)
With DataLoader:
Collect: Author IDs [1, 2, 3]
Batch: SELECT * FROM Authors WHERE Id IN (1, 2, 3)
(1 query!)
Implementing DataLoaders in HotChocolate
// DataLoaders/AuthorBatchDataLoader.cs
public class AuthorBatchDataLoader : BatchDataLoader<int, Author>
{
private readonly IDbContextFactory<LibraryDbContext> _contextFactory;
public AuthorBatchDataLoader(
IDbContextFactory<LibraryDbContext> contextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options)
{
_contextFactory = contextFactory;
}
protected override async Task<IReadOnlyDictionary<int, Author>> LoadBatchAsync(
IReadOnlyList<int> keys,
CancellationToken cancellationToken)
{
await using var context = _contextFactory.CreateDbContext();
// One query for ALL requested authors
return await context.Authors
.Where(a => keys.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, cancellationToken);
}
}
Using DataLoaders in Resolvers
public class BookType : ObjectType<Book>
{
protected override void Configure(IObjectTypeDescriptor<Book> descriptor)
{
descriptor.Field(b => b.Author)
.ResolveWith<BookResolvers>(r => r.GetAuthor(default!, default!));
}
}
public class BookResolvers
{
public async Task<Author?> GetAuthor(
[Parent] Book book,
AuthorBatchDataLoader authorLoader)
{
return await authorLoader.LoadAsync(book.AuthorId);
}
}
Projections
Only select columns you need:
[UsePaging]
[UseProjection] // Enables automatic field selection
[UseFiltering]
[UseSorting]
public IQueryable<Book> GetBooks(LibraryDbContext context)
{
return context.Books; // Don't use AsNoTracking() with projections
}
Now this query:
query { books { nodes { title price } } }
Generates this SQL:
SELECT Title, Price FROM Books -- Only requested columns!
Instead of:
SELECT * FROM Books -- All columns (wasteful)
Testing GraphQL APIs
Unit Testing Resolvers
Test your business logic directly:
[Fact]
public async Task AddBook_WithInvalidAuthor_ReturnsError()
{
// Arrange
var options = new DbContextOptionsBuilder<LibraryDbContext>()
.UseInMemoryDatabase("TestDb")
.Options;
using var context = new LibraryDbContext(options);
var mutation = new Mutation();
var input = new AddBookInput(
Title: "Test Book",
AuthorId: 999, // Non-existent author
// ... other fields
);
// Act
var result = await mutation.AddBook(context, /* mock */ null!, input);
// Assert
Assert.Null(result.Book);
Assert.Equal("Author not found", result.Error);
}
Integration Testing with WebApplicationFactory
public class GraphQLTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public GraphQLTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetBooks_ReturnsBooks()
{
// Arrange
var query = new
{
query = @"
query {
books {
nodes { id title }
}
}
"
};
// Act
var response = await _client.PostAsJsonAsync("/graphql", query);
var content = await response.Content.ReadAsStringAsync();
// Assert
response.EnsureSuccessStatusCode();
Assert.Contains("nodes", content);
}
}
Frontend Testing with MockedProvider
import { MockedProvider } from '@apollo/client/testing';
import { render, screen } from '@testing-library/react';
const mocks = [
{
request: {
query: GET_BOOKS,
variables: { first: 10 }
},
result: {
data: {
books: {
nodes: [
{ id: 1, title: 'Test Book', author: { name: 'Author' } }
],
pageInfo: { hasNextPage: false, endCursor: null },
totalCount: 1
}
}
}
}
];
test('renders books', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<BookList />
</MockedProvider>
);
expect(await screen.findByText('Test Book')).toBeInTheDocument();
});
Best Practices Summary
Schema Design
Use descriptive names - bookById not getBook
Consistent naming - camelCase for fields, PascalCase for types
Input types for mutations - Group parameters logically
Return affected data - Mutations return what was changed
Performance
Use pagination - Never return unbounded lists
Implement DataLoaders - Solve N+1 queries
Use projections - Only fetch needed columns
Set max page sizes - Prevent abuse
Error Handling
Payload-based errors - Return errors in response
Descriptive messages - Help users fix problems
Error codes - Enable programmatic handling
Validate inputs - Fail fast with clear messages
Security
Authenticate mutations - Don't allow anonymous writes
Authorize by resource - Check ownership/roles
Rate limiting - Prevent abuse
Query complexity limits - Prevent expensive queries
GraphQL Ecosystem Tools
| Tool | Purpose |
|---|
| Banana Cake Pop | HotChocolate's GraphQL IDE |
| Apollo Studio | Schema management, metrics |
| GraphQL Codegen | Generate TypeScript types from schema |
| GraphQL Voyager | Visual schema explorer |
| Altair | Alternative GraphQL client |
Key Takeaways
Cursor-based pagination - Industry standard, handles changing data
Payload errors - Return errors in response for predictable handling
DataLoaders - Batch database calls to avoid N+1 problem
Projections - Only fetch columns you need
Authentication - JWT tokens in headers
Authorization - [Authorize] attributes on resolvers
Testing - Unit test resolvers, integration test schema
Congratulations! 🎉
You've completed the GraphQL learning series! You now understand:
| Topic | What You Learned |
|---|
| Part 1 | GraphQL basics, schema, first queries |
| Part 2 | Arguments, variables, aliases, fragments, directives, filtering |
| Part 3 | Mutations, input types, cache management, optimistic updates |
| Part 4 | Subscriptions, WebSockets, real-time updates |
| Part 5 | Pagination, error handling, auth, performance, testing |
Where to Go Next
Code: GitHub
Practice - Build your own GraphQL API for a different domain
Explore federation - Connect multiple GraphQL services
Try Relay - Facebook's GraphQL client with strict conventions
Learn schema stitching - Combine multiple schemas
Study caching strategies - Optimize for your use case
Final Exercises
Add authentication to your Library API
Implement DataLoaders for authors and categories
Add query complexity analysis to prevent expensive queries
Create automated tests for all mutations
Deploy your GraphQL API to a cloud service
Happy coding!
If yuou wish you connect, here are few links:
My Microsoft MVP Profile: https://mvp.microsoft.com/en-US/mvp/profile/b5021ca6-c3e6-4e38-bf50-8a4e1520ebc1
Linkedin: https://www.linkedin.com/in/rikampalkar/