Skip to content

GraphQL Hoja de referencia

Query language and runtime for APIs.

01

Getting Started

Basic Query

GraphQL lets clients specify exactly what data they need. A query fetches data, and the response mirrors the query shape. This avoids over-fetching and under-fetching common in REST APIs.

graphql
# Query - read data
query {
  user(id: 1) {
    id
    name
    email
    posts {
      title
      content
    }
  }
}

# Response
{
  "data": {
    "user": {
      "id": 1,
      "name": "Alice",
      "email": "[email protected]",
      "posts": [
        { "title": "Hello", "content": "World" }
      ]
    }
  }
}

Query with Variables

Variables parameterize queries so the same operation can be reused with different inputs. They are declared in the operation header with $name: Type. The query string stays static, only the variables JSON changes per call.

graphql
# Declare variables with operation name
query GetUser($id: ID!, $withPosts: Boolean!) {
  user(id: $id) {
    name
    posts @include(if: $withPosts) {
      title
    }
  }
}

# Variables sent alongside the query
{
  "id": "1",
  "withPosts": true
}

Mutation Basics

Mutations modify data and return a selection set just like queries. By convention they should be followed by a read of the changed data. GraphQL executes mutations serially (one after another), while query fields run in parallel.

graphql
# Mutation - write data, then return the result
mutation {
  createUser(input: { name: "Bob", email: "[email protected]" }) {
    id
    name
  }
}

# Response
{
  "data": {
    "createUser": {
      "id": "42",
      "name": "Bob"
    }
  }
}

Fields and Selection Sets

A selection set is the list of fields inside curly braces. Scalars and enums return leaf values; object types require a nested selection set. Forgetting a sub-selection on an object type is a common validation error.

graphql
# You must select fields - GraphQL never returns whole objects
query {
  user(id: 1) {
    # this block is a "selection set"
    name
    email
  }
}

# Selecting a scalar returns its value directly
# Selecting an object REQUIRES a sub-selection set
query {
  user(id: 1) {
    name          # scalar - OK
    posts {       # object - needs { ... }
      title
    }
  }
}

GraphQL vs REST

REST exposes many endpoints with fixed shapes; GraphQL exposes one endpoint where the client defines the shape. This cuts round trips and prevents over/under-fetching. The trade-off is a more complex server and caching via POST.

graphql
# REST: multiple round trips, fixed shapes
GET /users/1            -> { id, name, email, address, ... }
GET /users/1/posts      -> [ { id, title, body, author, ... } ]

# GraphQL: one request, client-defined shape
query {
  user(id: 1) {
    name
    posts { title }
  }
}

# One endpoint, one POST request
POST /graphql
{ "query": "...", "variables": { ... } }

Introspection

Introspection lets clients discover the schema at runtime. Tools like GraphiQL, Apollo Studio, and code generators rely on it. The root fields __schema and __type are the entry points. Production servers may disable introspection to hide internals.

graphql
# Ask the schema what types and fields it has
{
  __schema {
    types {
      name
      kind
    }
  }
}

# Inspect a specific type's fields
{
  __type(name: "User") {
    name
    fields {
      name
      type { name kind ofType { name kind } }
    }
  }
}
02

Schema & Type System

Schema Declaration

The schema block wires root operation types. If your root types are named Query, Mutation, and Subscription, Apollo/GraphQL.js infers the schema automatically and you can omit this declaration entirely.

graphql
# SDL - Schema Definition Language
schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

# In most servers the schema is inferred from the
# Query/Mutation/Subscription types, so this block is optional.

Root Types

Query, Mutation, and Subscription are the entry points of a schema. Query is the only required root type. Each root field becomes an operation clients can run. Naming them as verbs (createUser) for mutations and nouns for queries is conventional.

