Isaac.

ASP.NET Core GraphQL

Implement GraphQL APIs in ASP.NET Core applications.

By EMEPublished: March 4, 2025
aspnetgraphqlapi

A Simple Explanation

The Restaurant Menu Analogy

Imagine two restaurants:

Traditional Restaurant (REST):

  • You order "Meal #1"
  • It comes with burger, fries, coleslaw, and dessert
  • You only wanted burger and fries
  • You pay for the whole meal
  • Leftover food is wasted

But if you want extra items:

  • You need to order "Meal #2" (completely different)
  • Or order individual items from different menus
  • Multiple trips to the counter

Smart Restaurant (GraphQL):

  • You say: "I want a burger, fries, and a drink"
  • That's exactly what you get
  • Nothing extra, no wasted food
  • If you change your mind, you just ask for something different
  • Same menu works for everyone

GraphQL in APIs:

  • Traditional REST: Fixed responses (you get all fields even if you only want some)
  • GraphQL: You request specific fields, get only those fields
  • Bandwidth: Less data transferred (mobile users love this)
  • Multiple requests: One query instead of many

Why GraphQL Exists

The Problem with REST APIs

Imagine you're building a mobile app showing a user's profile. You need:

  1. User's name
  2. User's email
  3. User's avatar
  4. Count of user's followers

With REST, the /api/users/{id} endpoint returns:

{
  "id": 1,
  "name": "John",
  "email": "john@example.com",
  "avatar": "...",
  "phone": "555-1234",
  "address": "123 Main St",
  "bio": "...",
  "birthDate": "1990-01-01",
  "followers": 1000000,
  "following": 500,
  // ... 20 more fields
}

Problem 1: Over-fetching - You got all 28 fields but only needed 4. Wasted 85% of bandwidth!

Then you need follower count, so you call /api/users/{id}/followers/count. That's another request.

Problem 2: Under-fetching - You need data from multiple sources, so you make 3-5 separate API calls:

  • GET /users/1
  • GET /users/1/followers
  • GET /users/1/posts
  • GET /posts/latest

For mobile apps on 4G, this is slow. For international users on slow connections, it's painful.

Problem 3: Version management - Your company grows. You want to add new fields:

/api/v1/users     (old, with old fields)
/api/v2/users     (new, with new fields)
/api/v3/users     (newer, with even more fields)

Now you maintain 3 versions. Clients don't upgrade. Nightmare.

GraphQL Solution

With GraphQL, client says:

query {
  user(id: 1) {
    name
    email
    avatar
    followers
  }
}

Server responds with exactly those 4 fields, nothing more:

{
  "user": {
    "name": "John",
    "email": "john@example.com",
    "avatar": "...",
    "followers": 1000000
  }
}

Benefits:

  • ✓ No over-fetching (25% payload size)
  • ✓ No under-fetching (one query gets everything)
  • ✓ No versioning (same endpoint forever)
  • ✓ Clients specify what they need
  • ✓ Mobile apps consume less battery and data

Content Overview

Table of Contents

  • How GraphQL Works (Conceptually)
  • Setting Up HotChocolate
  • Defining Types and Queries
  • Understanding Query Execution
  • Mutations (Creating & Modifying Data)
  • Nested Queries & Relationships
  • DataLoader for N+1 Prevention
  • Subscriptions (Real-time Updates)
  • Error Handling & Validation
  • Real-World Use Cases
  • Performance Considerations
  • GraphQL vs REST Comparison
  • Related Concepts to Explore

How GraphQL Works (Conceptually)

The Three-Layer System:

Layer 1: Schema

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
}

type Query {
  user(id: ID!): User
  posts: [Post!]!
}

This is like a contract: "Here's what data I have and what you can ask for"

Layer 2: Request

query {
  user(id: "1") {
    name
    email
    posts {
      title
    }
  }
}

Client says: "Give me this user's name, email, and titles of their posts"

Layer 3: Response

{
  "data": {
    "user": {
      "name": "John",
      "email": "john@example.com",
      "posts": [
        { "title": "GraphQL Guide" },
        { "title": "REST API Tips" }
      ]
    }
  }
}

