Schema, Queries, Mutations, Subscriptions, Resolvers, Fragments — GraphQL APIs.
Every GraphQL service defines a set of object types that describe the shape of data you can query.
"""
A human user in the system.
Multi-line descriptions use triple-quotes.
"""
type User {
"""
Unique identifier for the user.
Auto-generated UUID.
"""
id: ID!
"Display name (2-50 characters)"
name: String!
"Email address (must be unique)"
email: String!
"User's bio, optional"
bio: String
"Account creation timestamp (ISO 8601)"
createdAt: DateTime!
"Whether the user is active"
isActive: Boolean!
"Number of posts authored"
postCount: Int!
"Average rating (float)"
avgRating: Float
"The user's authored posts"
posts(limit: Int = 10, offset: Int = 0): [Post!]!
"The user's profile (nullable)"
profile: Profile
}
type Post {
id: ID!
title: String!
content: String!
author: User!
tags: [String!]!
createdAt: DateTime!
updatedAt: DateTime!
"Like count — computed field (no DB column)"
likes: Int!
comments(limit: Int = 20): [Comment!]!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: DateTime!
}GraphQL has built-in scalar types and allows you to define custom scalars.
| Scalar | Description | Example |
|---|---|---|
| <code >String</code> | UTF-8 character sequence | "Hello World" |
| <code >Int</code> | Signed 32-bit integer | 42 |
| <code >Float</code> | IEEE 754 double-precision | 3.14 |
| <code >Boolean</code> | True or false | true / false |
| <code >ID</code> | Unique identifier (serialized as String) | "usr_abc123" |
| <code >DateTime</code> | Custom ISO 8601 date-time | "2026-02-15T10:30:00Z" |
| <code >Email</code> | Custom Valid email address | "user@example.com" |
| <code >URL</code> | Custom Valid URL string | "https://example.com" |
| <code >JSON</code> | Custom Arbitrary JSON | { "key": "value" } |
# Custom scalar definitions
scalar DateTime
scalar Email
scalar URL
scalar JSON
scalar PositiveInt
scalar Timestamp
# Custom scalar with directives
directive @customScalar(
description: String!
specifiedByURL: String
) on SCALAR_DEFINITION# ── Enum ──────────────────────────────────────
enum Role {
ADMIN
MODERATOR
MEMBER
GUEST
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum SortDirection {
ASC
DESC
}
# ── Input Types ─────────────────────────────────
input CreateUserInput {
name: String!
email: Email!
role: Role = MEMBER
bio: String
}
input UpdateUserInput {
name: String
email: Email
bio: String
role: Role
}
input PostsFilterInput {
status: PostStatus
authorId: ID
tags: [String!]
createdAfter: DateTime
search: String
sortBy: String = "createdAt"
sortDir: SortDirection = DESC
}
input PaginationInput {
first: Int = 20
after: String
}
# ── Interface ──────────────────────────────────
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
interface Media {
id: ID!
url: String!
mimeType: String!
size: Int!
}
# Types implementing interfaces
type Image implements Node & Media & Timestamped {
id: ID!
url: String!
mimeType: String!
size: Int!
createdAt: DateTime!
updatedAt: DateTime!
width: Int!
height: Int!
alt: String
}
type Video implements Node & Media & Timestamped {
id: ID!
url: String!
mimeType: String!
size: Int!
createdAt: DateTime!
updatedAt: DateTime!
duration: Int!
thumbnail: Image
}
# ── Union Types ────────────────────────────────
union SearchResult = User | Post | Image | Video
union MediaResult = Image | Video
type SearchQuery {
search(query: String!, first: Int = 10): [SearchResult!]!
}# ── Root schema definition ────────────────────
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
# ── Query root ─────────────────────────────────
type Query {
"Fetch a single user by ID"
user(id: ID!): User
"Fetch all users with optional filtering"
users(
filter: UsersFilterInput
first: Int = 20
after: String
): UserConnection!
"Fetch a single post by ID"
post(id: ID!): Post
"Fetch posts with cursor-based pagination"
posts(
filter: PostsFilterInput
first: Int = 20
after: String
): PostConnection!
"Global search across users, posts, and media"
search(query: String!): [SearchResult!]!
"Get the currently authenticated user"
me: User
}
# ── Mutation root ──────────────────────────────
type Mutation {
"Create a new user account"
createUser(input: CreateUserInput!): CreateUserPayload!
"Update an existing user"
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
"Delete a user account"
deleteUser(id: ID!): DeleteUserPayload!
"Publish a new post"
createPost(input: CreatePostInput!): CreatePostPayload!
"Toggle a like on a post"
toggleLike(postId: ID!): ToggleLikePayload!
}
# ── Subscription root ──────────────────────────
type Subscription {
"Subscribe to new posts in real time"
postCreated(userId: ID): Post!
"Subscribe to new comments on a post"
commentAdded(postId: ID!): Comment!
"Subscribe to user online status changes"
userStatusChanged(userId: ID!): UserStatusEvent!
}
# ── Payload types (mutation responses) ─────────
type CreateUserPayload {
user: User!
token: String!
}
type CreatePostPayload {
post: Post!
}
type ToggleLikePayload {
post: Post!
liked: Boolean!
}
type UserStatusEvent {
userId: ID!
isOnline: Boolean!
lastSeen: DateTime!
}
# ── Connection types (cursor-based pagination) ─
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}Directives add metadata or alter execution behavior. Some are built-in; others are custom.
| Directive | Location | Description |
|---|---|---|
@deprecated(reason: "...") | FIELD_DEFINITION, ENUM_VALUE | Marks as deprecated with a reason |
@include(if: Boolean!) | FIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT | Include only when condition is true |
@skip(if: Boolean!) | FIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT | Skip when condition is true |
@specifiedBy(url: "...") | SCALAR_DEFINITION | Provides specification URL for custom scalars |
@auth(requires: Role!) | Custom FIELD_DEFINITION | Authorization check |
@cacheControl(maxAge: 300) | Custom FIELD_DEFINITION, OBJECT | Cache TTL hint |
@constraint(minLength: 1, maxLength: 100) | Custom INPUT_FIELD_DEFINITION | Validation constraint |
@upper | Custom FIELD_DEFINITION | Transform output to uppercase |
# Using built-in directives in a query
query GetUser($withEmail: Boolean!, $skipBio: Boolean!) {
user(id: "usr_1") {
id
name
email @include(if: $withEmail)
bio @skip(if: $skipBio)
oldField: legacyId @deprecated(reason: "Use 'id' instead")
}
}
# Using @deprecated on schema fields / enum values
type User {
id: ID!
username: String! @deprecated(reason: "Use 'name' instead")
name: String!
}
enum Role {
ADMIN
MEMBER
BANNED @deprecated(reason: "Use SUSPENDED instead")
SUSPENDED
}
# Custom directive definitions
directive @auth(
requires: Role = MEMBER
) on FIELD_DEFINITION | OBJECT
directive @cacheControl(
maxAge: Int
scope: CacheScope = PUBLIC
) on FIELD_DEFINITION | OBJECT | INTERFACE
directive @rateLimit(
limit: Int!
window: String!
) on FIELD_DEFINITION
directive @upper on FIELD_DEFINITION
directive @lower on FIELD_DEFINITION
directive @validate(
pattern: String
minLength: Int
maxLength: Int
) on INPUT_FIELD_DEFINITION
enum CacheScope {
PUBLIC
PRIVATE
}@include and @skip directives for conditional field selection based on client-side variables — great for reducing payload size.GraphQL supports two documentation styles: string literals (description) and comments.
# Single-line comment (not exposed in introspection)
"""
Multi-line description.
This IS exposed via introspection (__type).
Use this for API documentation.
"""
type Product {
"""
Unique product identifier.
Generated automatically.
"""
id: ID!
# This comment is NOT exposed in introspection
name: String!
"Short description (also exposed in introspection)"
description: String
"""
Product price in USD.
Must be greater than zero.
"""
price: Float!
"Whether this product is currently in stock"
inStock: Boolean!
}
# Description on enums
"""
Supported currencies for pricing.
ISO 4217 currency codes.
"""
enum Currency {
"""
United States Dollar
"""
USD
EUR
GBP
JPY
}# Basic field selection
query {
user(id: "usr_1") {
id
name
email
createdAt
}
}
# Arguments on fields
query {
posts(limit: 5, offset: 0) {
title
author {
name
}
}
}
# Aliases — fetch the same field with different args
query {
admin: user(id: "usr_admin") {
id
name
role
}
moderator: user(id: "usr_mod") {
id
name
role
}
}
# Using variables
query GetUser($userId: ID!, $withPosts: Boolean! = true) {
user(id: $userId) {
id
name
email
posts @include(if: $withPosts) {
title
createdAt
}
}
}# Named fragment definition
fragment UserFields on User {
id
name
email
role
createdAt
}
fragment PostSummary on Post {
id
title
createdAt
likes
author {
...UserFields
}
}
# Using fragments in a query
query GetPost($postId: ID!) {
post(id: $postId) {
...PostSummary
content
comments {
id
text
author {
...UserFields
}
}
}
}
# Inline fragments — used on unions / interfaces
query Search($query: String!) {
search(query: $query) {
... on User {
id
name
bio
}
... on Post {
id
title
likes
}
... on Image {
id
url
width
height
}
... on Video {
id
url
duration
thumbnail {
url
}
}
}
}
# Inline fragment with type condition
query GetUserWithPosts($userId: ID!) {
user(id: $userId) {
id
name
... on User {
posts {
title
}
}
}
}# Named query (operation name for debugging/caching)
query GetCurrentUser {
me {
id
name
email
role
posts(first: 5) {
edges {
node {
id
title
}
}
}
}
}
# Multiple named queries in one document
query GetDashboardData($userId: ID!) {
user(id: $userId) {
...UserFields
}
posts(first: 10) {
edges {
node {
...PostSummary
}
}
}
trending: posts(sortBy: "likes", sortDir: DESC, first: 5) {
edges {
node {
title
likes
}
}
}
}
fragment UserFields on User {
id
name
email
avatar
}
fragment PostSummary on Post {
id
title
likes
createdAt
}# Deeply nested query
query GetPostDetails($postId: ID!) {
post(id: $postId) {
id
title
content
author {
id
name
email
profile {
avatar
location
website
}
posts(first: 3) {
edges {
node {
id
title
}
}
}
}
comments(first: 20) {
edges {
node {
id
text
createdAt
author {
id
name
avatar
}
replies {
edges {
node {
id
text
author {
name
}
}
}
}
}
}
}
}
}
# List queries with filtering
query GetUsers($role: Role, $search: String, $limit: Int = 20) {
users(
filter: { role: $role, search: $search }
first: $limit
) {
edges {
node {
id
name
email
role
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}GraphQL supports offset-based, cursor-based (Relay spec), and slice-based pagination.
# ── Cursor-based pagination (Relay spec) ──────
query GetPosts($first: Int, $after: String) {
posts(first: $first, after: $after) {
edges {
cursor
node {
id
title
createdAt
author { name }
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
# ── Offset-based pagination ────────────────────
query GetPostsOffset($limit: Int = 20, $offset: Int = 0) {
posts(limit: $limit, offset: $offset) {
id
title
createdAt
}
_postsMeta(limit: $limit, offset: $offset) {
totalCount
hasNextPage
}
}
# ── Slice-based pagination (first/last/before/after)
query GetPostsSlice($first: Int, $last: Int, $before: String, $after: String) {
posts(first: $first, last: $last, before: $before, after: $after) {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}query ConditionalFields(
$isLoggedIn: Boolean!
$showMetrics: Boolean!
$skipAvatar: Boolean!
) {
user(id: "usr_1") {
id
name
avatar @skip(if: $skipAvatar)
email @include(if: $isLoggedIn)
# Private data only for authenticated users
privateData @include(if: $isLoggedIn) {
phone
address {
city
country
}
}
# Analytics metrics (optional heavy computation)
metrics @include(if: $showMetrics) {
postCount
followerCount
followingCount
engagementRate
}
}
}Introspection lets you query the GraphQL schema itself — perfect for tooling, documentation generators, and IDE autocomplete.
# Query the full schema
query IntrospectSchema {
__schema {
types {
name
kind
description
fields {
name
description
type {
name
kind
ofType { name kind }
}
args {
name
type {
name
kind
ofType { name kind }
}
defaultValue
}
}
}
directives {
name
description
locations
args { name type { name } }
}
queryType { name }
mutationType { name }
subscriptionType { name }
}
}
# Query a specific type
query IntrospectUserType {
__type(name: "User") {
name
description
fields {
name
description
type { name kind ofType { name } }
isDeprecated
deprecationReason
}
interfaces {
name
}
}
}
# Query enum values
query IntrospectEnum {
__type(name: "Role") {
name
kind
enumValues {
name
description
isDeprecated
deprecationReason
}
}
}# ── Input types for mutations ────────────────
input CreatePostInput {
title: String!
content: String!
tags: [String!] = []
status: PostStatus = DRAFT
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
status: PostStatus
}
input CreateCommentInput {
postId: ID!
text: String!
parentId: ID
}
# ── Payload types (response shapes) ────────────
type CreatePostPayload {
post: Post!
success: Boolean!
message: String
}
type UpdatePostPayload {
post: Post
success: Boolean!
errors: [String!]
}
type DeleteUserPayload {
userId: ID!
success: Boolean!
}
# ── Mutation root ──────────────────────────────
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
deletePost(id: ID!): DeletePostPayload!
createComment(input: CreateCommentInput!): Comment!
toggleLike(postId: ID!): ToggleLikePayload!
login(email: Email!, password: String!): AuthPayload!
logout: Boolean!
}# Basic mutation
mutation CreatePost {
createPost(input: {
title: "My First Post"
content: "Hello, GraphQL world!"
tags: ["graphql", "intro"]
status: PUBLISHED
}) {
post {
id
title
content
status
createdAt
author {
name
}
}
success
message
}
}
# Mutation with variables
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
user {
id
name
email
role
}
token
}
}
# Multiple mutations (executed sequentially!)
mutation UpdateProfile($userId: ID!, $userInput: UpdateUserInput!, $bio: String!) {
updateUser(id: $userId, input: $userInput) {
user {
id
name
bio
}
success
}
createPost(input: {
title: "Updated my profile"
content: $bio
tags: ["update"]
status: PUBLISHED
}) {
post { id }
success
}
}Optimistic updates immediately update the UI before the server responds, then roll back on error.
import { useMutation, useQueryClient } from '@apollo/client';
const TOGGLE_LIKE = gql`
mutation ToggleLike($postId: ID!) {
toggleLike(postId: $postId) {
post {
id
likes
}
liked
}
}
`;
function LikeButton({ postId, currentLikes, isLiked }) {
const queryClient = useQueryClient();
const [toggleLike] = useMutation(TOGGLE_LIKE, {
variables: { postId },
// Optimistic response
optimisticResponse: {
toggleLike: {
__typename: 'ToggleLikePayload',
post: {
__typename: 'Post',
id: postId,
likes: isLiked ? currentLikes - 1 : currentLikes + 1,
},
liked: !isLiked,
},
},
// Update cache after mutation
update(cache, { data: { toggleLike } }) {
cache.modify({
id: cache.identify({ __typename: 'Post', id: postId }),
fields: {
likes(existingLikes) {
return toggleLike.post.likes;
},
},
});
},
// Rollback on error
onError(error) {
console.error('Like toggle failed:', error.message);
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
return (
<button onClick={() => toggleLike()}>
{isLiked ? '❤️' : '🤍'} {currentLikes}
</button>
);
}GraphQL returns partial data on errors. The errors array is separate from data.
{
"data": {
"createPost": null
},
"errors": [
{
"message": "Title must be at least 5 characters long.",
"locations": [{ "line": 2, "column": 3 }],
"path": ["createPost"],
"extensions": {
"code": "BAD_USER_INPUT",
"validationErrors": [
{
"field": "input.title",
"message": "String must contain at least 5 character(s)"
}
]
}
}
]
}import { ApolloError } from '@apollo/client';
const [createPost, { loading, error }] = useMutation(CREATE_POST);
async function handleSubmit() {
try {
const { data } = await createPost({
variables: { input: { title: 'Hi', content: '' } },
});
if (data?.createPost?.success) {
showToast('Post created!');
}
} catch (err) {
if (err instanceof ApolloError) {
// GraphQL errors
for (const graphQLErr of err.graphQLErrors) {
const code = graphQLErr.extensions?.code;
if (code === 'BAD_USER_INPUT') {
// Handle validation errors
const validationErrors = graphQLErr.extensions?.validationErrors;
validationErrors?.forEach((ve) => {
console.error(`${ve.field}: ${ve.message}`);
});
} else if (code === 'UNAUTHENTICATED') {
redirectToLogin();
} else if (code === 'FORBIDDEN') {
showAccessDenied();
}
}
// Network errors
if (err.networkError) {
showToast('Network error. Please try again.');
}
}
}
}// Custom validation on the server (Apollo Server)
import { GraphQLError } from 'graphql';
const resolvers = {
Mutation: {
createPost: async (_, { input }, context) => {
// Validate input
if (input.title.length < 5) {
throw new GraphQLError('Title must be at least 5 characters', {
extensions: {
code: 'BAD_USER_INPUT',
validationErrors: [{
field: 'input.title',
message: 'String must contain at least 5 character(s)',
}],
},
});
}
if (input.content.length > 10000) {
throw new GraphQLError('Content exceeds maximum length', {
extensions: {
code: 'BAD_USER_INPUT',
validationErrors: [{
field: 'input.content',
message: 'Content must be at most 10000 characters',
}],
},
});
}
if (input.tags.length > 10) {
throw new GraphQLError('Maximum 10 tags allowed', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
// Auth check
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Proceed with creation
const post = await context.dataSources.posts.create({
...input,
authorId: context.user.id,
});
return { post, success: true };
},
},
};# ── Subscription root type ────────────────────
type Subscription {
"Real-time: notified when a new post is created"
postCreated(filter: PostCreatedFilter): Post!
"Real-time: notified when a comment is added"
commentAdded(postId: ID!): Comment!
"Real-time: notified on like toggles"
likeToggled(postId: ID!): LikeEvent!
"Real-time: user presence / online status"
userOnline(userId: ID!): UserPresenceEvent!
"Real-time: notification feed"
notification(userId: ID!): Notification!
}
# ── Supporting types ───────────────────────────
input PostCreatedFilter {
authorId: ID
tags: [String!]
}
type LikeEvent {
postId: ID!
userId: ID!
liked: Boolean!
totalLikes: Int!
timestamp: DateTime!
}
type UserPresenceEvent {
userId: ID!
isOnline: Boolean!
lastSeen: DateTime!
}
type Notification {
id: ID!
type: NotificationType!
message: String!
createdAt: DateTime!
read: Boolean!
}
enum NotificationType {
NEW_COMMENT
NEW_LIKE
NEW_FOLLOWER
MENTION
}import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
// Create WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(createClient({
url: 'wss://api.example.com/graphql',
// Connection params (sent on connect)
connectionParams: () => ({
authToken: localStorage.getItem('token'),
}),
// Lazy connect (only on first subscription)
lazy: true,
// Reconnect on disconnect
retryAttempts: 5,
shouldRetry: () => true,
}));
// HTTP link for queries/mutations
const httpLink = new HttpLink({
uri: 'https://api.example.com/graphql',
headers: {
authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
// Split transport: queries/mutations over HTTP, subscriptions over WS
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
// Create Apollo Client with split transport
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
// ── Using useSubscription hook ────────────────
import { useSubscription } from '@apollo/client';
const POST_CREATED = gql`
subscription OnPostCreated($authorId: ID) {
postCreated(filter: { authorId: $authorId }) {
id
title
content
author { id name }
createdAt
}
}
`;
function NewPostsFeed({ authorId }) {
const { data, loading, error } = useSubscription(POST_CREATED, {
variables: { authorId },
});
if (loading) return <p>Connecting...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h3>New Post!</h3>
<p>{data?.postCreated?.title}</p>
</div>
);
}import { Client, subscriptionExchange } from 'urql';
import { createClient as createWSClient } from 'graphql-ws';
// WebSocket subscription client
const wsClient = createWSClient({
url: 'wss://api.example.com/graphql',
});
// urql client with subscription exchange
const client = new Client({
url: 'https://api.example.com/graphql',
exchanges: [
subscriptionExchange({
forwardSubscription(operation) {
return {
subscribe(sink) {
const dispose = wsClient.subscribe(operation, sink);
return { unsubscribe: dispose };
},
};
},
}),
// ... other exchanges (cache, fetch, dedup, etc.)
],
});
// ── Using urql subscription hook ──────────────
import { useSubscription } from 'urql';
const NEW_COMMENTS = `
subscription($postId: ID!) {
commentAdded(postId: $postId) {
id
text
author { name avatar }
createdAt
}
}
`;
function CommentStream({ postId }) {
const [result] = useSubscription({
query: NEW_COMMENTS,
variables: { postId },
});
if (result.error) return <p>Error: {result.error.message}</p>;
const comment = result.data?.commentAdded;
if (!comment) return null;
return (
<div className="comment">
<img src={comment.author.avatar} alt={comment.author.name} />
<strong>{comment.author.name}</strong>
<p>{comment.text}</p>
<time>{comment.createdAt}</time>
</div>
);
}// Server-side subscription resolver (Apollo Server)
const resolvers = {
Subscription: {
postCreated: {
// subscribe: set up the async iterator
subscribe: (_, args, context) => {
// Auth check
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Optional: filter by authorId
if (args.filter?.authorId) {
return context.pubSub.asyncIterator(
['POST_CREATED'],
(payload) => {
return payload.postCreated.author.id === args.filter.authorId;
}
);
}
return context.pubSub.asyncIterator('POST_CREATED');
},
// resolve: transform the published event
resolve: (payload, args, context, info) => {
return payload.postCreated;
},
},
commentAdded: {
subscribe: (_, { postId }, context) => {
return context.pubSub.asyncIterator(
`COMMENT_ADDED_${postId}`
);
},
resolve: (payload) => payload,
},
},
};
// Publishing events from resolvers
const Mutation = {
createPost: async (_, { input }, context) => {
const post = await context.dataSources.posts.create(input);
// Publish to subscription channel
context.pubSub.publish('POST_CREATED', { postCreated: post });
return { post, success: true };
},
createComment: async (_, { input }, context) => {
const comment = await context.dataSources.comments.create(input);
// Publish to post-specific channel
context.pubSub.publish(
`COMMENT_ADDED_${input.postId}`,
{ commentAdded: comment }
);
return comment;
},
};
// ── PubSub setup (Apollo Server) ──────────────
import { GraphQLServer } from '@graphql-yoga/node';
import { createPubSub } from 'graphql-yoga';
const pubSub = createPubSub();
const server = new GraphQLServer({
schema,
context: () => ({ pubSub, user: null }),
});
server.start();| Criteria | Subscriptions | Polling |
|---|---|---|
| Latency | Instant Push-based | Delayed Interval-based |
| Server load | Persistent connections (WS) | Repeated HTTP requests |
| Client complexity | WebSocket management | Simple setInterval |
| Battery usage | Moderate (keepalive) | Higher (repeated requests) |
| Offline support | Requires reconnect logic | Works if data cached |
| Use case | Chat, live feeds, presence | Dashboard refresh, stock tickers |
| Scalability | Connection-per-client | Request-per-interval |
| Browser support | All modern browsers | All browsers |
Every field in a GraphQL schema is backed by a resolver function. If no resolver is provided, a default field resolver looks up the property on parent.
// ── Resolver signature ──────────────────────
// fieldName(parent, args, context, info) => result
// parent: the return value of the parent resolver
// args: the arguments passed to the field in the query
// context: shared object (auth, dataSources, services)
// info: AST information about the query (rarely needed)
import { GraphQLResolveInfo } from 'graphql';
interface Context {
user: { id: string; role: string } | null;
dataSources: {
users: UserDataSource;
posts: PostDataSource;
comments: CommentDataSource;
};
prisma: PrismaClient;
}
// ── Query resolvers ───────────────────────────
const resolvers = {
Query: {
// Root resolver: resolve from scratch
user: async (
_parent: unknown,
args: { id: string },
context: Context,
info: GraphQLResolveInfo
) => {
return context.dataSources.users.findById(args.id);
},
users: async (_parent, args, context) => {
const { filter, first, after } = args;
return context.dataSources.users.findMany({
filter,
first: first ?? 20,
after,
});
},
post: async (_parent, { id }, context) => {
return context.dataSources.posts.findById(id);
},
me: async (_parent, _args, context) => {
if (!context.user) return null;
return context.dataSources.users.findById(context.user.id);
},
},
// ── Type resolvers ───────────────────────────
User: {
// Resolve computed fields
posts: async (parent, args, context) => {
return context.dataSources.posts.findByAuthorId(
parent.id,
args.limit ?? 10,
args.offset ?? 0
);
},
postCount: async (parent, _args, context) => {
return context.dataSources.posts.countByAuthorId(parent.id);
},
profile: async (parent, _args, context) => {
return context.dataSources.users.findProfile(parent.id);
},
},
Post: {
// Resolve relation from parent
author: async (parent, _args, context) => {
return context.dataSources.users.findById(parent.authorId);
},
comments: async (parent, args, context) => {
return context.dataSources.comments.findByPostId(
parent.id,
args.limit ?? 20
);
},
},
// ── Union / Interface resolvers ──────────────
SearchResult: {
__resolveType(obj, context, info) {
if (obj.username) return 'User';
if (obj.title && obj.content) return 'Post';
if (obj.width && obj.height) return 'Image';
if (obj.duration) return 'Video';
return null;
},
},
Node: {
__resolveType(obj) {
if (obj.__typename) return obj.__typename;
// Fallback based on properties
if (obj.title && obj.content) return 'Post';
if (obj.email) return 'User';
return null;
},
},
};
export default resolvers;import { PrismaClient } from '@prisma/client';
import { UserDataSource } from './dataSources/UserDataSource';
import { PostDataSource } from './dataSources/PostDataSource';
import { verifyToken } from './utils/auth';
const prisma = new PrismaClient();
export interface AppContext {
user: { id: string; role: string; email: string } | null;
dataSources: {
users: UserDataSource;
posts: PostDataSource;
comments: CommentDataSource;
};
prisma: PrismaClient;
req: Request;
}
// Context factory for Apollo Server
export async function getContext({ req }: { req: Request }): Promise<AppContext> {
// Extract token from headers
const authHeader = req.headers.authorization || '';
const token = authHeader.replace('Bearer ', '');
let user = null;
if (token) {
try {
user = await verifyToken(token);
} catch {
// Invalid token — user stays null
}
}
return {
user,
dataSources: {
users: new UserDataSource(prisma),
posts: new PostDataSource(prisma),
comments: new CommentDataSource(prisma),
},
prisma,
req,
};
}
// Apollo Server setup with context
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const server = new ApolloServer<AppContext>({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: getContext,
listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);// Encapsulate data fetching in data source classes
export class UserDataSource {
constructor(private prisma: PrismaClient) {}
async findById(id: string) {
return this.prisma.user.findUnique({
where: { id },
include: { profile: true },
});
}
async findByEmail(email: string) {
return this.prisma.user.findUnique({
where: { email },
});
}
async findMany(options: {
filter?: Record<string, unknown>;
first?: number;
after?: string;
}) {
const { filter, first = 20, after } = options;
const where: any = {};
if (filter?.role) where.role = filter.role;
if (filter?.search) {
where.OR = [
{ name: { contains: filter.search, mode: 'insensitive' } },
{ email: { contains: filter.search, mode: 'insensitive' } },
];
}
const cursor = after ? { id: after } : undefined;
const [items, totalCount] = await Promise.all([
this.prisma.user.findMany({
where,
take: first + 1,
cursor,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
const hasNextPage = items.length > first;
if (hasNextPage) items.pop();
return {
edges: items.map((user) => ({
cursor: user.id,
node: user,
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: items[0]?.id ?? null,
endCursor: items[items.length - 1]?.id ?? null,
},
totalCount,
};
}
async create(data: { name: string; email: string; role?: string }) {
return this.prisma.user.create({
data: {
name: data.name,
email: data.email,
role: data.role ?? 'MEMBER',
},
});
}
}The N+1 problem occurs when resolving a list of items triggers one query per item for related data. DataLoader batches and caches these requests.
// ❌ BAD: N+1 problem — one query per post for author
Post: {
author: async (parent, _args, context) => {
// This fires once per post (N queries!)
return context.prisma.user.findUnique({
where: { id: parent.authorId },
});
},
},
// Query: posts(first: 100) → 1 query for posts + 100 queries for authors = 101 queries!import DataLoader from 'dataloader';
// ✅ GOOD: DataLoader batches all author lookups
function createLoaders(prisma: PrismaClient) {
return {
userLoader: new DataLoader<string, any>(async (userIds) => {
// Single batched query
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
});
// Map results back to original order
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) ?? null);
}),
commentCountLoader: new DataLoader<string, number>(
async (postIds) => {
const counts = await prisma.comment.groupBy({
by: ['postId'],
where: { postId: { in: postIds } },
_count: true,
});
const countMap = new Map(
counts.map((c) => [c.postId, c._count])
);
return postIds.map((id) => countMap.get(id) ?? 0);
}
),
};
}
// Use in context
export async function getContext({ req }) {
const loaders = createLoaders(prisma);
return {
user: await verifyToken(token),
loaders,
prisma,
};
}
// Resolver using DataLoader
Post: {
author: async (parent, _args, context) => {
// DataLoader batches all authorId lookups into ONE query
return context.loaders.userLoader.load(parent.authorId);
},
commentCount: async (parent, _args, context) => {
return context.loaders.commentCountLoader.load(parent.id);
},
},
// Now: posts(first: 100) → 1 query for posts + 1 batched query for authors = 2 queries!// ── Resolver middleware pattern ─────────────
type Resolver = (
parent: any,
args: any,
context: Context,
info: GraphQLResolveInfo
) => Promise<any>;
function withAuth(resolver: Resolver): Resolver {
return async (parent, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return resolver(parent, args, context, info);
};
}
function withRole(requiredRole: string) {
return (resolver: Resolver): Resolver => {
return async (parent, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
if (context.user.role !== requiredRole) {
throw new GraphQLError(`Requires ${requiredRole} role`, {
extensions: { code: 'FORBIDDEN' },
});
}
return resolver(parent, args, context, info);
};
};
}
function withLogging(resolver: Resolver): Resolver {
return async (parent, args, context, info) => {
const start = Date.now();
const fieldName = info.fieldName;
try {
const result = await resolver(parent, args, context, info);
console.log(`[${fieldName}] ${Date.now() - start}ms`);
return result;
} catch (error) {
console.error(`[${fieldName}] FAILED: ${error}`);
throw error;
}
};
}
// Compose middleware
const Mutation = {
createPost: withAuth(withLogging(async (_, { input }, context) => {
return context.dataSources.posts.create({
...input,
authorId: context.user.id,
});
})),
deleteUser: withRole('ADMIN')(async (_, { id }, context) => {
return context.dataSources.users.delete(id);
}),
};import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_, { id }, context) => {
const user = await context.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
},
});
}
return user;
},
},
Mutation: {
createUser: async (_, { input }, context) => {
try {
const user = await context.prisma.user.create({
data: input,
});
return { user, success: true };
} catch (error: any) {
if (error.code === 'P2002') {
// Prisma unique constraint violation
const field = error.meta?.target?.[0] ?? 'field';
throw new GraphQLError(`${field} already exists`, {
extensions: {
code: 'CONFLICT',
field,
http: { status: 409 },
},
});
}
throw new GraphQLError('Internal server error', {
extensions: {
code: 'INTERNAL_SERVER_ERROR',
http: { status: 500 },
},
});
}
},
},
};
// ── Custom error format (Apollo Server) ───────
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Log full error server-side
console.error('GraphQL Error:', error);
// Hide internal details from clients
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return {
...formattedError,
message: 'An unexpected error occurred',
};
}
return formattedError;
},
});Apollo Federation splits a monolithic graph across multiple services. Each service owns a slice of the schema.
# ── Products subgraph ────────────────────────
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.6",
import: ["@key", "@shareable", "@extends", "@external"])
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
description: String!
"In-stock quantity"
inStock: Int!
"Product belongs to a category"
category: Category!
}
type Category @key(fields: "id") {
id: ID!
name: String!
products: [Product!]!
}
type Query {
products(first: Int = 20): [Product!]!
product(id: ID!): Product
categories: [Category!]!
}# ── Users subgraph ───────────────────────────
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.6",
import: ["@key", "@shareable", "@extends", "@external"])
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
role: Role!
# Extend with review count from reviews subgraph
reviewCount: Int! @external
}
enum Role {
ADMIN
MEMBER
}
type Query {
users: [User!]!
user(id: ID!): User
}# ── Reviews subgraph ─────────────────────────
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.6",
import: ["@key", "@shareable", "@extends", "@external", "@requires"])
type Review @key(fields: "id") {
id: ID!
rating: Int!
text: String!
author: User! @provides(fields: "name")
product: Product!
createdAt: DateTime!
}
extend type User @key(fields: "id") {
id: ID! @external
name: String! @external
reviewCount: Int!
}
extend type Product @key(fields: "id") {
id: ID! @external
reviews: [Review!]!
avgRating: Float!
}| Directive | Location | Purpose |
|---|---|---|
@key(fields: "id") | OBJECT | Defines the entity primary key |
@extends | OBJECT | Extends an entity from another subgraph |
@external | FIELD_DEFINITION | Field is defined in another subgraph |
@requires(fields: "...") | FIELD_DEFINITION | Needs external fields to resolve |
@provides(fields: "...") | FIELD_DEFINITION | Guarantees fields are resolved |
@shareable | FIELD_DEFINITION, OBJECT | Field/value shared across subgraphs |
@link(url: "...") | SCHEMA | Import federation schema features |
// ── Authentication via context ──────────────
export async function createContext({ req, res }): Promise<AppContext> {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
user = await verifyJwt(token);
} catch {
// Token invalid or expired
}
}
return {
user,
prisma,
req,
res,
};
}
// ── Authorization directive (schema-level) ────
import { makeExecutableSchema } from '@graphql-tools/schema';
const authDirectiveTransformer = (schema) =>
AuthDirective(schema, {
resolve: (directiveArgs, next, source, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const { requires } = directiveArgs;
if (requires && context.user.role !== requires) {
throw new GraphQLError('Insufficient permissions', {
extensions: { code: 'FORBIDDEN' },
});
}
return next(source, args, context, info);
},
});
// Schema with auth directive
const typeDefs = gql`
directive @auth(requires: Role = MEMBER) on FIELD_DEFINITION
type Query {
publicPosts: [Post!]!
myPosts: [Post!]! @auth
adminDashboard: Dashboard! @auth(requires: ADMIN)
allUsers: [User!]! @auth(requires: ADMIN)
}
`;
// ── Row-level authorization in resolver ───────
const resolvers = {
Query: {
post: async (_, { id }, context) => {
const post = await context.prisma.post.findUnique({
where: { id },
});
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
// Only allow viewing published posts or own posts
if (
post.status !== 'PUBLISHED' &&
post.authorId !== context.user?.id
) {
throw new GraphQLError('Access denied', {
extensions: { code: 'FORBIDDEN' },
});
}
return post;
},
},
};# ── Schema-level cache hints ────────────────
directive @cacheControl(
maxAge: Int
scope: CacheScope = PUBLIC
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
enum CacheScope {
PUBLIC
PRIVATE
}
type Query {
# Public, cacheable for 60 seconds
posts(first: Int = 20): [Post!]!
@cacheControl(maxAge: 60)
# Private, cache per user for 30 seconds
me: User! @cacheControl(maxAge: 30, scope: PRIVATE)
# Never cache
liveStats: Stats! @cacheControl(maxAge: 0)
}
# Object-level default cache hint
type Post @cacheControl(maxAge: 120) {
id: ID! @cacheControl(maxAge: 86400)
title: String!
content: String!
# Dynamic field — override parent hint
views: Int! @cacheControl(maxAge: 5)
createdAt: DateTime! @cacheControl(maxAge: 86400)
author: User! @cacheControl(maxAge: 300)
}
type User @cacheControl(maxAge: 300) {
id: ID!
name: String!
email: String! @cacheControl(maxAge: 0, scope: PRIVATE)
}import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import { ApolloServerPluginResponseCache } from '@apollo/server/plugin/responseCache';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginCacheControl({ defaultMaxAge: 60 }),
ApolloServerPluginResponseCache({
cache: new InMemoryLRUCache({
maxSize: 1000,
}),
sessionId: (requestContext) => {
// Return user ID for private caching, null for public
return requestContext.contextValue.user?.id;
},
}),
],
cache: new InMemoryLRUCache(),
});import {
createComplexityLimitRule,
FieldExtensions,
} from 'graphql-query-complexity';
// ── Query complexity analysis ─────────────────
// Assign cost to fields
const resolvers = {
Query: {
user: {
complexity: (args, childComplexity) => childComplexity + 1,
resolve: (_, { id }, context) => {
return context.dataSources.users.findById(id);
},
},
users: {
complexity: (args, childComplexity) => {
// Cost = number of items × cost per item
return args.first * childComplexity + 1;
},
resolve: (_, args, context) => {
return context.dataSources.users.findMany(args);
},
},
posts: {
complexity: (args, childComplexity) => {
return args.first * childComplexity + 1;
},
},
},
User: {
posts: {
complexity: (args, childComplexity) => {
return args.limit * childComplexity;
},
},
},
};
// Apply rule to Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query cost:', cost),
scalarCost: 1,
objectCost: 5,
listFactor: 10,
}),
],
});
// ── Persisted queries (Apollo) ───────────────
import { ApolloServerPluginPersistedQueries } from '@apollo/server/plugin/persistedQueries';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginPersistedQueries({
cache: new InMemoryLRUCache(),
}),
],
});
// Client sends hash instead of full query
// POST /graphql
// { "extensions": { "persistedQuery": { "sha256Hash": "abc123..." } } }
// Server responds with full query if hash matches, or error if not foundimport rateLimit from 'express-rate-limit';
import { GraphQLError } from 'graphql';
// ── HTTP-level rate limiting ──────────────────
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: {
errors: [{
message: 'Too many requests, please try again later.',
extensions: { code: 'RATE_LIMITED' },
}],
},
standardHeaders: true,
legacyHeaders: false,
});
app.use('/graphql', limiter);
// ── Query-level rate limiting ─────────────────
import { createRateLimitDirective } from 'graphql-rate-limit-directive';
import { RedisStore } from 'rate-limit-redis';
const RateLimitDirective = createRateLimitDirective({
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
});
const server = new ApolloServer({
typeDefs,
resolvers,
});
// Schema directive usage:
// type Mutation {
// login(email: Email!, password: String!): AuthPayload!
// @rateLimit(limit: 5, window: "1m")
// }import { SchemaDirectiveVisitor } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLField } from 'graphql';
// ── @upper directive ──────────────────────────
class UpperDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field: GraphQLField<any, any>) {
const { resolve = defaultFieldResolver } = field;
field.resolve = async function (...args) {
const result = await resolve.apply(this, args);
if (typeof result === 'string') {
return result.toUpperCase();
}
return result;
};
}
}
// ── @length directive (validation) ────────────
class LengthDirective extends SchemaDirectiveVisitor {
visitInputFieldDefinition(field) {
const { min, max } = this.args;
// Validation happens in middleware / resolver layer
field.extensions = {
...field.extensions,
validation: { type: 'length', min, max },
};
}
}
// ── @auth directive ───────────────────────────
class AuthDirective extends SchemaDirectiveVisitor {
visitObject(type) {
const fields = type.getFields();
Object.keys(fields).forEach((fieldName) => {
// Apply auth to all fields in this type
});
}
visitFieldDefinition(field) {
const { requires } = this.args;
const { resolve = defaultFieldResolver } = field;
field.resolve = async function (source, args, context, info) {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
if (requires && context.user.role !== requires) {
throw new GraphQLError(`Role ${requires} required`, {
extensions: { code: 'FORBIDDEN' },
});
}
return resolve.call(this, source, args, context, info);
};
}
}
// Register directives
const server = new ApolloServer({
schema: makeExecutableSchema({
typeDefs,
resolvers,
directiveResolvers: {
upper: UpperDirective,
length: LengthDirective,
auth: AuthDirective,
},
}),
});import { describe, it, expect, vi, beforeEach } from 'vitest';
import userResolvers from '../resolvers/userResolvers';
describe('User Resolvers', () => {
const mockContext = {
user: { id: 'usr_1', role: 'MEMBER', email: 'test@example.com' },
prisma: {
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
loaders: {
userLoader: { load: vi.fn() },
},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Query.user', () => {
it('should return a user by ID', async () => {
const mockUser = {
id: 'usr_1',
name: 'John Doe',
email: 'john@example.com',
role: 'MEMBER',
};
mockContext.prisma.user.findUnique.mockResolvedValue(mockUser);
const result = await userResolvers.Query.user(
{}, { id: 'usr_1' }, mockContext
);
expect(result).toEqual(mockUser);
expect(mockContext.prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'usr_1' },
});
});
it('should throw NOT_FOUND if user does not exist', async () => {
mockContext.prisma.user.findUnique.mockResolvedValue(null);
await expect(
userResolvers.Query.user({}, { id: 'usr_999' }, mockContext)
).rejects.toThrow('User not found');
});
});
describe('Query.users', () => {
it('should return paginated users with default options', async () => {
const mockUsers = [
{ id: 'usr_1', name: 'Alice' },
{ id: 'usr_2', name: 'Bob' },
];
mockContext.prisma.user.findMany.mockResolvedValue(mockUsers);
mockContext.prisma.user.count.mockResolvedValue(2);
const result = await userResolvers.Query.users(
{}, {}, mockContext
);
expect(result.edges).toHaveLength(2);
expect(result.totalCount).toBe(2);
});
});
describe('Mutation.createUser', () => {
it('should create a new user and return it', async () => {
const input = { name: 'Jane', email: 'jane@example.com' };
const mockCreated = { id: 'usr_3', ...input, role: 'MEMBER' };
mockContext.prisma.user.create.mockResolvedValue(mockCreated);
const result = await userResolvers.Mutation.createUser(
{}, { input }, mockContext
);
expect(result.user).toEqual(mockCreated);
expect(mockContext.prisma.user.create).toHaveBeenCalledWith({
data: input,
});
});
});
});import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from '../schema';
import { resolvers } from '../resolvers';
import { createContext } from '../context';
describe('GraphQL API Integration', () => {
let url: string;
let testServer: any;
beforeAll(async () => {
const server = new ApolloServer({
typeDefs,
resolvers,
});
testServer = await startStandaloneServer(server, {
context: async () => ({
user: { id: 'usr_1', role: 'ADMIN' },
prisma: testPrisma,
}),
});
url = testServer.url;
});
afterAll(async () => {
await testServer?.stop();
});
describe('POST /graphql', () => {
it('should fetch a user by ID', async () => {
const response = await request(url)
.post('/')
.send({
query: `{
user(id: "usr_1") {
id
name
email
role
}
}`,
})
.expect(200);
const { data, errors } = response.body;
expect(errors).toBeUndefined();
expect(data.user).toMatchObject({
id: 'usr_1',
name: 'Test User',
email: 'test@example.com',
});
});
it('should create a post', async () => {
const response = await request(url)
.post('/')
.send({
query: `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post { id title status }
success
message
}
}
`,
variables: {
input: {
title: 'Integration Test Post',
content: 'Test content for integration testing.',
tags: ['test'],
status: 'PUBLISHED',
},
},
})
.expect(200);
const { data } = response.body;
expect(data.createPost.success).toBe(true);
expect(data.createPost.post.title).toBe('Integration Test Post');
});
it('should return error for invalid input', async () => {
const response = await request(url)
.post('/')
.send({
query: `
mutation {
createPost(input: {
title: "Hi"
content: ""
}) {
success
}
}
`,
})
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(
'BAD_USER_INPUT'
);
});
it('should return partial data on field error', async () => {
const response = await request(url)
.post('/')
.send({
query: `
query {
users { edges { node { id name } } }
invalidField
}
`,
})
.expect(400);
expect(response.body.errors).toBeDefined();
// users should still resolve
});
});
});import { describe, it, expect, vi } from 'vitest';
import { mockServer } from '@graphql-tools/mock';
import { addMocksToSchema } from '@graphql-tools/mock';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { print } from 'graphql';
// ── Schema mocking ────────────────────────────
const schema = makeExecutableSchema({ typeDefs, resolvers });
const schemaWithMocks = addMocksToSchema({ schema });
// Create a mock server
const testServer = mockServer(schema, {
User: () => ({
id: () => 'mock-user-id',
name: () => 'Mock User',
email: () => 'mock@example.com',
role: () => 'MEMBER',
}),
Post: () => ({
id: () => 'mock-post-id',
title: () => 'Mock Post Title',
content: () => 'Mock post content...',
likes: () => 42,
}),
});
// ── Snapshot test queries ─────────────────────
describe('Query Snapshots', () => {
it('GET_POST_QUERY returns expected shape', async () => {
const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
id
title
content
createdAt
author {
id
name
email
}
comments(first: 5) {
edges {
node {
id
text
author { name }
}
}
}
}
}
`;
const result = await testServer.execute(GET_POST, {
variables: { id: '1' },
});
// Snapshot the response shape
expect(result).toMatchSnapshot();
});
it('SEARCH_QUERY returns SearchResult union shapes', async () => {
const SEARCH = gql`
query Search($query: String!) {
search(query: $query) {
... on User { id name email }
... on Post { id title likes }
... on Image { id url width height }
}
}
`;
const result = await testServer.execute(SEARCH, {
variables: { query: 'test' },
});
expect(result.data.search).toBeDefined();
expect(result.data.search.length).toBeGreaterThan(0);
});
});import { ApolloServer } from '@apollo/server';
import { ApolloGateway } from '@apollo/gateway';
import { typeDefs } from '../schema';
import { resolvers } from '../resolvers';
import { createTestClient } from '@apollo/client-testing';
// ── Helper: create test Apollo Server ─────────
export function createTestServer(overrides = {}) {
return new ApolloServer({
typeDefs,
resolvers: {
...resolvers,
...overrides,
},
});
}
// ── Helper: mock context ──────────────────────
export function createMockContext(overrides = {}) {
return {
user: { id: 'test-user', role: 'ADMIN', email: 'test@test.com' },
prisma: {
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
post: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
},
...overrides,
};
}
// ── Helper: execute GraphQL operation ─────────
export async function executeOperation(
server: ApolloServer,
query: string,
variables: Record<string, any> = {},
contextOverrides = {}
) {
const response = await server.executeOperation(
{ query, variables },
{
contextValue: createMockContext(contextOverrides),
}
);
return response;
}
// ── Usage example ─────────────────────────────
describe('Post resolvers (with test utils)', () => {
it('returns posts with pagination', async () => {
const server = createTestServer();
const POSTS_QUERY = `
query {
posts(first: 10) {
edges { cursor node { id title } }
pageInfo { hasNextPage endCursor }
totalCount
}
}
`;
const result = await executeOperation(server, POSTS_QUERY);
expect(result.body.kind).toBe('single');
expect(result.body.singleResult.data?.posts).toBeDefined();
expect(result.body.singleResult.data?.posts.edges).toBeInstanceOf(
Array
);
});
});import { describe, it, expect } from 'vitest';
import { buildSchema, validate, parse } from 'graphql';
import { typeDefs } from '../schema';
describe('Schema Validation', () => {
it('should build a valid schema from type definitions', () => {
// buildSchema throws on invalid SDL
const schema = buildSchema(typeDefs.loc.source.body);
expect(schema).toBeDefined();
// Verify root types exist
expect(schema.getQueryType()?.name).toBe('Query');
expect(schema.getMutationType()?.name).toBe('Mutation');
expect(schema.getSubscriptionType()?.name).toBe('Subscription');
});
it('should validate all queries against the schema', () => {
const schema = buildSchema(typeDefs.loc.source.body);
// Validate a valid query
const validQuery = parse(`
query {
user(id: "usr_1") {
id
name
posts(limit: 5) {
title
}
}
}
`);
const validErrors = validate(schema, validQuery);
expect(validErrors).toHaveLength(0);
// Validate an invalid query (unknown field)
const invalidQuery = parse(`
query {
user(id: "usr_1") {
id
unknownField
}
}
`);
const invalidErrors = validate(schema, invalidQuery);
expect(invalidErrors.length).toBeGreaterThan(0);
expect(invalidErrors[0].message).toContain('unknownField');
});
it('should validate mutation input types', () => {
const schema = buildSchema(typeDefs.loc.source.body);
const mutation = parse(`
mutation {
createUser(input: {
name: "Test"
email: "invalid-email" # Should fail with Email scalar
role: INVALID_ROLE # Should fail enum check
}) {
user { id }
token
}
}
`);
const errors = validate(schema, mutation);
// Note: custom scalar validation happens at runtime,
// but enum values are validated at parse time
expect(errors.some(e => e.message.includes('INVALID_ROLE'))).toBe(true);
});
it('introspection query should succeed', () => {
const schema = buildSchema(typeDefs.loc.source.body);
const introspectionQuery = parse(`
query {
__schema {
types { name kind }
directives { name locations }
}
}
`);
const errors = validate(schema, introspectionQuery);
expect(errors).toHaveLength(0);
});
});GraphQL is a query language for APIs and a runtime for executing those queries against your data. Key differences from REST:
| Feature | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple (one per resource) | Single endpoint (<code >/graphql</code>) |
| Data fetching | Fixed response shape | Client specifies exact fields needed |
| Over/under-fetching | Common problem | Eliminated — client controls the response |
| Versioning | URL versioning (<code >/v1/</code>, <code >/v2/</code>) | Evolve schema — add fields without breaking |
| Error handling | HTTP status codes | Partial data + <code >errors</code> array |
| Type system | OpenAPI/Swagger (optional) | Built-in strong type system (SDL) |
| Caching | HTTP caching built-in | Requires custom caching (cacheControl) |
| Batching | Multiple requests | Single request with nested queries |
When you query a list of items and each item has a relation, the naive resolver makes one query per item:
// ❌ N+1: posts query (1) + N author queries
// For 100 posts = 101 database queries
Post: {
author: (parent, _, context) => {
return context.prisma.user.findUnique({
where: { id: parent.authorId },
});
},
}
// ✅ DataLoader: batches all author lookups into 1 query
// For 100 posts = 2 queries (posts + batched authors)
const userLoader = new DataLoader(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: ids } },
});
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id));
});// fieldName(parent, args, context, info) => result
// 1. parent (or root) — the return value of the previous resolver
// For root Query/Mutation fields, this is typically undefined
// For field resolvers, it's the resolved parent object
// 2. args — the arguments provided in the GraphQL query
// e.g., user(id: "123") → args = { id: "123" }
// 3. context — shared object passed to ALL resolvers
// Contains: authenticated user, database connections,
// data sources, request/response objects, services
// 4. info — AST representation of the query (rarely used directly)
// Contains: field name, field path, return type, fragments
// Example
Query: {
user: async (parent, { id }, context, info) => {
// parent: undefined (root resolver)
// args: { id: "usr_1" }
// context: { user, prisma, loaders, ... }
// info: { fieldName: "user", path: { key: "user" }, ... }
return context.prisma.user.findUnique({ where: { id } });
},
}Queries fetch data (read-only, like GET in REST). Mutations modify data (create, update, delete — like POST/PUT/DELETE). Key distinction: GraphQL guarantees mutations in a single request execute serially in order, while fields in a query execute in parallel. This ensures mutation side effects are predictable.
Mutation root type for writes, even if your server technically allows writes in Query. It communicates intent and enables proper caching and execution semantics.Subscriptions provide real-time updates via a persistent WebSocket connection. The server uses an async iterator (PubSub pattern) to push events to clients.
// Server-side: subscribe + resolve
Subscription: {
postCreated: {
// subscribe returns an AsyncIterator
subscribe: (_, args, { pubSub }) => {
return pubSub.asyncIterator('POST_CREATED');
},
// resolve transforms each event
resolve: (payload) => payload.postCreated,
},
},
// Trigger event from a mutation
Mutation: {
createPost: async (_, { input }, { pubSub, prisma }) => {
const post = await prisma.post.create({ data: input });
pubSub.publish('POST_CREATED', { postCreated: post });
return { post, success: true };
},
}
// Client-side: useSubscription hook
const { data } = useSubscription(gql`
subscription { postCreated { id title } }
`);Apollo Federation splits a single GraphQL schema across multiple services (subgraphs), each owning a domain. A gateway composes them into a unified supergraph.
Unlike REST, GraphQL returns partial data alongside errors. The response has both data and errors at the top level.
// Server: throw GraphQLError with structured extensions
throw new GraphQLError('Validation failed', {
extensions: {
code: 'BAD_USER_INPUT', // Client-facing error code
field: 'input.email', // Which field failed
http: { status: 400 }, // Suggested HTTP status
timestamp: Date.now(), // Extra metadata
},
});
// Client: handle partial data + errors
const { data, error } = await client.mutate({
mutation: CREATE_POST,
variables: { input: { title: 'Hi' } },
});
if (error) {
// error.graphQLErrors — GraphQL errors (server threw)
// error.networkError — HTTP/network errors
// error.partial — true if partial data was returned
for (const e of error.graphQLErrors) {
if (e.extensions.code === 'BAD_USER_INPUT') {
// Show field-level validation error
}
}
}
// Even with errors, data might have partial results:
// { "data": { "user": { "id": "1", "name": null } }, "errors": [...] }# Fragments are reusable field selections
# Benefits: DRY, co-location, composition
fragment UserCard on User {
id
name
avatar
bio
}
# Named fragment — reuse across queries
query Feed {
posts {
author { ...UserCard }
comments {
author { ...UserCard }
}
}
}
# Inline fragment — used with unions/interfaces
query Search {
search(query: "test") {
... on User { id name bio }
... on Post { id title likes }
... on Image { id url width height }
}
}
# Fragment with @skip/@include
fragment AdminFields on User {
id
email @include(if: $isAdmin)
salary @include(if: $isAdmin)
}Three main approaches, each with trade-offs:
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| Offset-based | Simple, jump to page | Skips/duplicates on data changes, slow at high offsets | Admin panels, static data |
| Cursor-based (Relay) | Stable cursors, efficient for large datasets | Cannot jump to arbitrary pages | Infinite scroll, feeds |
| Slice (first/last) | Flexible bidirectional pagination | More complex schema | Bidirectional lists |
# Relay Cursor Connections spec
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
cursor: String! # Opaque cursor (e.g., base64-encoded ID)
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(first: Int, after: String): PostConnection!
postsLast(last: Int, before: String): PostConnection!
}Directives modify the execution or validation behavior of fields, fragments, and schema elements. They are GraphQL's extension mechanism.
Both combine multiple GraphQL schemas, but with fundamentally different architectures:
| Schema Stitching | Apollo Federation | |
|---|---|---|
| Architecture | Merge schemas into one server | Each subgraph is a separate server, gateway composes at query time |
| Runtime | Single process | Multiple services + gateway |
| Type sharing | Manual merge + type merging | Entities with @key directives |
| Team autonomy | Lower — shared server | Higher each team owns their subgraph |
| Deployment | Single deployment | Independent deployments per subgraph |
| Complexity | Simpler setup | More infrastructure (gateway, subgraphs) |
| Maintainer | @graphql-tools/stitch | Apollo Federation v2 |
| Scalability | Limited by single server | Better scales independently |