graphql
type Query {
  user(id: ID!): User
  users(limit: Int = 10): [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

type Subscription {
  postAdded: Post!
}

Type Definitions

Types are defined with the type keyword followed by fields. Each field has a name and a return type. The ! suffix means non-null; [Type!] means a list of non-null items; [Type!]! means a non-null list of non-null items.

graphql
# Object type - the most common building block
type User {
  id: ID!
  name: String!
  age: Int
  email: String
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
}

Adding Descriptions

Triple-quoted strings (or single-line strings before a definition) become part of the schema as descriptions. They show up in GraphiQL docs and introspection. Plain # comments are dev-only and are stripped out, so use triple-quoted strings for user-facing docs.

graphql
"""A registered user of the platform."""
type User {
  """The unique, immutable identifier."""
  id: ID!

  "Display name shown publicly."
  name: String!

  # plain # comments are NOT exposed to clients
  email: String
}

Schema with Extensions

extend type adds fields to a type declared elsewhere. This is how you split a large schema across files or add module-specific fields. A type may be declared once and extended many times, but each field can only be defined once.

graphql
# Extend an existing type without redefining it
type User {
  id: ID!
  name: String!
}

extend type User {
  age: Int
  posts: [Post!]!
}

# Useful for splitting a schema across files,
# or for adding fields only in certain modules.

Schema-First vs Code-First

Schema-first writes SDL by hand and attaches resolvers (Apollo's classic approach). Code-first builds the schema programmatically with TypeScript decorators/builders (Nexus, TypeGraphQL, Pothos), gaining type safety between schema and resolvers. Both produce the same runtime schema.

graphql
# 1) Schema-first: write SDL, bind resolvers
const typeDefs = `
  type Query { hello: String }
`;
const resolvers = { Query: { hello: () => "world" } };
const server = new ApolloServer({ typeDefs, resolvers });

# 2) Code-first: build with builders (Nexus, TypeGraphQL)
export const Query = queryType({
  definition(t) {
    t.string("hello", { resolve: () => "world" });
  },
});
03

Scalar Types

Built-in Scalars

GraphQL ships five built-in scalars: Int, Float, String, Boolean, and ID. ID is serialized as a string but semantically means an identifier - clients should treat it as opaque. There is no built-in Date or DateTime; you define those yourself.

graphql
type Example {
  id: ID!          # unique identifier, serialized as string
  name: String!    # UTF-8 text
  age: Int         # 32-bit integer
  score: Float     # double-precision float
  active: Boolean! # true / false
}

Custom Scalars

Custom scalars let you represent domain primitives. After declaring them, you must provide a resolver that serializes (to JSON), parses (from variables), and optionally literal-parses (from inline AST). Otherwise the value is passed through as-is.

graphql
# Declare a custom scalar in SDL
scalar DateTime
scalar URL
scalar JSON

type Event {
  id: ID!
  at: DateTime!
  link: URL
  metadata: JSON
}

Scalar Resolvers

A scalar resolver has three optional methods. serialize converts an internal value to the JSON output. parseValue converts a variable input to internal form. parseLiteral converts an inline literal in the query AST to internal form. Define all three for a fully typed scalar.

graphql
const resolvers = {
  DateTime: {
    // value -> JSON sent to client
    serialize: (value: Date) => value.toISOString(),
    // variable value -> internal value
    parseValue: (value: string) => new Date(value),
    // inline AST literal -> internal value
    parseLiteral: (ast) => {
      if (ast.kind === "StringValue") {
        return new Date(ast.value);
      }
      return null;
    },
  },
};

Date/DateTime Scalar

The graphql-scalars package ships battle-tested scalars: DateTime, Date, Time, EmailAddress, URL, UUID, BigInt, JSON, and more. Prefer it over hand-rolling, especially for ISO 8601 date parsing where edge cases abound.

graphql
# Use graphql-scalars for well-tested common types
import { DateTimeResolver } from "graphql-scalars";

const resolvers = {
  DateTime: DateTimeResolver,
  Date: DateResolver,
  Time: TimeResolver,
};

# In SDL
scalar DateTime

type Query {
  now: DateTime!
}

JSON Scalar

JSON scalars let arbitrary data pass through, useful for dynamic config or metadata. The trade-off: you lose type safety and field-level validation, and clients can't select sub-fields. Use sparingly - a typed object type is almost always better.

graphql
scalar JSON

type Query {
  # accept arbitrary JSON as input or output
  config: JSON
  setConfig(value: JSON): JSON
}

# The simplest pass-through resolver
const resolvers = {
  JSON: {
    serialize: (v) => v,
    parseValue: (v) => v,
    parseLiteral: (ast) => parseJsonLiteral(ast),
  },
};
04

Object Types

Defining Object Types

An object type is a collection of named fields with return types. Object types form the graph - they reference each other to build relationships (User has Posts, Post has Author). Object types are output types; use input types for inputs.

graphql
type User {
  id: ID!
  name: String!
  email: String
  createdAt: String!
}

type Post {
  id: ID!
  title: String!
  body: String!
  published: Boolean!
}

Fields and Field Types

Every field declares a return type. Nullable is the default; ! makes it non-null. Lists wrap with []. The position of ! matters: [String] can be null and hold nulls; [String!] can be null but items can't; [String!]! can never be null nor hold nulls.

graphql
type Product {
  id: ID!                # non-null ID
  name: String!          # non-null string
  price: Float!          # non-null float
  discount: Float        # nullable float
  tags: [String!]!       # non-null list of non-null strings
  reviews: [Review]      # nullable list of nullable reviews
}

Non-Null and Lists

The ! placement controls nullability at each level. Non-null is powerful but risky: if a resolver returns null on a non-null field, the error bubbles up and nulls the parent field too. Default to nullable and opt into non-null where a value is truly guaranteed.

graphql
type Example {
  a: [String]       # may be null; items may be null
  b: [String!]      # may be null; items NOT null
  c: [String]!      # NOT null; items may be null
  d: [String!]!     # NOT null; items NOT null

  name: String      # may be null
  name2: String!    # NOT null - errors if null returned
}

Nested Objects

Object types reference each other to form the graph, enabling queries that traverse relationships (user -> posts -> author -> profile). Circular references between types are fine and common. The graph is what makes GraphQL a graph, not a tree of endpoints.

graphql
type User {
  id: ID!
  name: String!
  profile: Profile!
  posts: [Post!]!
}

type Profile {
  bio: String
  avatar: String
}

type Post {
  id: ID!
  title: String!
  author: User!   # back-reference creates the graph
}

Field Arguments on Objects

Any field - not just root fields - can take arguments. This lets a single type expose filtered/paginated views of related data (e.g. user.posts(limit: 10)). Arguments with default values become optional in queries.

graphql
type User {
  id: ID!
  name: String!
  # an object field can take arguments
  posts(limit: Int = 5, after: ID): [Post!]!
  role(type: RoleType!): Role
}

type Query {
  user(id: ID!): User
}
05

Queries

Query Type

The Query type is the entry point for all reads. Every field here is a top-level operation clients can request. Keep it focused on reads; put writes in Mutation. Naming fields as nouns (users, user, search) is conventional.

graphql
type Query {
  me: User!
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
  search(term: String!): [SearchResult!]!
}

# Clients run these as operations:
# query { me { name } }
# query { user(id: "1") { name } }

Multiple Fields

A query can select multiple root fields in one request. GraphQL executes them in parallel (except in mutations). This batches related fetches into a single round trip, a key efficiency win over REST.

graphql
# Several fields in one request - executed in parallel
query {
  me { name email }
  user(id: "1") { name }
  users(limit: 3) { id name }
}

# Response contains all three under "data"
{
  "data": {
    "me": { "name": "Alice", "email": "[email protected]" },
    "user": { "name": "Bob" },
    "users": [ { "id": "1", "name": "Bob" }, ... ]
  }
}

Aliases

Aliases rename fields in the response. They are required when selecting the same field with different arguments - JSON object keys must be unique. Aliases are also handy for shorter or more meaningful response keys.

graphql
# Same field twice with different args needs aliases
query {
  alice: user(id: "1") { name }
  bob:   user(id: "2") { name }
}

# Response keys match the aliases
{
  "data": {
    "alice": { "name": "Alice" },
    "bob":   { "name": "Bob" }
  }
}

Nested Queries

Traversing relationships is GraphQL's superpower. The client walks the graph (user -> posts -> comments -> author) in a single query. The server resolves each field via its resolver; deep nesting is fine but watch for N+1 queries on the backend.

graphql
query {
  user(id: "1") {
    name
    posts {            # traverse relationship
      title
      comments {        # and another level
        author {
          name
        }
      }
    }
  }
}

Query with Arguments

Arguments can be inline literals or variables. Inline literals are fine for static values; use variables whenever a value comes from user input or changes per call. Variables keep the query cacheable and avoid string concatenation.

graphql
# Schema
type Query {
  user(id: ID!, includeEmail: Boolean = false): User
}

# Inline arguments
query {
  user(id: "1", includeEmail: true) {
    name
    email
  }
}

# Prefer variables for dynamic values
query($uid: ID!, $withEmail: Boolean!) {
  user(id: $uid, includeEmail: $withEmail) {
    name
    email
  }
}

Operation Name

Naming operations (query GetUserProfile) is optional but strongly recommended. Named operations are easier to debug, show up in logs and Apollo Studio, and are required when sending multiple operations in one document or for persisted queries.

graphql
# Give operations a name - required in production
query GetUserProfile($id: ID!) {
  user(id: $id) {
    id
    name
    email
  }
}

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
  }
}
06

Mutations

Mutation Type

The Mutation type is the entry point for writes. By convention name mutation fields as verbs (createUser, updatePost). Mutations run serially in the order written, so order-dependent side effects are safe across multiple mutation fields in one request.

graphql
type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
  createPost(input: CreatePostInput!): Post!
}