Server responds with EXACTLY what was requested

Key Difference from REST:

  • REST: Client hits endpoint, server decides what to return
  • GraphQL: Client specifies what fields needed, server returns only those

1. Setting Up HotChocolate

What is HotChocolate?

HotChocolate is a GraphQL server library for .NET. It:

  • Takes your C# classes and converts them to GraphQL schema automatically
  • Handles query parsing and execution
  • Provides a playground to test queries (like Postman for GraphQL)
  • Integrates seamlessly with ASP.NET Core

Installation:

dotnet add package HotChocolate.AspNetCore

Configuration in Program.cs:

var builder = WebApplicationBuilder.CreateBuilder(args);

// Add GraphQL services
builder
    .Services
    .AddGraphQLServer()                    // Initialize GraphQL
    .AddQueryType<Query>()                 // What clients can query
    .AddMutationType<Mutation>()           // What clients can modify
    .AddSubscriptionType<Subscription>()   // Real-time updates
    .AddTransactionalOperationMiddleware() // Transaction support
    .ModifyRequestOptions(opt =>           // Configure behavior
    {
        opt.MaximumAllowedOperationComplexity = 50;
    });

var app = builder.Build();

// Map GraphQL endpoint
app.MapGraphQL("/graphql");  // Accessible at /graphql
app.MapGraphQLVoyager();      // Visual explorer at /graphql-voyager

app.Run();

After Setup:

  • Visit https://localhost:5001/graphql to access GraphQL Playground
  • Write queries and test them instantly
  • Schema documentation is auto-generated

2. Defining Types and Queries

Creating Your First GraphQL Type

C# class → GraphQL type (automatically!)

// This C# class...
public class Product
{
    public int Id { get; set; }                // Becomes: id: Int!
    public string Name { get; set; }           // Becomes: name: String!
    public decimal Price { get; set; }         // Becomes: price: Float!
    public string Description { get; set; }    // Becomes: description: String!
    public int Stock { get; set; }             // Becomes: stock: Int!
}

// ...automatically generates this GraphQL schema:
// type Product {
//   id: Int!
//   name: String!
//   price: Float!
//   description: String!
//   stock: Int!
// }

Creating Query Methods

public class Query
{
    private readonly IDbContext _db;
    
    public Query(IDbContext db)
    {
        _db = db;  // Dependency injection
    }

    // Becomes GraphQL query: product(id: Int!): Product
    public Product GetProduct(int id)
    {
        return _db.Products.FirstOrDefault(p => p.Id == id);
    }

    // Becomes GraphQL query: products: [Product!]!
    public IEnumerable<Product> GetProducts()
    {
        return _db.Products.ToList();
    }
    
    // Becomes GraphQL query: productsByCategory(categoryId: Int!): [Product!]!
    public IEnumerable<Product> GetProductsByCategory(int categoryId)
    {
        return _db.Products
            .Where(p => p.CategoryId == categoryId)
            .ToList();
    }
}

Making Your First Query

In GraphQL Playground, you write:

query GetAllProducts {
  products {
    id
    name
    price
  }
}

The Magic: You asked for only 3 fields (id, name, price). The response includes only those fields:

{
  "data": {
    "products": [
      {
        "id": 1,
        "name": "Laptop",
        "price": 999.99
      },
      {
        "id": 2,
        "name": "Mouse",
        "price": 19.99
      }
    ]
  }
}

Notice: description and stock are NOT in response because we didn't ask for them.

Compare to REST:

# REST: You get everything
GET /api/products
# Response: 100 fields per product, 50KB payload

# GraphQL: You get what you ask for
POST /graphql
query { products { id name price } }
# Response: 3 fields per product, 2KB payload

With Arguments

public class Query
{
    // Becomes: products(skip: Int!, take: Int!): [Product!]!
    public IEnumerable<Product> GetProducts(
        int skip = 0,
        int take = 10)
    {
        return _db.Products
            .Skip(skip)
            .Take(take)
            .ToList();
    }
    
