ASP.NET Core GraphQL
Implement GraphQL APIs in ASP.NET Core applications.
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:
- User's name
- User's email
- User's avatar
- 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/graphqlto 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:
- Parse: Check query syntax is valid
- Validate: Check query matches schema
- 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
- Call
- 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/statsGET /api/products/topGET /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