# Client mutation
mutation {
  createUser(input: { name: "Alice", email: "[email protected]" }) {
    id
    name
  }
}

Mutation with Input

Use input types for mutation arguments. They group related fields, make the signature clean, and evolve safely (add optional fields without breaking). Always pass mutation payloads through variables, never inline literals.

graphql
input CreateUserInput {
  name: String!
  email: String!
  age: Int
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

mutation($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
  }
}

# { "input": { "name": "Bob", "email": "[email protected]" } }

Returning Payloads

A common pattern is a payload object wrapping the result with errors, a success flag, or a client mutation ID. This is more flexible than throwing - you can return partial success and structured validation errors alongside the entity.

graphql
# Return the changed entity AND related fields like errors
type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  field: String
  message: String!
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}

Multiple Mutations

Unlike queries (parallel), mutations in one operation execute serially in the order declared. This guarantees the second mutation sees the first's effects. If one mutation field errors, later ones still run - validate inputs and use transactions where needed.

graphql
# Multiple mutations run SERIALLY in declared order
mutation {
  createPost(input: { title: "A" }) {
    id
  }
  publishPost(id: "1") {
    publishedAt
  }
}

Idempotency

Network retries can duplicate writes. An idempotency key lets the server recognize a retry and return the original result instead of creating a duplicate. This is essential for payments, orders, and any non-reversible action.

graphql
# Use a client mutation id or idempotency key
input CreateOrderInput {
  idempotencyKey: String!
  productId: ID!
  quantity: Int!
}

type Mutation {
  createOrder(input: CreateOrderInput!): Order!
}

# Clients retry with the SAME idempotencyKey on failure
# Server returns the original order instead of creating a duplicate
07

Subscriptions

Subscription Type

Subscriptions push data from server to client when events occur, typically over WebSocket. They are long-lived operations. Keep payloads small - clients usually fetch detailed data with a follow-up query rather than streaming large objects.

graphql
type Subscription {
  postAdded: Post!
  userJoined(roomId: ID!): User!
  messageReceived(userId: ID!): Message!
}

# Client subscribes (usually over WebSocket)
subscription {
  postAdded {
    id
    title
    author { name }
  }
}

Subscribe to Events

A subscription resolver exposes a subscribe function returning an AsyncIterator. Mutations (or any server code) publish events to the same topic via pubsub. The optional resolve function transforms the published payload into the field's shape.

graphql
# Server-side resolver with asyncIterator
const resolvers = {
  Subscription: {
    postAdded: {
      subscribe: () => pubsub.asyncIterator(["POST_ADDED"]),
      resolve: (payload) => payload.postAdded,
    },
  },
};

# Trigger from a mutation
const resolvers = {
  Mutation: {
    createPost: async (_, args, { pubsub }) => {
      const post = await createPost(args);
      await pubsub.publish("POST_ADDED", { postAdded: post });
      return post;
    },
  },
};

PubSub

graphql-subscriptions' PubSub works for a single server instance. For multiple server instances behind a load balancer you need a shared broker - redis-pubsub, MQTT, or a Kafka-backed pubsub - so events reach clients connected to any instance.

graphql
import { PubSub } from "graphql-subscriptions";

const pubsub = new PubSub();

// publish an event
pubsub.publish("POST_ADDED", { postAdded: post });

// consume in a subscription resolver
subscribe: () => pubsub.asyncIterator(["POST_ADDED"])

# For multi-instance production, swap PubSub for
# Redis, NATS, or Kafka-based implementations.

Subscription with Filters

withFilter lets each connected client receive only relevant events - e.g. messages addressed to them. The filter function receives the published payload and the subscription variables. Keep filters cheap; they run for every published event per connected client.

graphql
const resolvers = {
  Subscription: {
    messageReceived: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(["MESSAGE_RECEIVED"]),
        (payload, variables) =>
          payload.messageReceived.recipientId === variables.userId
      ),
      resolve: (payload) => payload.messageReceived,
    },
  },
};

# Only events where the filter returns true reach this client

WebSocket Transport

Subscriptions use the graphql-ws (or legacy subscriptions-transport-ws) protocol over WebSocket. The newer graphql-ws protocol is recommended. connectionParams carry auth tokens. The server must install the WS handler alongside the HTTP endpoint.

graphql
# Client uses graphql-ws over WebSocket
import { createClient } from "graphql-ws";

const wsClient = createClient({
  url: "ws://localhost:4000/graphql",
  connectionParams: { authToken: "..." },
});

wsClient.subscribe(
  { query: "subscription { postAdded { id title } }" },
  {
    next: (data) => console.log(data),
    error: (err) => console.error(err),
    complete: () => console.log("done"),
  }
);
08

Arguments

Field Arguments

Arguments are named, never positional. They can appear on any field, not just root fields. Named args make queries self-documenting and order-independent. Use variables instead of inline literals when values are dynamic.

graphql
type Query {
  user(id: ID!): User
  posts(limit: Int, offset: Int): [Post!]!
}

# Arguments can be inline literals
query {
  user(id: "1") { name }
  posts(limit: 10, offset: 0) { title }
}

Default Values

Arguments with default values become optional - clients can omit them. Default values are evaluated per request. For non-null arguments there's no default; the client must supply a value. Defaults keep queries terse while staying explicit when needed.

graphql
type Query {
  # default values make arguments optional
  posts(limit: Int = 10, sort: String = "newest"): [Post!]!
  user(id: ID!, includeDeleted: Boolean = false): User
}

# These are equivalent:
query { posts { title } }
query { posts(limit: 10, sort: "newest") { title } }

Argument Types

Arguments can be scalars, enums, lists, or input object types - never regular object types (those are output-only). Input objects are the idiomatic way to pass complex, structured arguments, especially for filters and pagination.

graphql
type Query {
  # scalar arguments
  byId(id: ID!): User
  byAge(min: Int!, max: Int!): [User!]!

  # enum argument
  byStatus(status: UserStatus!): [User!]!

  # input object argument
  search(filter: UserFilter!): [User!]!

  # list argument
  byIds(ids: [ID!]!): [User!]!
}

