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.
# 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.
# 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.
# 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.
# 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.
# 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.
# 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 } }
}
}
}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.
# 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.
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.
# 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.
"""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.
# 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.
# 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" });
},
});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.
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.
# 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.
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.
# 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.
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),
},
};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.
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.
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.
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.
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.
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
}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.
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.
# 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.
# 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.
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.
# 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.
# 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
}
}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.
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.
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.
# 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.
# 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.
# 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 duplicateSubscriptions
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.
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.
# 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.
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.
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 clientWebSocket 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.
# 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"),
}
);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.
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.
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.
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.
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.
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"] }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.
# 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.
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.
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.
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.
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 includedFragments
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.
# 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.
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.
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).
// 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.
# 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).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.
# 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.
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.
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.
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.
# 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.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.
# @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.
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.
# 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.
# 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.
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
}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.
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.
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.
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.
# 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.
# 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
}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.
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.
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.
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.
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.
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.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.
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.
# 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.
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.
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.)
# 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!
}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).
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.
# 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.
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.
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.
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");
}
// ...
}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.
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.
// (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.
# 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.
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.
# 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.
# 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.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.
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.
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.
# 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.
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.
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.
// 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.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.
# 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.
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.
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.
# 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.
# 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": "..." }]
}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.
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.
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.
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).
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.
# 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.
# 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.Verwandte GraphQL-Snippets
Copy-paste ready code for common tasks.
Schema and Types
Define types, enums, and scalars.
Queries
Read data with selection sets.
Mutations
Write data and return the result.
Subscriptions
Receive pushed updates over a stream.
Fragments
Reusable selection sets.
Variables
Parameterize operations dynamically.
Directives
Conditionally include or skip fields.
Resolvers
Functions that fulfill each field.
Was this helpful?