    // Becomes: productsBetween(minPrice: Float!, maxPrice: Float!): [Product!]!
    public IEnumerable<Product> GetProductsBetween(
        decimal minPrice,
        decimal maxPrice)
    {
        return _db.Products
            .Where(p => p.Price >= minPrice && p.Price <= maxPrice)
            .ToList();
    }
}

Query them like this:

query {
  productsBetween(minPrice: 100, maxPrice: 500) {
    name
    price
  }
}

3. Understanding Query Execution

How GraphQL Processes a Query

When you send a query:

query {
  product(id: 1) {
    name
    category {
      name
    }
  }
}

GraphQL does:

  1. Parse: Check query syntax is valid
  2. Validate: Check query matches schema
  3. Execute:
    • Call Query.Product(id: 1) → Gets Product with id=1
    • Call Product.Name → Gets product's name
    • Call Product.Category → Gets associated category
    • Call Category.Name → Gets category name
  4. Return: Sends only requested fields

Field Resolvers

By default, GraphQL maps properties automatically:

public class Product
{
    public int Id { get; set; }           // Auto-resolves from property
    public string Name { get; set; }      // Auto-resolves from property
    
    // But related data needs custom resolvers:
    [GraphQLIgnore]
    public int CategoryId { get; set; }   // Don't expose in GraphQL
    
    // Custom resolver for related category
    public async Task<Category> GetCategory(
        [Service] IDbContext db)          // Inject dependencies
    {
        return await db.Categories
            .FirstOrDefaultAsync(c => c.Id == CategoryId);
    }
}

Now clients can query nested data:

query {
  products {
    name
    category {
      name
    }
  }
}

4. Mutations (Creating & Modifying Data)

Mutations are how you modify data in GraphQL (like POST/PUT in REST).

public class Mutation
{
    public Product CreateProduct(
        string name, 
        decimal price, 
        string description)
    {
        var product = new Product 
        { 
            Name = name, 
            Price = price, 
            Description = description 
        };
        
        _db.Products.Add(product);
        _db.SaveChanges();
        
        return product;
    }

    public bool UpdateProduct(int id, string name, decimal price)
    {
        var product = _db.Products.FirstOrDefault(p => p.Id == id);
        if (product == null) return false;

        product.Name = name;
        product.Price = price;
        _db.SaveChanges();
        
        return true;
    }

    public bool DeleteProduct(int id)
    {
        var product = _db.Products.FirstOrDefault(p => p.Id == id);
        if (product == null) return false;

        _db.Products.Remove(product);
        _db.SaveChanges();
        
        return true;
    }
}

GraphQL Mutation Example:

mutation {
  createProduct(
    name: "Laptop"
    price: 999.99
    description: "High-performance laptop"
  ) {
    id
    name
  }
}

5. Nested Queries & Relationships

The N+1 Problem in Action

Imagine you want products with their categories:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }
    
    // Without optimization, this queries the database for EVERY product
    public Category Category { get; set; }  // ❌ Causes N+1 queries
}

When you query 100 products:

  • Query 1: Get 100 products
  • Query 2-101: For each product, get its category
  • Total: 101 database queries! 🔴

For 1,000 products? 1,001 queries. Disaster.

Bad Performance Flow:

GraphQL Query comes in
↓
Resolve 1,000 products (Query 1)
↓
For each product, resolve category (Query 2, 3, 4... 1,001)
↓
Database is overloaded
↓
App becomes slow

6. DataLoader for N+1 Prevention

The DataLoader Solution

Instead of loading categories one-by-one, batch them:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }

    // Instead of direct property, use DataLoader
    public async Task<Category> GetCategory(
        [Service] CategoryDataLoader loader)  // Inject DataLoader
    {
        return await loader.LoadAsync(CategoryId);  // Queue for batch loading
    }
}

// Define the DataLoader
public class CategoryDataLoader : BatchDataLoader<int, Category>
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public CategoryDataLoader(IDbContextFactory<AppDbContext> contextFactory)
        : base()
    {
        _contextFactory = contextFactory;
    }

    // Called once with ALL category IDs needed
    protected override async Task<IReadOnlyDictionary<int, Category>> LoadBatchAsync(
        IReadOnlyList<int> keys,  // All category IDs: [5, 7, 12, 5, 7, ...]
        CancellationToken ct)
    {
        var db = await _contextFactory.CreateDbContextAsync(ct);
        var uniqueKeys = keys.Distinct().ToList();  // Remove duplicates: [5, 7, 12]
        
        // Single query: Get all 3 categories at once
        var categories = await db.Categories
            .Where(c => uniqueKeys.Contains(c.Id))
            .ToDictionaryAsync(c => c.Id, ct);

        return categories;
    }
}

