DEV Community

Cover image for GraphQL: Letting Clients Ask for Exactly the Data They Need
Rhuturaj Takle
Rhuturaj Takle

Posted on

GraphQL: Letting Clients Ask for Exactly the Data They Need

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

  1. Introduction
  2. The Problem GraphQL Solves
  3. Schema and Type System
  4. Queries
  5. Mutations
  6. Subscriptions
  7. Resolvers
  8. The N+1 Problem and DataLoader
  9. Building a GraphQL API in .NET (Hot Chocolate)
  10. Error Handling
  11. Pagination: The Connection Pattern
  12. GraphQL vs. REST
  13. Security and Performance Considerations
  14. Quick Reference Table
  15. 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 }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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": "..."
}
Enter fullscreen mode Exit fullscreen mode

With GraphQL, the client asks for only what it needs:

query {
  user(id: 5) {
    name
    avatarUrl
  }
}
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

GraphQL collapses this into one request:

query {
  user(id: 5) {
    name
    posts {
      title
      commentCount
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode
{ "postId": "42" }
Enter fullscreen mode Exit fullscreen mode

Aliases

Request the same field twice with different arguments in a single query:

query {
  featured: post(id: "1") { title }
  runnerUp: post(id: "2") { title }
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

Directives

Conditionally include or skip fields based on variables:

query GetPost($postId: ID!, $includeComments: Boolean!) {
  post(id: $postId) {
    title
    comments @include(if: $includeComments) {
      text
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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!]
}
Enter fullscreen mode Exit fullscreen mode
mutation CreateNewPost($input: CreatePostInput!) {
  createPost(input: $input) {
    post {
      id
      title
    }
    errors {
      message
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode
subscription {
  commentAdded(postId: "42") {
    text
    author { name }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Resolvers form a tree, executed field by field

For this query:

query {
  user(id: 5) {
    name
    posts { title }
  }
}
Enter fullscreen mode Exit fullscreen mode

The runtime calls, roughly:

  1. Query.user(id: 5) → returns the User object.
  2. For each requested field on that User: name (simple property) and posts (its own resolver, given the parent User as context).
  3. For each Post returned, 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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())
    { }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "posts": {
      "edges": [
        { "cursor": "Y3Vyc29yOjEx", "node": { "id": "11", "title": "..." } }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "Y3Vyc29yOjEx"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)