Required vs Optional

The ! suffix on an argument makes it required. Omitting a required argument fails validation before the resolver runs, returning a clear error. Prefer required for genuinely needed inputs and optional-with-default for tunable behavior.

graphql
type Query {
  # required (non-null) - client MUST provide
  user(id: ID!): User

  # optional (nullable) - client may omit
  posts(limit: Int): [Post!]!

  # optional with default
  recentPosts(limit: Int = 5): [Post!]!
}

# Omitting a required argument is a validation error
# before any resolver runs.

List Arguments

List arguments use square brackets. [ID!]! means a non-null list of non-null IDs - the client must pass a list and every element must be non-null. Pass lists via variables rather than inline literals for cleanliness and to avoid escaping issues.

graphql
type Query {
  usersByIds(ids: [ID!]!): [User!]!
  tags(allOf: [String!]): [Post!]!
}

# Inline list literal
query {
  usersByIds(ids: ["1", "2", "3"]) { name }
}

# Via variable
query($ids: [ID!]!) {
  usersByIds(ids: $ids) { name }
}
# { "ids": ["1", "2", "3"] }
09

Variables

Variable Definition

Variables parameterize operations. Declare them as $name: Type in the operation header, reference them as $name in the body, and send values in a separate JSON variables field. This separates the static query from dynamic data.

graphql
# Declare variables in the operation, then use them
query GetUser($id: ID!) {
  user(id: $id) {
    name
    email
  }
}

# Variables are sent in a separate JSON object
{
  "id": "1"
}

Variable Types

Variable types must match the argument types in the schema. Variables can be nullable, non-null, lists, or input objects. A variable's type must be an input type (scalar, enum, or input object) - never an output object type.

graphql
query Search(
  $term: String!
  $limit: Int = 10
  $filters: PostFilter
  $tags: [String!]
) {
  search(term: $term, limit: $limit, filters: $filters, tags: $tags) {
    title
  }
}

Default Values

Variable defaults apply when the client omits the variable. They are only legal on nullable variables - a non-null variable means the client MUST supply a value. Defaults let clients send minimal variable sets while servers stay flexible.

graphql
query Posts($limit: Int = 10, $sort: String = "newest") {
  posts(limit: $limit, sort: $sort) { title }
}

# If the client omits "limit", it defaults to 10.
# Only nullable variables can have defaults - a non-null
# variable with a default is a contradiction.

Required Variables

A non-null variable (!) is required - omitting it fails validation before the resolver runs. This is the safest way to guarantee an input exists. Non-null variables cannot have defaults; if you want a default, make the variable nullable.

graphql
query User($id: ID!) {     # ! means required
  user(id: $id) { name }
}

# Missing required variable -> validation error
{ "id": "1" }      # OK
{ }                # ERROR: variable $id of type ID! is required

# A non-null variable cannot have a default value.

Using Variables with Directives

Variables can drive directives like @include and @skip, letting clients toggle fields dynamically. This is great for feature flags, A/B tests, or conditional sections - all with a single cached query document.

graphql
query User($id: ID!, $withPosts: Boolean!) {
  user(id: $id) {
    name
    posts @include(if: $withPosts) {
      title
    }
  }
}

# { "id": "1", "withPosts": false }  -> posts omitted
# { "id": "1", "withPosts": true }   -> posts included
10

Fragments

Named Fragments

Fragments are reusable selection sets bound to a specific type (fragment Name on Type). Spread them with ...Name wherever that type appears. They keep queries DRY and let clients share field selections across operations.

graphql
# Define a reusable fragment
fragment UserFields on User {
  id
  name
  email
}

# Spread it into queries
query {
  user(id: "1") {
    ...UserFields
  }
  users {
    ...UserFields
  }
}

Fragment Composition

Fragments can spread other fragments, building up composite selections. This modularizes field choices (basic vs full profile) and lets UI components each declare their own data needs, then compose them into one query.

graphql
fragment UserBasic on User {
  id
  name
}

fragment UserWithEmail on User {
  ...UserBasic
  email
}

fragment UserFull on User {
  ...UserWithEmail
  posts { title }
}

# Fragments can spread other fragments of the same type.

Fragment Variables

Relay-style fragment variables let a fragment accept parameters (like a limit) that callers pass via @arguments. This makes fragments truly reusable with different settings. Apollo supports this via the @arguments directive from its client directives.

graphql
fragment UserPosts on User
  @argumentDefinitions(limit: { type: "Int", defaultValue: 5 }) {
  posts(limit: $limit) {
    title
  }
}

query {
  user(id: "1") {
    ...UserPosts @arguments(limit: 10)
  }
}

# @argumentDefinitions and @arguments come from
# Relay; Apollo uses a similar pattern via client directives.

Reusable Fragments

Colocating fragments with the components that render them is a powerful pattern: each component declares its data needs, and the parent composes them into one query. This scales data fetching in large apps (Relay and Apollo both support it).

graphql
// Colocate fragments with the UI component that uses them
const USER_CARD = graphql`
  fragment UserCard_user on User {
    id
    name
    avatarUrl
  }
`;

function UserCard({ user }) {
  return <div>{user.name}</div>;
}

// The parent query spreads all needed fragments
query {
  users {
    ...UserCard_user
  }
}

Fragment Caveats

A fragment declared on Post can only be spread where a Post is expected - spreading it on a User is a validation error. Use inline fragments with type conditions to spread conditionally on unions/interfaces. Also avoid circular fragment spreads.

graphql
# A fragment must match the type where it's spread
fragment PostFields on Post { title body }

query {
  user(id: "1") {
    ...PostFields   # ERROR: user is not a Post
  }
}

# Fragments can only spread on the declared type
# (or via type conditions with inline fragments).
11

Inline Fragments

Inline Fragment Basics

An inline fragment is an unnamed selection set prefixed with ... on Type. It applies fields only when the runtime object matches the type condition. This is how you access type-specific fields on interfaces and unions.

graphql
# An inline fragment is a selection set with a type condition
query {
  user(id: "1") {
    id
    name
    # fields common to User
    ... on AdminUser {
      permissions
    }
  }
}

# No name - declared inline in the query.

Type Conditions

The ... on Type syntax is a type condition. Multiple inline fragments in one selection let you handle each possible type of a union/interface separately. Fields shared by all types can be selected directly outside the fragments.

graphql
query Search($term: String!) {
  search(term: $term) {
    ... on User { name email }
    ... on Post { title author { name } }
    ... on Comment { body }
  }
}

