GraphQL: an introduction

GraphQL is a query language for APIs and a runtime for executing those queries using a type system defined by the application. It was created at Facebook (2012) and is now an open de-facto standard maintained by the GraphQL Foundation. Its main goal is to give clients the power to request exactly the data they need, in a single request, reducing under- or over-fetching and simplifying API evolution.

Why GraphQL

  • Precise data fetching: the client specifies the fields; no redundant payloads.
  • Single endpoint: usually /graphql, with query/mutation/subscription.
  • Strong types: clear, verifiable contract via introspection and linting/codegen tools.
  • Versionless evolution: add fields and types; removal is handled with deprecations.
  • Great tooling: interactive editors (GraphiQL/Playground), schema registry, codegen, advanced client-side caches.

The core: Schema SDL

The GraphQL schema describes types, fields, and operations through SDL (Schema Definition Language). The three basic operations are: Query (read), Mutation (write), and Subscription (event stream).

# schema.graphql
schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

type Query {
  me: User
  post(id: ID!): Post
  posts(first: Int = 10, after: Cursor): PostConnection!
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  likePost(id: ID!): Post!
}

type Subscription {
  postCreated: Post!
}

"A user of the system"
type User {
  id: ID!
  username: String!
  name: String
  avatarUrl: String
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  likes: Int!
  createdAt: String!
}

# Cursor-style pagination
scalar Cursor

type PageInfo {
  hasNextPage: Boolean!
  endCursor: Cursor
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: Cursor!
}

input CreatePostInput {
  title: String!
  body: String!
}

type CreatePostPayload {
  post: Post!
}

Query: ask exactly what you need

query GetFeed($pageSize: Int!, $cursor: Cursor) {
  posts(first: $pageSize, after: $cursor) {
    totalCount
    pageInfo { hasNextPage endCursor }
    edges {
      cursor
      node { id title author { username } }
    }
  }
}

Variables sent in the request:

{
  "pageSize": 10,
  "cursor": null
}

Typical server response:

{
  "data": {
    "posts": {
      "totalCount": 123,
      "pageInfo": { "hasNextPage": true, "endCursor": "YXJyYXljb25uZWN0aW9uOjEw" },
      "edges": [
        { "cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": { "id": "p1", "title": "Hello", "author": { "username": "marta" } } }
      ]
    }
  }
}

Mutation: change state

mutation CreateAndLike($input: CreatePostInput!, $id: ID!) {
  createPost(input: $input) {
    post { id title }
  }
  likePost(id: $id) {
    id
    likes
  }
}
{
  "input": { "title": "GraphQL 101", "body": "Introduction..." },
  "id": "p1"
}

Subscription: real-time events

subscription OnPostCreated {
  postCreated { id title author { username } createdAt }
}

Subscriptions use WebSocket (or equivalent transports) for real-time push to the client.

Resolvers and execution flow

The server associates one or more resolvers to each field—functions that fetch the required data. Resolvers receive arguments, the parent (result from the previous level), the context (for authentication, loaders, database), and info (AST, schema, path).

// Example with Apollo Server (Node.js)
import { ApolloServer } from "@apollo/server";
import { readFileSync } from "node:fs";
import DataLoader from "dataloader";
import db from "./db.js";

const typeDefs = readFileSync("./schema.graphql", "utf8");

const resolvers = {
  Query: {
    me: (_p, _a, ctx) => ctx.user,
    post: (_p, { id }, _ctx) => db.posts.findById(id),
    posts: async (_p, { first, after }, _ctx) => db.posts.paginated({ first, after })
  },
  Post: {
    author: (post, _a, ctx) => ctx.userLoader.load(post.authorId)
  },
  Mutation: {
    createPost: async (_p, { input }, ctx) => {
      ctx.authz.assertLoggedIn();
      const post = await db.posts.create({ ...input, authorId: ctx.user.id });
      ctx.pubsub.publish("POST_CREATED", { postCreated: post });
      return { post };
    },
    likePost: (_p, { id }) => db.posts.like(id)
  },
  Subscription: {
    postCreated: {
      subscribe: (_p, _a, { pubsub }) => pubsub.asyncIterator("POST_CREATED")
    }
  }
};

const server = new ApolloServer({ typeDefs, resolvers });
export default server;

Fragments, aliases, directives

fragment PostPreview on Post { id title author { username } }

query Feed($withLikes: Boolean!) {
  posts(first: 5) {
    edges {
      node {
        ...PostPreview
        likes @include(if: $withLikes)
      }
    }
  }
}

# Alias to rename fields in the output
query OnePost { post(id: "p1") { title authorName: author { username } } }

Pagination: offset vs cursor

  • Offset/limit: simple but fragile with moving data (skips/duplicates).
  • Cursor-based: robust; use a Connection with edges, node, cursor, and pageInfo (see schema above).

Quick comparison with REST

Aspect REST GraphQL
Structure Many resources/endpoints One endpoint, typed schema
Payload Fixed per endpoint Dynamic field selection
Versioning v1, v2, ... Deprecation and field addition
Caching HTTP/URL-based Field/operation level; depends on client
Batching Multiple round-trips One query, DataLoader to solve N+1

Errors and response formats

A GraphQL response always has data and optionally errors. Errors can be partial: some fields fail, others do not. The extensions field is useful for proprietary codes, tracing, and retry hints.

{
  "data": { "post": null },
  "errors": [
    {
      "message": "Post not found",
      "path": ["post"],
      "extensions": { "code": "NOT_FOUND", "requestId": "abc-123" }
    }
  ]
}

