GraphQL Schema Design Patterns

Type: Software Reference Confidence: 0.90 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

PatternUse CaseComplexityBenefitsTrade-offs
Relay ConnectionsPaginated listsMediumCursor-based pagination, consistent interface, metadata via edgesMore verbose than simple lists; requires Connection/Edge/PageInfo types
Input TypesMutationsLowSingle variable per mutation, extensible, clear contractExtra type definitions; cannot share with output types
Union TypesPolymorphic returns (search, feeds)MediumType-safe multi-type results, exhaustive handling with ... onClients must handle every member; no shared fields
InterfacesShared fields across types (Node, Timestamped)MediumCommon fields guaranteed, works with fragmentsMust be implemented completely; adding fields is breaking
Custom ScalarsDomain-specific values (DateTime, URL, Email)LowValidation at schema level, self-documentingRequires parser/serializer on server; clients need codegen
Schema StitchingCombining multiple schemas (legacy)HighQuick integration of existing APIsFragile merge conflicts; replaced by federation for most cases
Apollo FederationMulti-team microservice graphHighIndependent subgraph deployment, entity resolution across servicesOperational complexity; requires gateway; cold-start latency
SubscriptionsReal-time updates (chat, notifications)HighPush-based data delivery, native GraphQLWebSocket infrastructure; connection management at scale
@defer / @streamIncremental delivery of slow fieldsMediumFaster initial response, progressive renderingStill draft spec (2024); limited server/client support
Enum TypesFixed-set values (status, sort order)LowType-safe constants, auto-documentedAdding values can break exhaustive client switches
Nullable by DefaultField evolution and resilienceLowSafe schema evolution, graceful partial failuresClients must handle null; requires discipline
Payload TypesMutation responsesMediumReturn errors alongside data, user errors vs system errorsMore boilerplate than returning the entity directly

Decision Tree

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

Step-by-Step Guide

1. Define your domain entities as object types

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.

2. Implement Relay connection pagination

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.

3. Design mutations with Input types and Payload types

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.

4. Add interfaces and unions for polymorphism

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.

5. Implement DataLoader for batched resolution

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.

6. Add query complexity and depth limiting

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.

Code Examples

Node.js / Apollo Server: Federation subgraph schema

# 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!
}

Python / Strawberry: Type-safe code-first schema

# 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)

TypeScript / TypeGraphQL: Decorator-based code-first

// 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);
  }
}

Anti-Patterns

Wrong: Exposing database tables as GraphQL types

# 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
}

Correct: Domain-driven type design

# 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
}

Wrong: Returning raw lists without pagination

# BAD -- unbounded list, no cursor, no page info
type Query {
  products: [Product!]!     # returns ALL products
  orderItems(orderId: ID!): [OrderItem!]!
}

Correct: Relay connection pagination

# GOOD -- bounded, cursor-based, with metadata
type Query {
  products(first: Int!, after: String): ProductConnection!
  orderItems(orderId: ID!, first: Int = 20, after: String): OrderItemConnection!
}

Wrong: Reusing output types as mutation inputs

# BAD -- output type used as input; id and computed fields cause confusion
type Mutation {
  updateProduct(product: Product!): Product!
}

Correct: Dedicated Input types per mutation

# GOOD -- separate input type with only writable fields
input UpdateProductInput {
  title: String
  description: String
  price: MoneyInput
}

type Mutation {
  updateProduct(id: ID!, input: UpdateProductInput!): UpdateProductPayload!
}

Wrong: No query depth or complexity limits

// BAD -- no protection against malicious deep queries
const server = new ApolloServer({ schema });
// attacker can send: { user { posts { comments { author { posts { ... } } } } } }

Correct: Depth limit + complexity analysis

// 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 }),
  ],
});

Common Pitfalls

Diagnostic Commands

# 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 History & Compatibility

Version / SpecStatusBreaking ChangesMigration Notes
GraphQL spec (Oct 2021)CurrentNone since June 2018Stable foundation
Relay Connection SpecStable since 2015NoneUniversal pagination standard
Apollo Federation v2.3+Currentv1 @requires/@external semantics changed in v2Migrate with rover subgraph migrate
Apollo Federation v1DeprecatedN/AUpgrade to v2; v1 gateway EOL 2025
@defer / @streamDraft (2024)Spec not finalizedApollo Server 4.x + Apollo Client 3.8+ experimental
GraphQL-over-HTTPProposed standard (2024)Standardizes GET/POST, multipartMost servers already comply

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Multiple client types (web, mobile, BFF) need different data shapesSimple CRUD with one client and no nestingREST API
Frontend teams need autonomy to query what they needHigh-throughput binary RPC between internal servicesgRPC
You need a typed, self-documenting API with introspectionReal-time streaming of large binary payloadsWebSocket + binary protocol
Multiple teams own different parts of the data graphTiny project with 1-3 endpointsREST or tRPC
You want to aggregate multiple backend services into one APIFile uploads are the primary operationREST multipart upload

Important Caveats

Related Units