Union Access

Unions have no shared fields beyond __typename, so to read type-specific data you must use inline fragments. __typename is always selectable and tells the client which concrete type each item is - essential for rendering union results.

graphql
union SearchResult = User | Post | Comment

query {
  search(term: "graphql") {
    # shared __typename is always available
    __typename
    ... on User { name }
    ... on Post { title }
    ... on Comment { body }
  }
}

Interface Access

Interfaces declare shared fields. Those shared fields can be selected directly on the interface; type-specific fields need inline fragments. Unlike unions, interfaces guarantee a common set of fields every implementing type must provide.

graphql
interface Node { id: ID! }
type User implements Node { id: ID! name: String! }
type Post implements Node { id: ID! title: String! }

query {
  node(id: "1") {
    id              # shared interface field - selectable directly
    ... on User { name }
    ... on Post { title }
  }
}

Named Fragment with Type Condition

Named fragments carry their own type condition, so you can spread them on a union/interface result and each applies only to its matching type. This combines reusability with conditional selection - spread several named fragments to cover all union members.

graphql
# Named fragments can also carry a type condition
fragment UserFields on User { name email }
fragment PostFields on Post { title }

query {
  search(term: "a") {
    ...UserFields
    ...PostFields
  }
}

# Equivalent to inlining those fragments inline.
12

Directives

Built-in Directives

GraphQL ships two built-in client directives: @skip(if: Boolean!) omits the field when true, @include(if: Boolean!) includes it only when true. They are mutually exclusive and can be applied to fields, fragment spreads, and inline fragments.

graphql
# @skip - omit field if true
query($skip: Boolean!) {
  user(id: "1") {
    name
    email @skip(if: $skip)
  }
}

# @include - include field only if true
query($withPosts: Boolean!) {
  user(id: "1") {
    posts @include(if: $withPosts) { title }
  }
}

@skip and @include

@skip and @include let one query serve multiple shapes. They take a Boolean variable, so the same persisted query can render a summary or a detailed view depending on variables - no need to maintain two query strings.

graphql
query User($id: ID!, $detailed: Boolean!) {
  user(id: $id) {
    name
    email @skip(if: $detailed)      # hide when detailed
    profile @include(if: $detailed) {  # show when detailed
      bio
    }
  }
}

# { "id": "1", "detailed": false } -> name, email
# { "id": "1", "detailed": true }  -> name, profile { bio }

Custom Directives

Custom directives start with directive declaration giving arguments and allowed locations. Execution semantics aren't built in - you implement them with schema transforms (Apollo) or directive visitors (graphql-tools). Common uses: @auth, @cached, @deprecated.

graphql
# Declare a directive in SDL
directive @auth(requires: Role = ADMIN) on FIELD_DEFINITION

type Query {
  adminData: String @auth(requires: ADMIN)
  userData: String @auth(requires: USER)
}

# A directive needs a location: FIELD_DEFINITION, FIELD,
# OBJECT, ENUM, etc. Logic is implemented in the server
# (e.g. via schema transforms or a directive visitor).

Directive Locations

Every directive declares where it's legal - on operations, fields, fragments, or schema definitions (FIELD_DEFINITION, OBJECT, etc.). Client directives (applied in queries) and schema directives (applied in SDL) use different location sets. Validate at schema build.

graphql
# Where a directive can appear
directive @example on
  | QUERY          # operation
  | FIELD          # a field in a selection
  | FRAGMENT_DEFINITION
  | FRAGMENT_SPREAD
  | INLINE_FRAGMENT
  | FIELD_DEFINITION  # in SDL (server-side)
  | OBJECT | INTERFACE | UNION | ENUM
  | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION;

# Multiple locations use | between them.

Directive Arguments

Directives can take typed arguments with defaults. The built-in @deprecated(reason:) marks fields and enum values as obsolete; tools warn clients using them. Custom directives with arguments drive behavior like cache TTLs or required roles.