// Register in Program.cs
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddDataLoader<CategoryDataLoader>();

How DataLoader Works Internally

GraphQL executes query for 1,000 products
↓
For each product's Category field:
  - Don't fetch immediately
  - Add (categoryId) to "batch queue"
  - Queue: [5, 7, 12, 5, 7, ...] (duplicates allowed)
↓
All fields resolved, time to execute batches
↓
DataLoader batches: [5, 7, 12, 5, 7, ...] → unique: [5, 7, 12]
↓
Single query: SELECT * FROM Categories WHERE Id IN (5, 7, 12)
↓
Return results: { 5: Category5, 7: Category7, 12: Category12 }
↓
Each product gets its category

Performance Comparison:

Without DataLoader:
- 1,001 database queries
- Response time: 5 seconds
- Database CPU: 95%

With DataLoader:
- 2 database queries (products + categories)
- Response time: 50ms
- Database CPU: 5%

When to Use DataLoader

✓ When loading related data (products → categories) ✓ When same ID requested multiple times ✓ When you have many-to-one relationships ✗ When you only load a few items ✗ When data is already in memory/cache

7. Subscriptions (Real-time Updates)

Get notified when data changes:

public class Subscription
{
    public async IAsyncEnumerable<Product> ProductCreated(
        [Service] ProductUpdatedTopic topic)
    {
        await foreach (var product in topic.ProductCreated())
        {
            yield return product;
        }
    }
}

// In mutation
public class Mutation
{
    public async Task<Product> CreateProduct(
        string name,
        [Service] ProductUpdatedTopic topic)
    {
        var product = new Product { Name = name };
        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        await topic.OnProductCreated(product);
        return product;
    }
}

8. Error Handling & Validation

GraphQL provides detailed error information:

public class Query
{
    public Product GetProduct(int id)
    {
        var product = _db.Products.FirstOrDefault(p => p.Id == id);
        
        if (product == null)
        {
            throw new GraphQLException(
                new Error(
                    $"Product with ID {id} not found",
                    extensions: new Dictionary<string, object>
                    {
                        { "code", "NOT_FOUND" },
                        { "productId", id }
                    }
                )
            );
        }

        return product;
    }
}

9. Real-World Use Cases

Use Case 1: Mobile App - Twitter Clone

Home Feed Page needs:
- Tweet ID, text, timestamp
- Author (name, avatar)
- Like count, reply count