Security and governance

  • Authentication: typically via header (e.g., Bearer), resolved in the context.
  • Authorization: per field/operation; centralize policy in context or resolvers.
  • Query cost analysis: limit depth, breadth, and complexity to prevent abuse.
  • Persisted Query: whitelisting hashes of known queries to reduce risk and payload size.
  • Introspection: useful in development; consider disabling or filtering in production.
  • Rate limiting: per user/key/API gateway; integrate with per-operation metrics.
  • File upload: support the multipart spec (scalar Upload) when needed.

Performance: N+1, batching, and caching

The N+1 problem emerges when each item triggers a separate query (e.g., 100 posts ⇒ 100 author lookups). The canonical solution is DataLoader (or equivalents) for batching and per-request caching.

import DataLoader from "dataloader";
import db from "./db.js";

function makeUserLoader() {
  return new DataLoader(async (ids) => {
    const rows = await db.users.findByIds(ids);
    const map = new Map(rows.map(u => [u.id, u]));
    return ids.map(id => map.get(id) || null);
  });
}

// In the server
const context = async ({ req }) => ({
  user: await authenticate(req),
  userLoader: makeUserLoader()
});

Advanced directives: @defer and @stream

GraphQL supports incremental delivery to improve TTFB on heavy payloads: use @defer for slow fields and @stream for lists.

query ProductPage($id: ID!) {
  product(id: $id) {
    id
    name
    # send reviews later without blocking the rest
    reviews @defer { author { username } body }
    similar @stream(initialCount: 3) { id name }
  }
}

Schema design: best practices

  • Consistent naming: fields in camelCase, types in PascalCase.
  • Input vs output: use input for mutations; avoid scattered scalar params.
  • Nullability: think of ! as contracts; avoid blanket non-null everywhere.
  • Enum for closed sets; Union/Interface for polymorphism.
  • Deprecation: use @deprecated(reason: "...") on fields and enums.
type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  teaser: String @deprecated(reason: "Use 'excerpt' with configurable length")
  excerpt(length: Int = 120): String!
}

Code-first vs Schema-first

  • Schema-first: write SDL and then resolvers; excellent for collaboration and governance.
  • Code-first: define types in code (e.g., decorators/TS) and generate the schema; great DX and 1:1 types with the model.

End-to-end example (Node.js)

# Basic installation
npm i @apollo/server graphql
// index.js
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

const typeDefs = `#graphql
  type Query { hello: String! }
`;

const resolvers = {
  Query: { hello: () => "Hello GraphQL!" }
};

const server = new ApolloServer({ typeDefs, resolvers });

const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`Server ready at ${url}`);
# Test with curl
curl -X POST http://localhost:4000/ \
  -H "content-type: application/json" \
  -d '{ "query": "query { hello }" }'
{
  "data": { "hello": "Hello GraphQL!" }
}

Client: caching and typing

  • Apollo Client: normalized cache, reactive variables, persisted queries, link chain.
  • Relay: focus on connections, data colocation, great for complex apps.
  • urql and graphql-request: lightweight alternatives.
  • Codegen: generate TS types and hooks from schema/operations.
// Apollo Client example
import { ApolloClient, InMemoryCache, gql } from "@apollo/client";

const client = new ApolloClient({
  uri: "/graphql",
  cache: new InMemoryCache()
});

const GET_ME = gql`query { me { id username } }`;
client.query({ query: GET_ME }).then(r => console.log(r.data));

Federation and architectures

  • Monolithic: simple to start.
  • Federated: multiple “subgraphs” compose the supergraph (e.g., Apollo Federation); each team owns its data subdomain.
  • Schema stitching: merges existing schemas (REST/GraphQL) into a composite schema.
  • BFF (Backend for Frontend): GraphQL server for app/mobile with orchestration over internal REST/gRPC services.

Uploads, files, and custom scalars

Beyond the standard scalars (Int, Float, String, Boolean, ID) you can define custom scalars (e.g., DateTime, URL, Email) and support uploads via Upload with the multipart protocol.

scalar DateTime
scalar URL

type Media {
  id: ID!
  url: URL!
  uploadedAt: DateTime!
}

Testing, observability, and migrations

  • Unit tests for resolvers and utilities (mock context/DB).
  • Contract testing: snapshots of client operations and breaking-change detection.
  • Tracing and metrics: per-field timings, error rate, response size, average depth.
  • Schema migrations: deprecation process, client communication, planned removals.

Common anti-patterns

  • Exposing a schema that mirrors DB tables 1:1 without modeling use cases.
  • Non-Null fields everywhere, preventing useful partial responses.
  • Ignoring the N+1 problem in relationships.
  • Using huge queries instead of splitting them and leveraging @defer/@stream.
  • Relying solely on the gateway for security, neglecting per-field authorization.

Production-readiness checklist

  1. Depth/complexity limits and max query time.
  2. Persisted queries for critical operations and caching on gateway/CDN.
  3. Observability: tracing, enriched logs, correlation via requestId.
  4. Consistent error policies & mapping to application codes.
  5. Documented and automated deprecation process (schema registry).
  6. Safe backups/rollouts for schema and DB migrations.

Recommended resources

Conclusion

GraphQL offers a declarative, strongly typed, and composable model to expose data and orchestrate services. Start with a use-case–centric schema, add efficient resolvers with DataLoader, implement per-field security and observability, and scale toward federation when your organization requires it. With these foundations, you can build flexible, evolvable, and fast APIs that delight both clients and backend teams.

Back to top