graphql
directive @deprecated(
  reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE

type Query {
  oldField: String @deprecated(reason: "Use newField")
  newField: String
}

enum Status {
  ACTIVE
  INACTIVE @deprecated(reason: "Use ARCHIVED")
  ARCHIVED
}
13

Enums

Defining Enums

An enum is a finite set of named values. Enums are scalar types - they're leaf values with no sub-selections. Use them for any field with a fixed set of options (status, role, kind) rather than magic strings, gaining validation and self-documentation.

graphql
enum Role {
  ADMIN
  USER
  GUEST
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type User {
  role: Role!
}

type Post {
  status: PostStatus!
}

Enum Values

Enum values are written as unquoted identifiers in queries (color: RED), but serialized as strings in JSON variables and responses. Naming is conventionally UPPER_SNAKE_CASE. The server validates any value against the declared set.

graphql
enum Color {
  RED
  GREEN
  BLUE
}

# In a query, enum values are unquoted identifiers
query {
  products(color: RED) { name }
}

# In variables JSON, they're sent as strings
{ "color": "RED" }

Enum Usage in Queries

Enums work as both field types and argument types. When used as an argument, the client passes one of the declared values. Invalid enum values fail validation before the resolver runs, giving clear errors.

graphql
type Query {
  posts(status: PostStatus!): [Post!]!
}

query {
  posts(status: PUBLISHED) { title }
}

query($status: PostStatus!) {
  posts(status: $status) { title }
}
# { "status": "DRAFT" }

Enum Resolvers

By default an enum value maps to itself. An enum resolver lets you map between the public API name (ADMIN) and an internal representation (a string "admin" or numeric role code). This decouples your schema from storage details.

graphql
# Map internal values <-> API values
enum Role {
  ADMIN
  USER
}

const resolvers = {
  Role: {
    ADMIN: "admin",   // internal DB value
    USER: "user",
  },
};

# Internally the resolver sees "admin"; the client sees ADMIN.
# Useful when DB values differ from the public API.

Enum Best Practices

Always prefer enums over free-form strings for fixed option sets - they validate inputs, document the API, and enable better tooling. To remove an enum value without breaking clients, deprecate it first. Removing a value clients still send is a breaking change.

graphql
# Prefer enums over strings for fixed option sets
type User {
  role: Role!          # GOOD - validated, self-documenting
  roleStr: String      # AVOID - no validation, magic strings
}

# Deprecate values instead of removing them
enum Status {
  ACTIVE
  INACTIVE @deprecated(reason: "Use ARCHIVED")
  ARCHIVED
}
14

Interfaces

Defining Interfaces

An interface is an abstract type declaring a set of fields that implementing types must provide. It defines a contract. Interfaces are useful for shared abstractions like Node (relay-style global IDs) or Character across related types.

graphql
interface Node {
  id: ID!
}

interface Character {
  name: String!
}

type User implements Node & Character {
  id: ID!
  name: String!
  email: String!
}

Implementing Interfaces

A type declares it implements an interface with the implements keyword. It must define every field the interface declares, with compatible types. A type can implement multiple interfaces (separated by &). The schema validates this at build time.

graphql
interface Node {
  id: ID!
}

type User implements Node {
  id: ID!        # required by Node
  name: String!
}

type Post implements Node {
  id: ID!        # required by Node
  title: String!
}

# A type must implement every field of the interface.

Querying Interfaces

When a field returns an interface, you can select the interface's shared fields directly. To access type-specific fields, use inline fragments (... on User). The runtime type of each result determines which inline fragments apply.

graphql
interface Node { id: ID! }

type Query {
  node(id: ID!): Node
}

query {
  node(id: "1") {
    id              # interface field - always selectable
    ... on User { name }
    ... on Post { title }
  }
}

Interface Resolvers

An interface needs a __resolveType function that returns the concrete type name (or the type object) for a given runtime value. This lets GraphQL know which inline fragments to apply. Without it, GraphQL can't determine the concrete type at runtime.

graphql
interface Character { name: String! }

type Human implements Character {
  name: String!
  starships: [String!]!
}

type Droid implements Character {
  name: String!
  primaryFunction: String
}

const resolvers = {
  Character: {
    // tell GraphQL which concrete type a value is
    __resolveType(obj) {
      return obj.primaryFunction ? "Droid" : "Human";
    },
  },
};

Multiple Interfaces

A type can implement multiple interfaces with the & separator. This composes capabilities - e.g. an Article that is a Node, Timestamped, and SoftDeletable. Querying returns the interface type, and clients use inline fragments for concrete fields.

graphql
interface Node { id: ID! }
interface Timestamped { createdAt: String! }
interface SoftDeletable { deletedAt: String }

type Article implements Node & Timestamped & SoftDeletable {
  id: ID!
  createdAt: String!
  deletedAt: String
  title: String!
}

# Use & to list multiple implemented interfaces.
15

Unions

Defining Unions

A union is an abstract type that can be one of several concrete types, without requiring shared fields (unlike interfaces). Define it with union Name = TypeA | TypeB. Unions are great when results are heterogeneous but conceptually related.

graphql
union SearchResult = User | Post | Comment

type User { id: ID! name: String! }
type Post { id: ID! title: String! }
type Comment { id: ID! body: String! }

type Query {
  search(term: String!): [SearchResult!]!
}

Union vs Interface

Use an interface when members share a common contract (fields every member has). Use a union when members are unrelated except conceptually - they share no fields. A type can be in many unions; unions can't directly implement other unions.

graphql
# INTERFACE - shared contract
interface Node { id: ID! }
type User implements Node { id: ID! name: String! }
type Post implements Node { id: ID! title: String! }
# All members share the "id" field.

# UNION - no shared contract
union SearchResult = User | Post
# Members share NO required fields.

Querying Unions

Unions expose no fields of their own, so you must use inline fragments to read anything. __typename is always available and tells the client which concrete type each item is. Selecting a field directly on a union (without a type condition) is a validation error.

graphql
query {
  search(term: "graphql") {
    __typename          # always available
    ... on User { name email }
    ... on Post { title }
    ... on Comment { body }
  }
}

# You CANNOT select fields directly on a union
# (no shared fields) - only via inline fragments.

Union Resolvers

Like interfaces, unions need a __resolveType resolver returning the concrete type name for a runtime value. Without it GraphQL can't pick inline fragments. Return the type name as a string or the type object from the schema.

graphql
union SearchResult = User | Post | Comment

const resolvers = {
  SearchResult: {
    __resolveType(obj) {
      if (obj.email) return "User";
      if (obj.title) return "Post";
      if (obj.body) return "Comment";
      return null;
    },
  },
};

Common Patterns

Unions model result-or-error outcomes: a mutation returns a union of a success type and an error type, forcing clients to handle both. This is more type-safe than a single payload with optional error fields. (SDL requires union, not type aliases.)

graphql
# Mutation result that may be success or error
type CreateUserSuccess { user: User! }
type CreateUserError { messages: [String!]! }
type CreateUserPayload = CreateUserSuccess | CreateUserError
# (note: a type alias like this isn't valid SDL; use union)
union CreateUserPayload = CreateUserSuccess | CreateUserError

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}
16

Input Types

Input Object Types

Input types are object types used for arguments, declared with input. They group related fields (a user, a filter) into a single argument. Unlike output object types, input types can only contain input types (scalars, enums, other input types).

graphql
input CreateUserInput {
  name: String!
  email: String!
  age: Int
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

mutation($input: CreateUserInput!) {
  createUser(input: $input) { id }
}
# { "input": { "name": "Alice", "email": "[email protected]" } }

Input vs Output

Output types (type) can have field arguments and resolvers; input types (input) are plain data carriers with no resolvers. They're separate namespaces - you can have a User type and a UserInput input. Don't reuse one for both; it breaks evolution.

graphql
# OUTPUT type - returned from resolvers, has resolvers
type User {
  id: ID!
  name: String!
  posts: [Post!]!   # can have field arguments and resolvers
}

# INPUT type - passed into operations, no resolvers
input UserInput {
  name: String!
  email: String!
  # NO field arguments, NO resolvers, NO output object types
}

# A type cannot be used as both input and output.

Nested Inputs

Input types can reference other input types and lists, modeling complex nested payloads. This is ideal for hierarchical forms or nested filters. Each level is validated against the schema, giving clear errors on malformed input.

graphql
input AddressInput {
  street: String!
  city: String!
  zip: String!
}

input CreateUserInput {
  name: String!
  email: String!
  address: AddressInput      # nested input type
  tags: [String!]            # list input
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

Input Defaults

Input fields and arguments can have default values. When the client omits a field, the default applies. Defaults make inputs optional without forcing clients to send full objects. Note defaults are static literals, not computed per request.

graphql
input PaginationInput {
  limit: Int = 20
  offset: Int = 0
  sort: SortOrder = DESC
}

enum SortOrder { ASC DESC }

type Query {
  posts(page: PaginationInput = { limit: 20, offset: 0 }): [Post!]!
}

# Defaults apply per-field when omitted by the client.

Input Validation

GraphQL validates types and non-nullity, but not semantic rules (email format, age range, string length). Implement those in the resolver, throwing UserInputError for bad data. Some servers add validation directives (@constraint) for declarative rules.

graphql
input CreateUserInput {
  name: String!              # required, non-null
  email: String!             # format check needs custom code
  age: Int                   # range check needs custom code
}

# GraphQL validates structure; you validate semantics
function createUser(_, { input }) {
  if (!input.email.includes("@")) {
    throw new UserInputError("Invalid email");
  }
  if (input.age < 0 || input.age > 150) {
    throw new UserInputError("Invalid age");
  }
  // ...
}
17

Resolvers

Resolver Functions

A resolver is a function that produces the value for one field. The resolver map mirrors the schema: resolvers.Query.user handles the user field. If a resolver is missing, GraphQL uses a default that reads the matching property from the parent.

graphql
const resolvers = {
  Query: {
    user: (parent, args, context, info) => {
      return context.db.user.findById(args.id);
    },
    users: () => db.users.findAll(),
  },
  Mutation: {
    createUser: (_, { input }, { db }) =>
      db.user.create(input),
  },
};

Resolver Arguments

Every resolver gets four args: parent (the resolved value of the parent field), args (this field's arguments), context (a per-request shared object), and info (AST and execution metadata). The first two are most common; info is for advanced cases like sub-selection inspection.

graphql
// (parent, args, context, info)
const resolvers = {
  Query: {
    user: (parent, args, context, info) => {
      // parent   - value returned by the parent field (root for Query)
      // args     - arguments passed to this field
      // context  - shared per-request object (db, user, loaders)
      // info     - AST + execution info (rarely needed)
      return context.db.user.findById(args.id);
    },
  },
};

Resolver Chain

Resolvers run in a depth-first chain: the parent field's resolver produces an object, then each child field's resolver runs with that object as parent. This top-down flow lets each field resolve independently - the graph assembles itself field by field.

graphql
# query { user(id: "1") { name posts { title } } }
const resolvers = {
  Query: {
    user: () => ({ id: "1", name: "Alice" }),   // runs first
  },
  User: {
    posts: (user, args, ctx) => {                // runs after user
      return ctx.db.posts.findByAuthor(user.id);
    },
  },
  Post: {
    title: (post) => post.title,                 // runs after posts
  },
};

Async Resolvers

Resolvers can return Promises; GraphQL awaits them before continuing to child fields. This is how you call databases and APIs. Use async/await for readability. Resolvers at the same level run in parallel (for queries), so independent async work batches naturally.

graphql
const resolvers = {
  Query: {
    // return a Promise - GraphQL awaits it
    user: async (_, { id }, { db }) => {
      const user = await db.user.findById(id);
      if (!user) throw new Error("User not found");
      return user;
    },
  },
  User: {
    posts: async (user, _, { db }) =>
      db.posts.findByAuthor(user.id),
  },
};

Default Resolvers

If a field has no resolver, GraphQL reads the property of the same name from the parent object (returning null if undefined). You only write resolvers for fields needing logic or fetching. The default behavior works when your parent objects already have the right shape.

graphql
# If you don't define a resolver, GraphQL reads from the parent
const resolvers = {
  User: {
    // name resolver missing - default behavior:
    // (parent) => parent.name
    posts: (user) => fetchPosts(user.id),  // only this is custom
  },
};

// Equivalent explicit default:
User: {
  name: (user) => (user.name === undefined ? null : user.name),
}

Resolver Best Practices

Keep resolvers thin: delegate fetching to data source classes on the context. This centralizes data access (testable, mockable) and lets data sources batch and cache. Resolvers should orchestrate, not contain SQL or fetch logic directly.

graphql
# Keep resolvers thin - delegate to data sources
const resolvers = {
  Query: {
    user: (_, { id }, { dataSources }) =>
      dataSources.userAPI.getUser(id),
  },
  User: {
    posts: (user, _, { dataSources }) =>
      dataSources.postAPI.byAuthor(user.id),
  },
};

// dataSources live on context, are injected per request,
// and can batch/cache calls internally.
18

Context & DataLoader

Context Object

The context is a per-request object shared across all resolvers in an operation. Put shared dependencies here: database clients, the authenticated user, data loaders, and loggers. The context function runs once per request and its return value is passed to every resolver.

graphql
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    db: new PrismaClient(),
    user: getUserFromToken(req.headers.authorization),
    loaders: buildLoaders(),
  }),
});