Query:
```graphql
query HomeFeed {
  tweets(limit: 20) {
    id
    text
    timestamp
    author { name avatar }
    likeCount
    replyCount
  }
}

Response: ~40KB (efficient for mobile)

Profile Page needs different fields:

  • Tweet ID, text
  • Full author profile
  • Media urls
  • Engagement metrics

Query:

query UserProfile($userId: ID!) {
  user(id: $userId) {
    name
    bio
    followers
    tweets {
      id
      text
      mediaUrl
      likeCount
      replyCount
    }
  }
}

Response: Different structure, same endpoint

Benefit: Same API endpoint (/graphql), but mobile saves 60% bandwidth vs. REST


Use Case 2: E-commerce Dashboard

Manager's dashboard needs:

query Dashboard {
  totalRevenue: summaryStats { totalSales monthlyGrowth }
  topProducts: products(limit: 5, orderBy: SALES) {
    name
    sales
    revenue
  }
  recentOrders: orders(limit: 10) {
    id
    customer { name email }
    total
    status
  }
}

Instead of 3 REST endpoints:

  • GET /api/stats
  • GET /api/products/top
  • GET /api/orders/recent

One GraphQL query returns everything.


Use Case 3: Real-time Multiplayer Game

subscription GameUpdates($gameId: ID!) {
  gameStateChanged(gameId: $gameId) {
    players { id position health }
    items { id x y }
    events { type timestamp }
  }
}

When any change happens (player moved, item picked up), all clients subscribed to this game get instant update via WebSocket.


Use Case 4: Multi-tenant SaaS Platform

Different customers see different data:

query CompanyData($companyId: ID!) {
  company(id: $companyId) {
    name
    employees { id name role salary }
    projects { id name status }
    financials { revenue expenses profit }
  }
}
  • Customer A might want: name, employees, projects
  • Customer B might want: name, financials, projects
  • Same query endpoint, different responses based on permissions

Use Case 5: Backend-for-Frontend (BFF)

You have one backend API serving:

  • Web app (desktop browser)
  • Mobile app (iOS/Android)
  • Tablet app
  • Smart TV app

Each platform has different data needs and network conditions. Instead of 4 different APIs, one GraphQL endpoint optimized for each client's query.


Use Case 6: Microservices Integration

You have:

  • User Service
  • Product Service
  • Order Service
  • Payment Service

GraphQL gateway federates them:

query OrderDetails($orderId: ID!) {
  order(id: $orderId) {
    id
    customer { id name email }  # From User Service
    items { id title price }    # From Product Service
    payment { status method }   # From Payment Service
  }
}

Clients see single API. Internally, GraphQL calls 3 services. Hides complexity.

10. Performance Considerations

Common Performance Issues

Problem 1: Expensive Nested Queries

A query like this could be devastating:

query {
  users {
    id
    posts {
      id
      comments {
        id
        author {
          id
          posts {
            id
            comments { id }
          }
        }
      }
    }
  }
}

This could request millions of records. Solution: Query complexity analysis

builder.Services
    .AddGraphQLServer()
    .AddMaxComplexityRules(maxComplexity: 50)  // Block complex queries

Problem 2: Unbounded Lists

query {
  allUsers {  # What if there's 1 million users?
    id name posts { id }
  }
}

Solution: Require pagination

public class Query
{
    public Connection<User> GetUsers(
        int first = 10,    // Default to 10
        int? after = null) // Cursor-based pagination
    {
        // Return only 10 users, provide cursor for next batch
    }
}

Problem 3: Missing Indexes

GraphQL might query data in unexpected ways. Ensure your database indexes are good.

Solution: Caching

public class Query
{
    [GraphQLType("User")]
    [Cached(duration: 300)]  // Cache for 5 minutes
    public User GetUser(int id)
    {
        return _db.Users.FirstOrDefault(u => u.Id == id);
    }
}

11. GraphQL vs REST Comparison

| Aspect | GraphQL | REST | |--------|---------|------| | Fetching Data | Exact fields requested | Fixed response structure | | Multiple Resources | Single query | Multiple endpoints (N requests) | | Versioning | No versions needed | v1, v2, v3... | | Caching | More complex (not HTTP native) | HTTP caching works naturally | | Learning Curve | Steeper | Easier for basics | | Bandwidth | Optimal (only requested fields) | Often over-fetched | | Performance | Fast with proper optimization | Consistent | | Tooling | Excellent (Playground, voyager) | Simple (cURL, Postman) | | Type Safety | Strong schema validation | Manual validation | | Real-time | Native (subscriptions) | Need WebSockets separately |

When to Use Each

Use GraphQL When:

  • ✓ Mobile clients (bandwidth matters)
  • ✓ Multiple clients with different needs
  • ✓ Complex data relationships
  • ✓ Real-time updates needed
  • ✓ Internal APIs between services

Use REST When:

  • ✓ Simple CRUD operations
  • ✓ Heavy caching is important
  • ✓ Team unfamiliar with GraphQL
  • ✓ Public API for external developers (REST is more familiar)
  • ✓ Simple resource-based APIs

12. Related Concepts to Explore

GraphQL Core Concepts

  • SDL (Schema Definition Language) - Language for writing GraphQL schemas
  • Type System - Scalars, Objects, Interfaces, Unions, Enums
  • Directives - Annotations like @deprecated, @cacheControl
  • Aliases - Get same field with different names: { newName: oldField }
  • Fragments - Reuse query parts: fragment ProductFields on Product { id name }
  • Variables - Parameterize queries: query GetUser($id: ID!) { user(id: $id) { ... } }
  • Introspection - Query the schema itself
  • Custom Scalars - Beyond Int, Float, String, Boolean (e.g., DateTime, UUID)

Query Optimization

  • Query Complexity Analysis - Prevent expensive queries
  • Query Depth Limiting - Prevent deeply nested queries
  • Field Timeout - Kill slow field resolvers
  • Result Set Size Limiting - Cap returned records
  • Batch DataLoader - Prevent N+1 queries
  • Query Persisting - Pre-compile queries for security and performance
  • Operation Name Whitelisting - Only allow approved queries
  • Cost Analysis - Assign costs to fields, sum for query

Authorization & Security

  • Field-level Authorization - Some users can't see certain fields
  • Resolver-level Permissions - Check permissions in each resolver
  • Rate Limiting - Limit queries per user
  • Query Depth Limiting - Prevent DoS attacks
  • Authentication - JWT, OAuth integration
  • Input Validation - Sanitize query inputs
  • Directive-based Rules - @authorize(role: "Admin")

Advanced Features

  • Federation - Combine multiple GraphQL services (Apollo Federation)
  • Schema Stitching - Merge multiple GraphQL schemas
  • Apollo Gateway - Unified GraphQL API across services
  • Custom Directives - Create application-specific rules
  • Middleware - Transform data, validate, log
  • Error Handling - Custom error formats
  • Field Middleware - Intercept field resolution

Subscriptions & Real-time

  • WebSocket Transport - Real-time connection protocol
  • Context Broadcasting - Send updates to multiple subscribers
  • Event Streaming - Handle high-volume updates
  • Backpressure Handling - Manage slow subscribers
  • Connection Management - Handle client reconnects

Tools & Clients

  • Apollo Client - Popular JavaScript GraphQL client with caching
  • urql - Lightweight GraphQL client
  • React Query - Data fetching library (REST-focused but works with GraphQL)
  • GraphQL Playground - IDE for testing queries
  • GraphQL Voyager - Visual schema explorer
  • Postman - Supports GraphQL testing
  • GraphQL Code Generator - Generate types from schema

Testing

  • Unit Testing Resolvers - Test individual field resolvers
  • Integration Testing - Test full queries
  • Snapshot Testing - Compare query responses
  • Performance Testing - Measure query execution time
  • Security Testing - Test authorization rules
  • Schema Linting - Validate schema best practices

Server-Side Patterns

  • Relay Cursor Pagination - Standard pagination pattern
  • Global Object ID - Unique IDs across types
  • Connection Type - Standard way to return paginated results
  • Mutation Input Type - Bundle mutation parameters
  • Error Interface - Structured error responses
  • Search & Filtering - Input types for complex queries

Monitoring & Observability

  • Query Execution Time - Track slow queries
  • N+1 Detection - Identify missing optimizations
  • Usage Metrics - Popular fields, operations
  • Error Tracking - Log GraphQL errors
  • APM Integration - Application performance monitoring
  • Distributed Tracing - Track across services

Database Integration

  • EF Core with GraphQL - ORM + GraphQL
  • Lazy Loading Issues - N+1 problems with EF
  • Query Optimization - SelectAsync vs loading
  • Transactions - Ensuring data consistency
  • Caching Strategies - Redis, in-memory caching

Comparison Ecosystems

  • Apollo (JavaScript) - Full-featured GraphQL ecosystem
  • Relay (JavaScript) - Facebook's GraphQL client
  • Hot Chocolate (.NET) - Feature-rich GraphQL server
  • Hasura (Any DB) - Instant GraphQL API from database
  • FaunaDB - Database with native GraphQL
  • Shopify GraphQL API - Real-world example

Advanced Architectures

  • BFF (Backend-for-Frontend) - Separate GraphQL for each client
  • API Gateway - Single entry point for multiple services
  • Microservices GraphQL - GraphQL across services
  • Event-Driven Architecture - Subscriptions trigger events
  • CQRS (Command Query Responsibility Separation) - Separate read/write models