type ProductConnection { edges: [ProductEdge!]! pageInfo: PageInfo! } -- the Relay connection spec is the universal pagination pattern.@deprecated| Pattern | Use Case | Complexity | Benefits | Trade-offs |
|---|---|---|---|---|
| Relay Connections | Paginated lists | Medium | Cursor-based pagination, consistent interface, metadata via edges | More verbose than simple lists; requires Connection/Edge/PageInfo types |
| Input Types | Mutations | Low | Single variable per mutation, extensible, clear contract | Extra type definitions; cannot share with output types |
| Union Types | Polymorphic returns (search, feeds) | Medium | Type-safe multi-type results, exhaustive handling with ... on | Clients must handle every member; no shared fields |
| Interfaces | Shared fields across types (Node, Timestamped) | Medium | Common fields guaranteed, works with fragments | Must be implemented completely; adding fields is breaking |
| Custom Scalars | Domain-specific values (DateTime, URL, Email) | Low | Validation at schema level, self-documenting | Requires parser/serializer on server; clients need codegen |
| Schema Stitching | Combining multiple schemas (legacy) | High | Quick integration of existing APIs | Fragile merge conflicts; replaced by federation for most cases |
| Apollo Federation | Multi-team microservice graph | High | Independent subgraph deployment, entity resolution across services | Operational complexity; requires gateway; cold-start latency |
| Subscriptions | Real-time updates (chat, notifications) | High | Push-based data delivery, native GraphQL | WebSocket infrastructure; connection management at scale |
| @defer / @stream | Incremental delivery of slow fields | Medium | Faster initial response, progressive rendering | Still draft spec (2024); limited server/client support |
| Enum Types | Fixed-set values (status, sort order) | Low | Type-safe constants, auto-documented | Adding values can break exhaustive client switches |
| Nullable by Default | Field evolution and resilience | Low | Safe schema evolution, graceful partial failures | Clients must handle null; requires discipline |
| Payload Types | Mutation responses | Medium | Return errors alongside data, user errors vs system errors | More boilerplate than returning the entity directly |
START
|-- Single team or monolith?
| |-- YES --> Use single GraphQL server (Apollo Server, graphql-yoga, Strawberry)
| | |-- Need pagination? --> Use Relay connection spec
| | |-- Need polymorphism? --> interfaces (shared fields) or unions (disjoint types)
| | |-- Need real-time? --> Add subscriptions (WebSocket) or @defer/@stream
| |-- NO (multiple teams/services) |
| |-- Teams own separate domains? --> Apollo Federation v2 with subgraphs
| |-- Integrating legacy GraphQL APIs? --> Schema stitching (last resort)
|
|-- Schema-first or code-first?
| |-- Team needs shared SDL review (design-first) --> Schema-first (SDL files + codegen)
| |-- Developers prefer type-safe code --> Code-first (TypeGraphQL, Nexus, Strawberry)
|
|-- REST alongside GraphQL?
| |-- YES --> GraphQL as BFF layer over REST microservices (Apollo RESTDataSource)
| |-- NO (pure GraphQL) --> Direct database/service resolvers with DataLoader
|
|-- Relay client?
| |-- YES --> MUST implement Node interface, connection spec, global IDs
| |-- NO --> Connection spec still recommended for pagination; Node interface optional
Start with the core types your clients need. Use descriptive field names and include @deprecated for fields being phased out. [src2]
"""A product in the catalog."""
type Product implements Node {
"""Global Relay ID."""
id: ID!
"""Human-readable unique handle."""
handle: String!
title: String!
description: String
price: Money!
status: ProductStatus!
createdAt: DateTime!
updatedAt: DateTime!
"""Paginated list of product variants."""
variants(first: Int, after: String): VariantConnection!
}
enum ProductStatus {
ACTIVE
DRAFT
ARCHIVED
}
Verify: npx graphql-inspector validate schema.graphql -- should produce no errors.
For any list that can grow, use the connection spec: Connection, Edge, PageInfo. [src3]
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type ProductEdge {
node: Product!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Verify: Query { products(first: 2) { pageInfo { hasNextPage endCursor } edges { cursor node { title } } } } returns valid cursors.
Each mutation takes a single input argument and returns a payload with the result plus user-facing errors. [src4]
input CreateProductInput {
title: String!
description: String
price: MoneyInput!
status: ProductStatus = DRAFT
}
type CreateProductPayload {
product: Product
userErrors: [UserError!]!
}
type Mutation {
createProduct(input: CreateProductInput!): CreateProductPayload!
}
Verify: Mutation with invalid input returns userErrors array, not a GraphQL error.
Use interfaces when types share common fields; use unions when types are disjoint. [src1]
interface Node {
id: ID!
}
union SearchResult = Product | Article | Category
type Query {
node(id: ID!): Node
search(query: String!, first: Int): SearchResultConnection!
}
Verify: { __type(name: "SearchResult") { possibleTypes { name } } } returns all union members.
Every resolver that fetches related data must use DataLoader to batch requests within a single tick. [src6]
import DataLoader from 'dataloader';
const createLoaders = () => ({
productById: new DataLoader(async (ids) => {
const products = await db.products.findByIds(ids);
const map = new Map(products.map(p => [p.id, p]));
return ids.map(id => map.get(id) || null);
}),
});
const resolvers = {
OrderItem: {
product: (item, _, { loaders }) =>
loaders.productById.load(item.productId),
},
};
Verify: Enable query logging -- a list of 50 orders should produce 1 batch query, not 50.
Protect your API from abusive queries by setting maximum depth and complexity budgets. [src1]
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator }
from 'graphql-query-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10),
createComplexityRule({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
}),
],
});
Verify: A deeply nested query (11+ levels) is rejected with a depth-limit error.
# Input: SDL file for Apollo Federation v2
# Output: Subgraph that composes into a supergraph
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3",
import: ["@key", "@shareable", "@external"])
type Product @key(fields: "id") {
id: ID!
title: String!
price: Money!
reviews(first: Int = 10, after: String): ReviewConnection!
}
type Money {
amount: Int! # cents
currencyCode: String!
}
# Input: Python classes defining GraphQL types
# Output: Executable schema with Relay pagination
import strawberry
from strawberry import relay
@strawberry.type
class Product(relay.Node):
id: relay.NodeID[int]
title: str
price_cents: int
@relay.connection(relay.ListConnection[Product])
def variants(self, info) -> list["Product"]:
return get_variants(self.id)
@strawberry.type
class Query:
@relay.connection(relay.ListConnection[Product])
def products(self, info, status: str | None = None) -> list[Product]:
return get_products(status=status)
schema = strawberry.Schema(query=Query)
// Input: TypeScript classes with decorators
// Output: Executable schema with type safety
import { ObjectType, Field, ID, InputType, Mutation, Arg, Resolver }
from "type-graphql";
@ObjectType()
class Product {
@Field(() => ID)
id!: string;
@Field()
title!: string;
@Field({ deprecationReason: "Use priceV2 instead" })
price!: number;
@Field(() => Money)
priceV2!: Money;
}
@InputType()
class CreateProductInput {
@Field()
title!: string;
@Field({ nullable: true })
description?: string;
}
@Resolver(Product)
class ProductResolver {
@Mutation(() => Product)
async createProduct(@Arg("input") input: CreateProductInput) {
return await ProductService.create(input);
}
}
# BAD -- mirrors database schema, exposes internal column names
type products {
product_id: Int!
product_name: String
category_fk: Int # leaking foreign keys
is_deleted: Boolean # internal soft-delete flag
created_at_utc: String # raw DB column naming
}
# GOOD -- models the business domain, hides implementation
type Product implements Node {
id: ID! # opaque global ID
title: String! # domain language
category: Category! # resolved object, not FK
createdAt: DateTime! # custom scalar, consistent naming
# is_deleted never exposed -- filter at resolver level
}
# BAD -- unbounded list, no cursor, no page info
type Query {
products: [Product!]! # returns ALL products
orderItems(orderId: ID!): [OrderItem!]!
}
# GOOD -- bounded, cursor-based, with metadata
type Query {
products(first: Int!, after: String): ProductConnection!
orderItems(orderId: ID!, first: Int = 20, after: String): OrderItemConnection!
}
# BAD -- output type used as input; id and computed fields cause confusion
type Mutation {
updateProduct(product: Product!): Product!
}
# GOOD -- separate input type with only writable fields
input UpdateProductInput {
title: String
description: String
price: MoneyInput
}
type Mutation {
updateProduct(id: ID!, input: UpdateProductInput!): UpdateProductPayload!
}
// BAD -- no protection against malicious deep queries
const server = new ApolloServer({ schema });
// attacker can send: { user { posts { comments { author { posts { ... } } } } } }
// GOOD -- bounded depth and computed complexity budget
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule } from 'graphql-query-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10),
createComplexityRule({ maximumComplexity: 1000 }),
],
});
order.product fires 100 separate DB queries. Fix: new DataLoader(ids => batchFetchProducts(ids)) and create loaders per-request. [src6]@deprecated(reason: "Use fieldV2"), monitor usage for 2+ months, then remove. [src1]@shareable defeats ownership boundaries. Fix: only share fields that genuinely need multi-subgraph resolution. [src5]userErrors array in the mutation payload type. [src4]# Introspect schema and save locally
npx graphql-inspector introspect http://localhost:4000/graphql > schema.graphql
# Validate schema against best practices
npx graphql-inspector validate schema.graphql
# Diff two schema versions for breaking changes
npx graphql-inspector diff old-schema.graphql new-schema.graphql
# Check query complexity of a specific operation
npx graphql-query-complexity-cli --schema schema.graphql --query query.graphql
# List all deprecated fields still in use (Apollo Studio)
rover graph check my-graph@prod --schema schema.graphql
# Test a connection query with curl
curl -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ products(first:5) { pageInfo { hasNextPage endCursor } edges { node { id title } } } }"}'
| Version / Spec | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| GraphQL spec (Oct 2021) | Current | None since June 2018 | Stable foundation |
| Relay Connection Spec | Stable since 2015 | None | Universal pagination standard |
| Apollo Federation v2.3+ | Current | v1 @requires/@external semantics changed in v2 | Migrate with rover subgraph migrate |
| Apollo Federation v1 | Deprecated | N/A | Upgrade to v2; v1 gateway EOL 2025 |
| @defer / @stream | Draft (2024) | Spec not finalized | Apollo Server 4.x + Apollo Client 3.8+ experimental |
| GraphQL-over-HTTP | Proposed standard (2024) | Standardizes GET/POST, multipart | Most servers already comply |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Multiple client types (web, mobile, BFF) need different data shapes | Simple CRUD with one client and no nesting | REST API |
| Frontend teams need autonomy to query what they need | High-throughput binary RPC between internal services | gRPC |
| You need a typed, self-documenting API with introspection | Real-time streaming of large binary payloads | WebSocket + binary protocol |
| Multiple teams own different parts of the data graph | Tiny project with 1-3 endpoints | REST or tRPC |
| You want to aggregate multiple backend services into one API | File uploads are the primary operation | REST multipart upload |
@defer and @stream are still draft directives as of February 2026 -- server and client support varies; do not rely on them in production without verifying your toolchain supports incremental delivery.rover subgraph check to validate before deploying.