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
Related Articles
API Versioning in ASP.NET Core
Learn about different strategies for API versioning in ASP.NET Core.
Read More aspnetAPI Gateway Patterns
Explore common patterns and practices for building API gateways.
Read More aspnetASP.NET Core Background Jobs
Implement background job processing in ASP.NET Core applications.
Read More