ASP.NET Core  

GraphQL with .NET & React | Part 5: Advanced Patterns (Pagination, Errors, JWT & Testing)

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

StrategyHow it worksBest for
Offset-basedSkip N items, take MSimple lists, known total
Cursor-basedUse opaque cursor to find positionInfinite scroll, changing data
Page-basedPage number + page sizeTraditional 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:

FieldPurpose
first / lastHow many items to fetch
after / beforeCursor to paginate from
pageInfoNavigation metadata
edgesItems with their cursors
nodesItems without cursors (shortcut)
totalCountTotal 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

TypeWhen it happensHow to handle
Network errorNo connection, timeoutApollo error field
GraphQL errorQuery syntax, validationApollo error field
Business errorInvalid input, not foundPayload 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

ConceptQuestion 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:

  1. User logs in → Server returns a token

  2. Client includes token in all requests

  3. 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. 1 query to get all books

  2. 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

  1. Use descriptive names - bookById not getBook

  2. Consistent naming - camelCase for fields, PascalCase for types

  3. Input types for mutations - Group parameters logically

  4. Return affected data - Mutations return what was changed

Performance

  1. Use pagination - Never return unbounded lists

  2. Implement DataLoaders - Solve N+1 queries

  3. Use projections - Only fetch needed columns

  4. Set max page sizes - Prevent abuse

Error Handling

  1. Payload-based errors - Return errors in response

  2. Descriptive messages - Help users fix problems

  3. Error codes - Enable programmatic handling

  4. Validate inputs - Fail fast with clear messages

Security

  1. Authenticate mutations - Don't allow anonymous writes

  2. Authorize by resource - Check ownership/roles

  3. Rate limiting - Prevent abuse

  4. Query complexity limits - Prevent expensive queries

GraphQL Ecosystem Tools

ToolPurpose
Banana Cake PopHotChocolate's GraphQL IDE
Apollo StudioSchema management, metrics
GraphQL CodegenGenerate TypeScript types from schema
GraphQL VoyagerVisual schema explorer
AltairAlternative GraphQL client

Key Takeaways

  1. Cursor-based pagination - Industry standard, handles changing data

  2. Payload errors - Return errors in response for predictable handling

  3. DataLoaders - Batch database calls to avoid N+1 problem

  4. Projections - Only fetch columns you need

  5. Authentication - JWT tokens in headers

  6. Authorization - [Authorize] attributes on resolvers

  7. Testing - Unit test resolvers, integration test schema

Congratulations! 🎉

You've completed the GraphQL learning series! You now understand:

TopicWhat You Learned
Part 1GraphQL basics, schema, first queries
Part 2Arguments, variables, aliases, fragments, directives, filtering
Part 3Mutations, input types, cache management, optimistic updates
Part 4Subscriptions, WebSockets, real-time updates
Part 5Pagination, error handling, auth, performance, testing

Where to Go Next

Code: GitHub

  1. Practice - Build your own GraphQL API for a different domain

  2. Explore federation - Connect multiple GraphQL services

  3. Try Relay - Facebook's GraphQL client with strict conventions

  4. Learn schema stitching - Combine multiple schemas

  5. Study caching strategies - Optimize for your use case

Final Exercises

  1. Add authentication to your Library API

  2. Implement DataLoaders for authors and categories

  3. Add query complexity analysis to prevent expensive queries

  4. Create automated tests for all mutations

  5. 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/