GraphQL: Letting Clients Ask for Exactly the Data They Need
A practical guide to GraphQL — the API query language that replaces multiple fixed REST endpoints with a single, flexible endpoint where clients specify exactly what data they want, covering schema design, queries, mutations, subscriptions, resolvers, the N+1 problem, and how it compares to REST.
Table of Contents
- Introduction
- The Problem GraphQL Solves
- Schema and Type System
- Queries
- Mutations
- Subscriptions
- Resolvers
- The N+1 Problem and DataLoader
- Building a GraphQL API in .NET (Hot Chocolate)
- Error Handling
- Pagination: The Connection Pattern
- GraphQL vs. REST
- Security and Performance Considerations
- Quick Reference Table
- Conclusion
Introduction
GraphQL is a query language and runtime for APIs, originally developed at Facebook (2012, open-sourced 2015). Instead of a server dictating fixed response shapes across many endpoints (/users/5, /users/5/posts, /posts/12/comments), GraphQL exposes a single endpoint with a strongly-typed schema, and the client specifies exactly which fields it wants in a single request.
query {
user(id: 5) {
name
posts {
title
comments {
text
author { name }
}
}
}
}
One request, one round trip, and a response shaped exactly like the query — no more, no less.
1. The Problem GraphQL Solves
Over-fetching
A REST endpoint like GET /users/5 might return every field on the user, even if the client only needs the name and avatarUrl for a header component:
{
"id": 5,
"name": "Ada Lovelace",
"email": "ada@example.com",
"avatarUrl": "...",
"billingAddress": { /* ... */ },
"preferences": { /* ... */ },
"createdAt": "..."
}
With GraphQL, the client asks for only what it needs:
query {
user(id: 5) {
name
avatarUrl
}
}
Under-fetching and the N+1 round-trip problem
Conversely, rendering a page that needs a user, their posts, and each post's comment count often means multiple REST round trips:
GET /users/5
GET /users/5/posts
GET /posts/12/comments/count
GET /posts/13/comments/count
...
GraphQL collapses this into one request:
query {
user(id: 5) {
name
posts {
title
commentCount
}
}
}
This is especially valuable for mobile clients on slow or metered connections, where every round trip has real latency and battery cost.
One schema, many client shapes
Different clients (web, iOS, Android, a partner integration) often need different slices of the same underlying data. With REST, this either means one bloated general-purpose endpoint or a proliferation of client-specific endpoints. With GraphQL, every client queries the same schema and asks for its own shape — no server-side changes needed to support a new UI variant.
2. Schema and Type System
Every GraphQL API is defined by a schema — a strongly-typed contract describing every type, field, and operation the API supports, written in Schema Definition Language (SDL).
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
commentCount: Int!
}
type Comment {
id: ID!
text: String!
author: User!
}
type Query {
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
}
Scalar types
GraphQL ships with built-in scalars: Int, Float, String, Boolean, ID (a string treated as a unique identifier). Custom scalars (like DateTime or Decimal) are common and supported by most implementations.
Nullability
name: String! # non-null — this field will always have a value
email: String # nullable — this field can be null
posts: [Post!]! # non-null list of non-null posts (list itself can't be null, items can't be null)
posts: [Post!] # nullable list of non-null posts (the list itself could be null)
The ! suffix is one of GraphQL's most valuable design tools — it makes null-safety part of the API contract itself, not something clients discover at runtime, similar in spirit to nullable reference types in C#.
Enums, interfaces, and unions
enum OrderStatus {
PENDING
SHIPPED
DELIVERED
CANCELLED
}
interface Node {
id: ID!
}
type Product implements Node {
id: ID!
name: String!
}
union SearchResult = User | Post | Comment
Interfaces and unions let a single field return one of several possible types, with the client using fragments to request type-specific fields (see Section 3).
3. Queries
A query is the read operation in GraphQL — the equivalent of a REST GET.
Basic query with arguments
query {
post(id: "42") {
title
author {
name
}
}
}
Named queries and variables
Production clients almost always use named queries with variables rather than inline literal values — this enables caching, logging, and reuse:
query GetPost($postId: ID!) {
post(id: $postId) {
title
body
author { name }
}
}
{ "postId": "42" }
Aliases
Request the same field twice with different arguments in a single query:
query {
featured: post(id: "1") { title }
runnerUp: post(id: "2") { title }
}
Fragments
Reuse a set of fields across multiple parts of a query:
fragment PostSummary on Post {
id
title
author { name }
}
query {
latestPost: post(id: "42") { ...PostSummary }
popularPost: post(id: "7") { ...PostSummary }
}
Directives
Conditionally include or skip fields based on variables:
query GetPost($postId: ID!, $includeComments: Boolean!) {
post(id: $postId) {
title
comments @include(if: $includeComments) {
text
}
}
}
4. Mutations
Mutations are the write operations — the equivalent of REST's POST/PUT/PATCH/DELETE. By convention, mutation fields are named as verbs, and each returns the data the client needs to update its local state after the change.
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
deletePost(id: ID!): DeletePostPayload!
}
input CreatePostInput {
title: String!
body: String!
authorId: ID!
}
type CreatePostPayload {
post: Post
errors: [Error!]
}
mutation CreateNewPost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
}
errors {
message
}
}
}
Why the Input/Payload wrapper pattern
Wrapping mutation arguments in a single input type (rather than several loose scalar arguments) and wrapping the response in a Payload type (rather than returning the bare object) makes it easy to add new fields later — including error lists — without a breaking schema change. This has become a de facto community convention (popularized by the Relay specification) even outside strict Relay-compliant APIs.
Mutations execute serially
Unlike query fields, which the server can resolve in parallel, top-level mutation fields in a single request are guaranteed to execute in order, one at a time — important when a client sends multiple mutations in one request and later ones depend on earlier ones succeeding.
5. Subscriptions
Subscriptions are GraphQL's real-time operation type, typically delivered over WebSockets — the client "subscribes" to an event and receives a new payload every time it fires.
type Subscription {
postAdded: Post!
commentAdded(postId: ID!): Comment!
}
subscription {
commentAdded(postId: "42") {
text
author { name }
}
}
Typical use cases: live chat messages, notifications, live dashboards, collaborative editing indicators. Subscriptions require a stateful, persistent connection (unlike the stateless request/response model of queries and mutations), which has real infrastructure implications — they don't scale the same way behind simple load balancers and often need a dedicated pub/sub backend (Redis, Azure SignalR, etc.) to fan out events across multiple server instances.
6. Resolvers
A resolver is the function that produces the value for a single field. Every field in the schema has one (even if the runtime provides a sensible default for simple property access).
public class Query
{
public async Task<User?> GetUser(int id, [Service] IUserRepository repo) =>
await repo.GetByIdAsync(id);
}
public class UserResolvers
{
public async Task<List<Post>> GetPosts([Parent] User user, [Service] IPostRepository repo) =>
await repo.GetByAuthorIdAsync(user.Id);
}
Resolvers form a tree, executed field by field
For this query:
query {
user(id: 5) {
name
posts { title }
}
}
The runtime calls, roughly:
-
Query.user(id: 5)→ returns theUserobject. - For each requested field on that
User:name(simple property) andposts(its own resolver, given the parentUseras context). - For each
Postreturned,title(simple property).
This field-by-field resolution is exactly what makes the N+1 problem so easy to accidentally create (see next section) — each resolver is independent by default and has no built-in awareness of sibling resolvers running for other items in a list.
7. The N+1 Problem and DataLoader
The problem
query {
posts {
title
author { name } # naive resolver: 1 DB query per post
}
}
If posts returns 50 posts, and the author resolver runs a separate SELECT * FROM users WHERE id = ? for each one, that's 1 query for the posts plus 50 queries for authors — the classic N+1 problem, and it gets worse with every additional level of nesting.
The fix: batching with DataLoader
A DataLoader batches individual load requests that occur within the same execution tick into a single call, and caches results for the duration of a single request:
public class UserByIdDataLoader : BatchDataLoader<int, User>
{
private readonly IUserRepository _repo;
public UserByIdDataLoader(IUserRepository repo, IBatchScheduler scheduler) : base(scheduler) => _repo = repo;
protected override async Task<IReadOnlyDictionary<int, User>> LoadBatchAsync(
IReadOnlyList<int> keys, CancellationToken ct)
{
var users = await _repo.GetByIdsAsync(keys, ct); // one query: WHERE id IN (...)
return users.ToDictionary(u => u.Id);
}
}
public class PostResolvers
{
public async Task<User> GetAuthor([Parent] Post post, UserByIdDataLoader loader) =>
await loader.LoadAsync(post.AuthorId);
}
Instead of 50 individual queries, the DataLoader collects all 50 requested authorIds issued during that tick and runs a single WHERE id IN (1, 2, 3, ..., 50) query — turning N+1 queries into 2. This pattern (batching + per-request caching) is universal across GraphQL implementations, not specific to any one language or library.
8. Building a GraphQL API in .NET (Hot Chocolate)
Hot Chocolate is the most widely used GraphQL server library for ASP.NET Core.
Setup
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddSubscriptionType<Subscription>()
.AddType<PostType>()
.AddFiltering()
.AddSorting()
.AddProjections();
var app = builder.Build();
app.MapGraphQL(); // exposes /graphql, including the Banana Cake Pop IDE in development
app.Run();
Code-first schema definition
public class Query
{
[UseFiltering, UseSorting, UsePaging]
public IQueryable<Post> GetPosts(AppDbContext db) => db.Posts;
}
public class Mutation
{
public async Task<Post> CreatePost(CreatePostInput input, AppDbContext db)
{
var post = new Post { Title = input.Title, Body = input.Body };
db.Posts.Add(post);
await db.SaveChangesAsync();
return post;
}
}
The [UseFiltering, UseSorting, UsePaging] attributes automatically extend the schema with filter arguments, sort arguments, and cursor pagination for that field — translating GraphQL query arguments directly into an efficient, provider-specific IQueryable expression (e.g., translated to SQL by Entity Framework Core) rather than fetching everything and filtering in memory.
Projections
AddProjections() ensures that if a client only asks for title and id, Hot Chocolate only selects those columns from the underlying IQueryable, rather than pulling entire entities — extending GraphQL's "ask for only what you need" principle all the way down to the SQL layer.
9. Error Handling
Unlike REST, GraphQL always returns 200 OK at the HTTP level, even when part of the query fails — errors are reported inside the response body alongside any data that did resolve successfully.
{
"data": {
"user": {
"name": "Ada Lovelace",
"posts": null
}
},
"errors": [
{
"message": "Failed to load posts for user 5.",
"path": ["user", "posts"],
"extensions": {
"code": "DATABASE_TIMEOUT"
}
}
]
}
This partial-success model is deliberate: a client can still render the parts of the page that succeeded (the user's name) while showing an error or fallback state only for the part that failed (their posts) — something a single all-or-nothing REST response can't express as naturally.
Custom error codes via extensions
public class NotFoundException : GraphQLException
{
public NotFoundException(string message)
: base(ErrorBuilder.New()
.SetMessage(message)
.SetCode("NOT_FOUND")
.Build())
{ }
}
Clients branch on extensions.code rather than parsing free-text message strings, similar in spirit to a type/title field in a REST ProblemDetails response.
10. Pagination: The Connection Pattern
GraphQL's community-standard approach to pagination is the Relay Connection specification — a cursor-based pattern that's become the de facto standard across most GraphQL APIs, not just ones built with Relay itself.
query {
posts(first: 10, after: "Y3Vyc29yOjEw") {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
{
"data": {
"posts": {
"edges": [
{ "cursor": "Y3Vyc29yOjEx", "node": { "id": "11", "title": "..." } }
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "Y3Vyc29yOjEx"
}
}
}
}
Each item is wrapped in an edge (giving it its own cursor), and pageInfo tells the client whether more pages exist — the same underlying idea as cursor-based REST pagination, just standardized into a predictable shape that GraphQL client libraries (like Relay and Apollo Client) can cache and merge automatically.
11. GraphQL vs. REST
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Many (one per resource/action) | One (typically /graphql) |
| Response shape | Fixed per endpoint | Client-defined per request |
| Over/under-fetching | Common problem | Solved by design |
| Versioning | Explicit (/v1, /v2) |
Usually avoided — evolve the schema additively, deprecate fields instead |
| Caching | Native HTTP caching (ETag, Cache-Control, CDNs) works out of the box |
Requires client-side caching (Apollo/Relay normalized cache) or custom server work — HTTP-level caching is harder since most requests are POST to one URL |
| File uploads | Native (multipart/form-data) |
Requires an additional spec/library (e.g., graphql-multipart-request-spec) |
| Learning curve | Lower — HTTP semantics most developers already know | Higher — schema design, resolvers, N+1 awareness |
| Tooling | Swagger/OpenAPI, Postman | GraphiQL/Banana Cake Pop, Apollo Studio, strongly-typed codegen from the schema |
| Best fit | Public APIs, simple CRUD services, when HTTP caching matters a lot | Complex client data needs, multiple client types (web/mobile/partner), rapidly evolving front-ends |
It's not strictly either/or
Many organizations run both: a public REST API for simple integrations and webhooks, and an internal GraphQL layer (sometimes literally a "backend for frontend" that itself calls several REST/gRPC services) to serve rich, client-driven UIs efficiently. GraphQL is often layered on top of existing REST or gRPC services rather than replacing them outright.
12. Security and Performance Considerations
Because GraphQL lets clients construct arbitrarily nested queries, a few protections are essentially mandatory in production:
-
Query depth limiting — reject queries nested beyond a reasonable depth (e.g.,
user { posts { comments { author { posts { ... } } } } }recursing indefinitely). - Query complexity/cost analysis — assign a "cost" to each field (based on expected result size) and reject queries whose total estimated cost exceeds a threshold, rather than relying on depth alone.
- Persisted queries — in production, many teams only allow a pre-registered allowlist of known query documents (identified by hash) rather than accepting arbitrary ad-hoc queries from the public internet, which closes off a large class of abuse while keeping the flexibility during development.
- Rate limiting per field or per query cost, not just per request — a single expensive query can do more damage than many cheap ones.
- Disable introspection in production (or restrict it) if the schema itself shouldn't be publicly discoverable — introspection is invaluable during development but also hands an attacker a full map of your data model.
- DataLoader everywhere non-trivial — as covered in Section 7, this is as much a performance necessity as a correctness nicety once your schema has any meaningful nesting.
Quick Reference Table
| Concept | Purpose |
|---|---|
| Schema (SDL) | Strongly-typed contract for all types/operations |
| Query | Read operation, parallelizable, equivalent to REST GET
|
| Mutation | Write operation, executes serially, equivalent to REST POST/PUT/DELETE
|
| Subscription | Real-time, event-driven operation over a persistent connection |
| Resolver | Function producing the value for one field |
| DataLoader | Batches and caches per-request loads to avoid N+1 queries |
| Fragment | Reusable set of fields across a query |
Directive (@include/@skip) |
Conditionally include fields based on variables |
| Connection pattern | Standardized cursor-based pagination shape |
| Persisted queries | Allowlist-only queries for production hardening |
| Query cost analysis | Prevents abusive, arbitrarily expensive queries |
Conclusion
GraphQL's core promise — clients ask for exactly the data they need, in exactly the shape they need it, in a single round trip — solves real, common pain points in REST APIs serving multiple client types or deeply nested data. The tradeoff is real complexity elsewhere: HTTP caching gets harder, N+1 queries are one careless resolver away, and query cost/depth limiting become mandatory rather than optional once you're exposed to the public internet.
It isn't a wholesale replacement for REST so much as a different tool for a different shape of problem: REST still wins for simple CRUD services, public APIs that lean on HTTP caching, and cases where "one predictable endpoint per resource" is exactly what you want. GraphQL earns its complexity when clients are varied, data is deeply relational, and minimizing round trips matters more than keeping the server simple.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the N+1 bug that taught you to respect DataLoader.
Top comments (0)