// Inside any resolver, the third argument is context:
(parent, args, context) => context.db.user.findById(args.id);

Per-Request Context

Create fresh DataLoader instances inside the context function so their caches scope to a single request. Reusing a loader across requests would leak cached data between users. The context itself is built once per request, so per-request setup goes here.

graphql
const context = async ({ req }) => {
  const token = req.headers.authorization || "";
  const user = await verifyToken(token);  // may be null

  return {
    user,
    db,
    // a FRESH DataLoader instance per request
    // so caches don't leak across requests
    userLoader: new DataLoader((ids) => batchGetUsers(ids)),
  };
};

The N+1 Problem

The N+1 problem: fetching a list of N items, then making one extra query per item to load a relation. GraphQL's parallel field resolution makes this easy to hit - a User.posts resolver runs once per user. The result is a flood of redundant queries.

graphql
# query { users { id name posts { title } } }
// BAD: 1 query for users, then N queries for each user's posts
const resolvers = {
  User: {
    posts: (user, _, { db }) =>
      db.posts.findByAuthor(user.id),  // runs once per user!
  },
};

// If there are 100 users, that's 101 DB queries.
// This is the classic N+1 problem.

DataLoader Basics

DataLoader coalesces many individual loads into one batched request. You give it a batch function that takes an array of keys and returns an array of results in the same order. DataLoader then collects all loads within a tick and calls your batch function once.

graphql
import DataLoader from "dataloader";

// A batch function receives an array of keys, returns
// an array of values IN THE SAME ORDER as the keys.
const userLoader = new DataLoader(async (userIds) => {
  const users = await db.users.findMany({ where: { id: { in: userIds } } });
  // reorder to match the input keys
  return userIds.map((id) =>
    users.find((u) => u.id === id) ?? null
  );
});

DataLoader in Resolvers

Use loader.load(key) in resolvers instead of direct DB calls. Within a single tick, all loads for the same loader are batched into one query, and results are cached for the request's duration. This kills N+1s while keeping resolvers simple.

graphql
const resolvers = {
  User: {
    posts: (user, _, { loaders }) =>
      loaders.postLoader.load(user.id),
  },
  Post: {
    author: (post, _, { loaders }) =>
      loaders.userLoader.load(post.authorId),
  },
};

