# Scalar types: String, Int, Float, Boolean, ID
# Custom scalars for domain types
scalar DateTime
scalar Email
scalar URL
type User {
id: ID!
name: String!
email: Email!
avatar: URL
role: UserRole!
posts(first: Int = 10, after: String): PostConnection!
createdAt: DateTime!
}
enum UserRole {
ADMIN
MEMBER
VIEWER
}
# Queries - read operations
type Query {
user(id: ID!): User
users(
first: Int = 20
after: String
filter: UserFilter
orderBy: UserOrderBy = CREATED_AT_DESC
): UserConnection!
me: User! # Current authenticated user
}
# Mutations - write operations
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
# Input types (separate from output types)
input CreateUserInput {
name: String!
email: Email!
role: UserRole = MEMBER
}
input UpdateUserInput {
id: ID!
name: String
email: Email
role: UserRole
}
input UserFilter {
role: UserRole
search: String
createdAfter: DateTime
}
enum UserOrderBy {
CREATED_AT_ASC
CREATED_AT_DESC
NAME_ASC
NAME_DESC
}
Always return a payload type (not the entity directly):
type CreateUserPayload {
user: User!
clientMutationId: String # Relay convention
}
type UpdateUserPayload {
user: User!
}
type DeleteUserPayload {
deletedId: ID!
success: Boolean!
}
# For operations that can partially fail
type BulkDeleteUsersPayload {
deletedIds: [ID!]!
errors: [BulkError!]!
}
type BulkError {
id: ID!
message: String!
code: ErrorCode!
}
type Subscription {
# Simple subscription
orderStatusChanged(orderId: ID!): Order!
# Filtered subscription
newMessage(channelId: ID!): Message!
# With initial state
userPresence(teamId: ID!): PresenceEvent!
}
enum PresenceEventType {
ONLINE
OFFLINE
AWAY
}
type PresenceEvent {
user: User!
type: PresenceEventType!
timestamp: DateTime!
}
const resolvers: Resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.dataSources.users.findById(id);
},
users: async (_, { first, after, filter }, context) => {
return context.dataSources.users.findMany({ first, after, filter });
},
me: async (_, __, context) => {
if (!context.currentUser) {
throw new AuthenticationError("Not authenticated");
}
return context.currentUser;
},
},
Mutation: {
createUser: async (_, { input }, context) => {
const user = await context.dataSources.users.create(input);
return { user };
},
},
// Field-level resolver (runs when field is requested)
User: {
posts: async (parent, { first, after }, context) => {
return context.dataSources.posts.findByUserId(parent.id, { first, after });
},
// Simple field mapping (usually not needed)
email: (parent) => parent.email,
},
};
Without DataLoader:
Query { users(first: 10) { posts { title } } }
# 1 query for users + 10 queries for posts = 11 queries
With DataLoader:
import DataLoader from "dataloader";
// Create per-request DataLoader instances
function createLoaders() {
return {
postsByUserId: new DataLoader<string, Post[]>(async (userIds) => {
// Single batched query: SELECT * FROM posts WHERE user_id IN (...)
const posts = await db.posts.findMany({
where: { userId: { in: [...userIds] } },
});
// Map results back to input order
const postsByUser = new Map<string, Post[]>();
for (const post of posts) {
const existing = postsByUser.get(post.userId) || [];
existing.push(post);
postsByUser.set(post.userId, existing);
}
return userIds.map((id) => postsByUser.get(id) || []);
}),
userById: new DataLoader<string, User | null>(async (ids) => {
const users = await db.users.findMany({
where: { id: { in: [...ids] } },
});
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) || null);
}),
};
}
// In resolver
const resolvers = {
User: {
posts: (parent, args, context) => {
return context.loaders.postsByUserId.load(parent.id);
},
},
Post: {
author: (parent, args, context) => {
return context.loaders.userById.load(parent.authorId);
},
},
};
// Server setup - extract user from token
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const token = req.headers.authorization?.replace("Bearer ", "");
let currentUser = null;
if (token) {
try {
const decoded = await verifyJWT(token);
currentUser = await db.users.findById(decoded.sub);
} catch {
// Invalid token - currentUser remains null
}
}
return {
currentUser,
loaders: createLoaders(),
dataSources: createDataSources(),
};
},
});
Directive-based (schema-level):
directive @auth(requires: UserRole = MEMBER) on FIELD_DEFINITION | OBJECT
type Query {
users: [User!]! @auth(requires: ADMIN)
me: User! @auth
}
type User {
email: Email! @auth(requires: ADMIN) # Only admins see emails
name: String! # Public field
}
// Directive implementation
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field: GraphQLField<any, any>) {
const requiredRole = this.args.requires;
const originalResolve = field.resolve || defaultFieldResolver;
field.resolve = async (parent, args, context, info) => {
if (!context.currentUser) {
throw new AuthenticationError("Authentication required");
}
if (requiredRole && context.currentUser.role !== requiredRole) {
throw new ForbiddenError("Insufficient permissions");
}
return originalResolve(parent, args, context, info);
};
}
}
Resolver-level authorization:
const resolvers = {
Mutation: {
deleteUser: async (_, { id }, context) => {
// Only admins or the user themselves
if (context.currentUser.role !== "ADMIN" && context.currentUser.id !== id) {
throw new ForbiddenError("Cannot delete other users");
}
await context.dataSources.users.delete(id);
return { deletedId: id, success: true };
},
},
};
{
"data": {
"createUser": null
},
"errors": [
{
"message": "Email already exists",
"locations": [{ "line": 2, "column": 3 }],
"path": ["createUser"],
"extensions": {
"code": "CONFLICT",
"field": "email",
"timestamp": "2024-01-15T10:30:00Z"
}
}
]
}
// Custom error classes
class ValidationError extends GraphQLError {
constructor(message: string, field: string) {
super(message, {
extensions: {
code: "VALIDATION_ERROR",
field,
},
});
}
}
class BusinessRuleError extends GraphQLError {
constructor(message: string, rule: string) {
super(message, {
extensions: {
code: "BUSINESS_RULE_VIOLATION",
rule,
},
});
}
}
// Usage in resolvers
const resolvers = {
Mutation: {
createUser: async (_, { input }, context) => {
if (!isValidEmail(input.email)) {
throw new ValidationError("Invalid email format", "email");
}
const existing = await context.dataSources.users.findByEmail(input.email);
if (existing) {
throw new BusinessRuleError("Email already registered", "unique_email");
}
const user = await context.dataSources.users.create(input);
return { user };
},
},
};
type Mutation {
bulkCreateUsers(inputs: [CreateUserInput!]!): BulkCreateResult!
}
type BulkCreateResult {
users: [User!]!
errors: [CreateError!]!
totalRequested: Int!
totalCreated: Int!
}
type CreateError {
index: Int! # Which input failed
message: String!
code: String!
}
type Query {
users(
first: Int # Forward pagination
after: String # Cursor
last: Int # Backward pagination
before: String # Cursor
): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int # Optional - expensive on large datasets
}
type UserEdge {
node: User!
cursor: String! # Opaque cursor for this edge
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
async function connectionFromQuery<T>(
query: SelectQueryBuilder<T>,
args: { first?: number; after?: string; last?: number; before?: string }
): Promise<Connection<T>> {
const limit = args.first || args.last || 20;
const maxLimit = 100;
const effectiveLimit = Math.min(limit, maxLimit);
let afterId: string | null = null;
if (args.after) {
afterId = Buffer.from(args.after, "base64").toString("utf8");
}
if (afterId) {
query = query.where("id > :afterId", { afterId });
}
// Fetch one extra to determine hasNextPage
const items = await query
.orderBy("id", "ASC")
.take(effectiveLimit + 1)
.getMany();
const hasNextPage = items.length > effectiveLimit;
const nodes = hasNextPage ? items.slice(0, effectiveLimit) : items;
const edges = nodes.map((node) => ({
node,
cursor: Buffer.from(node.id).toString("base64"),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!args.after,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
};
}
If Relay connections are overkill:
type Query {
users(limit: Int = 20, offset: Int = 0): UserList!
}
type UserList {
items: [User!]!
total: Int!
hasMore: Boolean!
}
# Define reusable field sets
fragment UserBasic on User {
id
name
avatar
}
fragment UserDetailed on User {
...UserBasic
email
role
createdAt
posts(first: 5) {
edges {
node {
id
title
}
}
}
}
# Use in queries
query {
me {
...UserDetailed
}
users(first: 10) {
edges {
node {
...UserBasic
}
}
}
}
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
name: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node & Timestamped {
id: ID!
title: String!
createdAt: DateTime!
updatedAt: DateTime!
}
# Query any Node by ID
type Query {
node(id: ID!): Node
}
union SearchResult = User | Post | Comment
type Query {
search(query: String!): [SearchResult!]!
}
# Client query with type-specific fields
query {
search(query: "graphql") {
... on User {
id
name
}
... on Post {
id
title
author { name }
}
... on Comment {
id
body
post { title }
}
}
}
// Resolver must include __typename
const resolvers = {
SearchResult: {
__resolveType(obj: any) {
if (obj.email) return "User";
if (obj.title) return "Post";
if (obj.body) return "Comment";
return null;
},
},
};
Split schema across microservices:
# Users service
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
me: User
}
# Orders service - extends User from another service
type User @key(fields: "id") {
id: ID!
orders: [Order!]! # Added by this service
}
type Order @key(fields: "id") {
id: ID!
total: Int!
status: OrderStatus!
user: User!
}
type Query {
order(id: ID!): Order
}
// Orders service resolver
const resolvers = {
User: {
// Reference resolver - how to fetch User stub
__resolveReference(ref: { id: string }, context: Context) {
// Only need to resolve fields this service owns
return { id: ref.id };
},
orders(user: { id: string }, _, context: Context) {
return context.dataSources.orders.findByUserId(user.id);
},
},
};
| Use Federation | Don't Federate |
|---|---|
| Multiple teams own different domains | Single team, single service |
| Independent deployment needed | Monolith or simple microservices |
| Schema > 500 types | Schema < 100 types |
| Different scaling requirements | Uniform load |
Write .graphql files, generate types:
# schema.graphql
type Query {
user(id: ID!): User
}
// Generated types (via graphql-codegen)
export type QueryUserArgs = { id: string };
export type QueryResolvers = {
user?: Resolver<Maybe<User>, {}, Context, QueryUserArgs>;
};
Pros: Schema is the contract, readable, tooling-friendly Cons: Types and schema can drift, boilerplate
Write TypeScript/Go, generate schema:
// Using Pothos (TypeScript)
const builder = new SchemaBuilder<{
Context: Context;
Scalars: { DateTime: { Input: Date; Output: Date } };
}>({});
const UserType = builder.objectRef<User>("User").implement({
fields: (t) => ({
id: t.exposeID("id"),
name: t.exposeString("name"),
email: t.exposeString("email"),
posts: t.field({
type: [PostType],
resolve: (user, _, context) =>
context.loaders.postsByUserId.load(user.id),
}),
}),
});
builder.queryField("user", (t) =>
t.field({
type: UserType,
nullable: true,
args: { id: t.arg.id({ required: true }) },
resolve: (_, { id }, context) =>
context.dataSources.users.findById(id),
})
);
Pros: Single source of truth, type-safe, refactor-friendly Cons: Schema less visible, framework lock-in
import { createComplexityLimitRule } from "graphql-validation-complexity";
const server = new ApolloServer({
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10, // Multiplier for list fields
formatErrorMessage: (cost: number) =>
`Query too complex: cost ${cost} exceeds maximum 1000`,
}),
],
});
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
validationRules: [
depthLimit(7, { ignore: ["__schema"] }), // Max 7 levels deep
],
});
Lock down which queries can execute (production hardening):
// Build step: extract queries from client code
// queries.json
{
"abc123": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
"def456": "query ListUsers($first: Int) { users(first: $first) { edges { node { id name } } } }"
}
// Server: only allow registered queries
const server = new ApolloServer({
persistedQueries: {
cache: new InMemoryLRUCache(),
},
// In production, reject non-persisted queries
allowBatchedHttpRequests: false,
});
# Client sends hash first (saves bandwidth)
POST /graphql
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "abc123hash..."
}
},
"variables": { "id": "user-123" }
}
# Server: "I don't have that hash"
{ "errors": [{ "message": "PersistedQueryNotFound" }] }
# Client retries with full query (cached for future)
POST /graphql
{
"query": "query GetUser($id: ID!) { ... }",
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "abc123hash..."
}
}
}
// Field-level cache hints
const resolvers = {
Query: {
user: (_, { id }, __, info) => {
info.cacheControl.setCacheHint({ maxAge: 60, scope: "PRIVATE" });
return fetchUser(id);
},
products: (_, __, ___, info) => {
info.cacheControl.setCacheHint({ maxAge: 300, scope: "PUBLIC" });
return fetchProducts();
},
},
};
# codegen.yml
schema: "./schema/**/*.graphql"
documents: "./src/**/*.{ts,tsx}"
generates:
./src/generated/types.ts:
plugins:
- typescript
- typescript-resolvers
config:
contextType: "../context#Context"
mappers:
User: "../models#UserModel"
./src/generated/operations.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo # For React hooks
npx graphql-codegen --watch
// Auto-generated hook from codegen
import { useGetUserQuery } from "./generated/operations";
function UserProfile({ id }: { id: string }) {
const [{ data, fetching, error }] = useGetUserQuery({
variables: { id },
});
if (fetching) return <Loading />;
if (error) return <Error error={error} />;
// data.user is fully typed
return <h1>{data.user.name}</h1>;
}
| Anti-Pattern | Problem | Fix |
|---|---|---|
| No DataLoader | N+1 queries tank performance | Always batch with DataLoader |
| No depth/complexity limits | DoS via nested queries | Set limits before production |
| Huge input types | Mutations become dump trucks | Split into focused mutations |
| Business logic in resolvers | Untestable, duplicated | Thin resolvers, service layer |
| No error codes | Clients parse error strings | Use extensions.code |
| Schema-per-team with no coordination | Inconsistent naming, types | Schema governance / federation |
| Exposing DB schema as GraphQL schema | Coupling, security risk | Design for the client, not the DB |