// 100 users -> ONE batched query for all their posts,
// instead of 100 separate queries.

Batching and Caching

DataLoader does two things: batching (many loads in one event-loop tick become one batch call) and per-key caching (repeated loads of the same key return the same Promise). The cache is per-loader-instance, which is why you create a fresh loader per request.

graphql
// Batching: many .load() calls in one tick -> one batch fn call
await Promise.all([loader.load(1), loader.load(2), loader.load(3)]);
// -> batchGetUsers([1, 2, 3]) called ONCE

// Caching: repeated .load(sameKey) returns the cached Promise
loader.load(1); loader.load(1); loader.load(1);
// -> batchGetUsers([1]) called ONCE; later calls hit cache

// .clear(key) invalidates; .clearAll() resets everything.
19

Error Handling

Error Response Format

Errors are returned in a top-level errors array alongside data. Each error has a message, location in the query, path to the failing field, and extensions for machine-readable codes. A field error nulls that field (or bubbles up if non-null) while data still returns successful parts.

graphql
# Errors appear in a top-level "errors" array
{
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND",
        "exception": { "stacktrace": ["..."] }
      }
    }
  ],
  "data": { "user": null }
}

Throwing Errors

The simplest error handling: throw an Error. GraphQL catches it, nulls the field, and adds an entry to the errors array. For more structure, use specialized error classes (ApolloError, UserInputError) that carry an error code in extensions.

graphql
const resolvers = {
  Query: {
    user: async (_, { id }, { db }) => {
      const user = await db.user.findById(id);
      if (!user) {
        throw new Error("User not found");
      }
      return user;
    },
  },
};

# Thrown errors become entries in the "errors" array.

Custom Errors

Use typed error classes to convey intent: UserInputError for bad client input (4xx), AuthenticationError for missing auth, ForbiddenError for no permission, and ApolloError with a code for custom cases. The code in extensions lets clients branch reliably on errors.

graphql
import { ApolloError, UserInputError } from "apollo-server-errors";

const resolvers = {
  Mutation: {
    createUser: (_, { input }) => {
      if (!isValidEmail(input.email)) {
        throw new UserInputError("Invalid email", {
          field: "email",
        });
      }
      if (emailTaken(input.email)) {
        throw new ApolloError("Email taken", "EMAIL_TAKEN", {
          email: input.email,
        });
      }
      // ...
    },
  },
};

Error Extensions

extensions carries machine-readable data: a stable code string, retry hints, validation field paths, etc. Clients should switch on extensions.code rather than parsing the human message, which can change. Keep stacktraces out of production responses.

graphql
# Structured, machine-readable error data
throw new ApolloError("Rate limited", "RATE_LIMITED", {
  code: "RATE_LIMITED",
  retryAfter: 60,
  limit: 100,
});

# Clients check extensions.code to handle errors
if (err.extensions.code === "RATE_LIMITED") {
  wait(err.extensions.retryAfter);
}

Partial Results

GraphQL returns partial results: successful fields come back in data while failed fields are null (or bubble up if non-null) and listed in errors. Prefer nullable fields so one failure doesn't null the whole response. Non-null is a strong contract - use it deliberately.

graphql
# A non-null field error nulls the parent; nullable just nulls the field
type Query {
  user(id: ID!): User       # nullable - error nulls only "user"
  me: User!                 # non-null - error bubbles up to null "me"
}

# Query selecting multiple fields:
query { me { name } user(id: "bad") { name } }
# If "user" errors but "me" succeeds:
{
  "data": { "me": { "name": "Alice" }, "user": null },
  "errors": [{ "path": ["user"], "message": "..." }]
}
20

Apollo Server & Integration

Apollo Server Setup

Apollo Server is the most popular Node.js GraphQL server. Pass typeDefs (SDL) and resolvers to construct it, then start with the standalone server for quick dev. For production, integrate with an existing HTTP framework (Express, Fastify, Next.js) instead of standalone.

graphql
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

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

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

const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);

Express Integration

For Express, use expressMiddleware from @apollo/server/express4. Start the server before mounting the middleware. The context function receives the Express req/res, letting you read headers, sessions, or attach auth. This composes GraphQL into an existing Express app.

graphql
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";

const app = express();
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use("/graphql", cors(), bodyParser.json(),
  expressMiddleware(server, {
    context: async ({ req }) => ({ user: getUser(req) }),
  })
);
app.listen(4000);

Schema Build

makeExecutableSchema (from @graphql-tools/schema) builds a schema from typeDefs and resolvers, returning a GraphQLSchema object you pass to ApolloServer. This indirection enables schema transforms, merging multiple modules, and applying custom directives - useful in larger codebases.

graphql
import { makeExecutableSchema } from "@graphql-tools/schema";

const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});

const server = new ApolloServer({ schema });

// makeExecutableSchema lets you apply schema transforms,
// directive visitors, and merge modules before serving.

Context Function

The context function runs once per request and its return value is shared by all resolvers. Read auth headers and attach the authenticated user, database clients, and fresh DataLoaders here. Throw inside context to reject a request before any resolver runs (e.g. bad auth).

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

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    const token = req.headers.authorization || "";
    const user = await verifyToken(token);
    return { user, db, loaders: makeLoaders() };
  },
  listen: { port: 4000 },
});

Apollo Studio / Sandbox

Apollo Sandbox (in-browser at your server URL) lets you explore the schema and run operations during development. Apollo Studio is the cloud counterpart for schema history, metrics, and tracing. Disable introspection in production if you want to hide your schema from outsiders.

graphql
# Apollo Sandbox is a dev IDE in the browser
# Visit the server URL in a browser to open it.

const server = new ApolloServer({
  typeDefs,
  resolvers,
  // Apollo Studio reports go through this key (optional)
  // apollo: { graphRef: "my-graph@current" },
});

# For introspection in production, enable it explicitly:
# new ApolloServer({ introspection: true });

Request Lifecycle

Apollo Server runs a lifecycle per request: build context, parse the query, validate against the schema, execute resolvers, then format the response. formatError customizes error shape; plugins hook into phases like didResolveOperation, willSendResponse - great for logging, caching, and metrics.

graphql
# Order: HTTP request -> context -> parse -> validate ->
#        execute (resolvers) -> format response -> HTTP response

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formatted, error) => ({
    message: error.message,
    code: formatted.extensions?.code,
  }),
  plugins: [loggingPlugin, cachePlugin],
});

# plugins hook into every lifecycle phase.

Was this helpful?

Learning path

Learn from scratch

Learn this language from the ground up with structured lessons.