---
# === IDENTITY ===
id: software/patterns/graphql-schema-patterns/2026
canonical_question: "What are the best GraphQL schema design patterns?"
aliases:
  - "GraphQL schema best practices"
  - "GraphQL type design patterns"
  - "How to design a GraphQL API schema"
  - "GraphQL federation patterns"
  - "Relay connection spec pagination"
  - "GraphQL input types and mutations"
  - "Schema-first vs code-first GraphQL"
  - "GraphQL union types and interfaces"
entity_type: software_reference
domain: software > patterns > graphql_schema_patterns
region: global
jurisdiction: global
temporal_scope: 2020-2026

# === VERIFICATION ===
last_verified: 2026-02-24
confidence: 0.90
version: 1.0
first_published: 2026-02-24

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: stable
  last_breaking_change: "Apollo Federation v2 (2022) replaced @requires/@external with @shareable; Relay connection spec unchanged since 2015; @defer/@stream directives still incremental-delivery draft (2024)"
  next_review: 2026-08-23
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "NEVER expose database schema directly as your GraphQL schema -- design around client use cases, not table structures"
  - "ALWAYS implement depth limiting and query complexity analysis on public-facing GraphQL APIs to prevent denial-of-service via deeply nested queries"
  - "ALWAYS use DataLoader (or equivalent batching) to resolve N+1 query problems -- naive resolvers will multiply database queries"
  - "Mutation inputs MUST use dedicated Input types -- do not reuse output object types as mutation arguments"
  - "Relay connection fields MUST return Connection types with edges/node/pageInfo -- never return raw lists for paginated data"
  - "Breaking schema changes (removing fields, changing return types) require a deprecation period -- use @deprecated directive before removal"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Debugging N+1 query performance issues specifically"
    use_instead: "software/debugging/graphql-n-plus-1-dataloader/2026"
  - condition: "Migrating an existing REST API to GraphQL"
    use_instead: "software/migrations/rest-to-graphql/2026"
  - condition: "Designing overall GraphQL API architecture (gateway, caching, auth)"
    use_instead: "software/system-design/graphql-api-architecture/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: "client_type"
    question: "What type of clients will consume this GraphQL API?"
    type: choice
    options: ["web (SPA)", "mobile (iOS/Android)", "BFF (backend-for-frontend)", "multi-client (web + mobile + third-party)"]
  - key: "schema_approach"
    question: "Do you prefer schema-first (SDL) or code-first (programmatic) development?"
    type: choice
    options: ["schema-first (SDL)", "code-first (TypeGraphQL, Nexus, Strawberry)", "no preference"]
  - key: "relay_compatible"
    question: "Do you need Relay-compatible schema (Node interface, connection spec)?"
    type: choice
    options: ["yes", "no", "unsure"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/patterns/graphql-schema-patterns/2026"
suggested_citation: "Source: knowledgelib.io -- AI Knowledge Library (verified 2026-02-24)"

# === RELATED UNITS ===
related_kos:
  related_to:
    - id: "software/patterns/rest-pagination/2026"
      label: "REST API Pagination Patterns"
    - id: "software/patterns/api-versioning/2026"
      label: "API Versioning Strategies"
    - id: "software/patterns/grpc-service-design/2026"
      label: "gRPC Service Design Patterns"
    - id: "software/patterns/polling-sse-websocket/2026"
      label: "Polling vs SSE vs WebSocket"
  depends_on:
    - id: "software/debugging/graphql-n-plus-1-dataloader/2026"
      label: "GraphQL N+1 Problem and DataLoader"
  solves:
    - id: "software/system-design/graphql-api-architecture/2026"
      label: "GraphQL API Architecture"
  alternative_to:
    - id: "software/migrations/rest-to-graphql/2026"
      label: "REST to GraphQL Migration"

# === SOURCES (7 authoritative sources) ===
# Types: official_docs, technical_blog, rfc_spec, community_resource, industry_report
# Reliability: high, moderate_high, moderate, authoritative
sources:
  - id: src1
    title: "GraphQL Best Practices"
    author: GraphQL Foundation
    url: https://graphql.org/learn/best-practices/
    type: official_docs
    published: 2024-01-15
    reliability: authoritative
  - id: src2
    title: "Demand Oriented Schema Design"
    author: Apollo GraphQL
    url: https://www.apollographql.com/docs/graphos/schema-design/guides/demand-oriented-schema-design
    type: official_docs
    published: 2024-06-01
    reliability: high
  - id: src3
    title: "GraphQL Cursor Connections Specification"
    author: Relay / Meta
    url: https://relay.dev/graphql/connections.htm
    type: rfc_spec
    published: 2015-09-01
    reliability: authoritative
  - id: src4
    title: "Shopify GraphQL Design Tutorial"
    author: Shopify Engineering
    url: https://github.com/Shopify/graphql-design-tutorial/blob/master/TUTORIAL.md
    type: technical_blog
    published: 2018-05-10
    reliability: high
  - id: src5
    title: "Introduction to Apollo Federation"
    author: Apollo GraphQL
    url: https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/federation
    type: official_docs
    published: 2024-03-01
    reliability: high
  - id: src6
    title: "Solving the N+1 Problem for GraphQL through Batching"
    author: Shopify Engineering
    url: https://shopify.engineering/solving-the-n-1-problem-for-graphql-through-batching
    type: technical_blog
    published: 2023-06-15
    reliability: moderate_high
  - id: src7
    title: "Schema Design Best Practices (Part 2)"
    author: The Guild (GraphQL Hive)
    url: https://the-guild.dev/graphql/hive/blog/schema-design-best-practices-part-2
    type: technical_blog
    published: 2024-08-20
    reliability: moderate_high
---

# GraphQL Schema Design Patterns

## TL;DR

- **Bottom line**: Design your GraphQL schema around client use cases and business domain, not database tables -- use Relay connections for pagination, input types for mutations, and interfaces/unions for polymorphism.
- **Key tool/command**: `type ProductConnection { edges: [ProductEdge!]! pageInfo: PageInfo! }` -- the Relay connection spec is the universal pagination pattern.
- **Watch out for**: N+1 queries -- every resolver that fetches related data without DataLoader batching will multiply your database round-trips by the list size.
- **Works with**: Any GraphQL server (Apollo Server 4.x, graphql-yoga 5.x, Strawberry 0.x, gqlgen 0.17+, Absinthe 1.7+).

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- NEVER expose database schema directly as your GraphQL schema -- design around client use cases, not table structures [src4]
- ALWAYS implement depth limiting and query complexity analysis on public-facing APIs to prevent denial-of-service via deeply nested queries [src1]
- ALWAYS use DataLoader or equivalent batching to solve N+1 query problems [src6]
- Mutation inputs MUST use dedicated `Input` types -- never reuse output types as mutation arguments [src4]
- Relay connection fields MUST return Connection types with edges/node/pageInfo -- never raw lists for paginated data [src3]
- Breaking schema changes (removing fields, changing types) require a deprecation period using `@deprecated` [src1]

## Quick Reference

| 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 results, 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 |

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

```graphql
"""A product in the catalog."""
type Product implements Node {
  """Global Relay ID."""
  id: ID!
  """Human-readable unique handle (e.g., 'blue-widget')."""
  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**: Run schema validation -- `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]

```graphql
type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  """Total count (optional, can be expensive)."""
  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]

```graphql
input CreateProductInput {
  title: String!
  description: String
  price: MoneyInput!
  status: ProductStatus = DRAFT
}

type CreateProductPayload {
  product: Product
  userErrors: [UserError!]!
}

type UserError {
  field: [String!]
  message: String!
  code: ErrorCode
}

type Mutation {
  createProduct(input: CreateProductInput!): CreateProductPayload!
}
```

**Verify**: Mutation with invalid input returns `userErrors` array with `field` path, 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]

```graphql
"""Relay Node interface for global ID resolution."""
interface Node {
  id: ID!
}

"""Search can return products or articles."""
union SearchResult = Product | Article | Category

type Query {
  node(id: ID!): Node
  search(query: String!, first: Int): SearchResultConnection!
}
```

**Verify**: Introspection query `{ __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]

```javascript
// Node.js / Apollo Server
import DataLoader from 'dataloader';

// Create per-request loader (MUST be per-request, not global)
const createLoaders = () => ({
  productById: new DataLoader(async (ids) => {
    const products = await db.products.findByIds(ids);
    // MUST return results in same order as input ids
    const map = new Map(products.map(p => [p.id, p]));
    return ids.map(id => map.get(id) || null);
  }),
});

// In resolver
const resolvers = {
  OrderItem: {
    product: (item, _, { loaders }) =>
      loaders.productById.load(item.productId),
  },
};
```

**Verify**: Enable query logging on database -- a list of 50 orders should produce 1 batch query for products, not 50.

### 6. Add query complexity and depth limiting

Protect your API from abusive queries by setting maximum depth and complexity budgets. [src1]

```javascript
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator }
  from 'graphql-query-complexity';

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(10), // max 10 levels deep
    createComplexityRule({
      maximumComplexity: 1000,
      estimators: [
        fieldExtensionsEstimator(),
        simpleEstimator({ defaultComplexity: 1 }),
      ],
      onComplete: (complexity) => {
        console.log('Query complexity:', complexity);
      },
    }),
  ],
});
```

**Verify**: Query `{ a { b { c { d { e { f { g { h { i { j { k { l } } } } } } } } } } } }` is rejected with depth-limit error.

## Code Examples

### Node.js / Apollo Server: Complete schema with federation

```graphql
# products subgraph schema (schema-first)
# 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

```python
# 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

> Full script: [typescript-typegraphql-decorator-based-code-first.ts](scripts/typescript-typegraphql-decorator-based-code-first.ts) (29 lines)

```typescript
// Input:  TypeScript classes with decorators
// Output: Executable schema with type safety
import { ObjectType, Field, ID, InputType, Mutation, Arg, Resolver }
  from "type-graphql";
@ObjectType()
# ... (see full script)
```

## Anti-Patterns

### Wrong: Exposing database tables as GraphQL types

```graphql
# 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

```graphql
# 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

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

### Correct: Relay connection pagination

```graphql
# 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

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

### Correct: Dedicated Input types per mutation

```graphql
# 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

```javascript
// 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

```javascript
// 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

- **N+1 query explosion**: Without DataLoader, resolving a list of 100 orders with `order.product` fires 100 separate DB queries. Fix: `new DataLoader(ids => batchFetchProducts(ids))` and create loaders per-request. [src6]
- **Schema-database coupling**: Auto-generating schema from DB (e.g., PostGraphile defaults, Hasura without customization) exposes internal structure and makes frontend changes require DB migrations. Fix: treat the schema as a separate API contract. [src4]
- **Breaking changes without deprecation**: Removing a field or changing its type breaks existing clients silently. Fix: add `@deprecated(reason: "Use fieldV2")`, monitor usage for 2+ months, then remove. [src1]
- **Overly broad `@shareable` in federation**: Marking all fields `@shareable` defeats ownership boundaries and causes merge conflicts. Fix: only share fields that genuinely need multi-subgraph resolution. [src5]
- **Cursor opacity violation**: Using raw database IDs as cursors couples pagination to the database and breaks when the underlying storage changes. Fix: base64-encode cursor payloads (e.g., `btoa(JSON.stringify({id, createdAt}))`). [src3]
- **Missing error handling in mutations**: Returning GraphQL errors for validation failures (e.g., "title too long") mixes user errors with system errors. Fix: return `userErrors` array in the mutation payload type. [src4]
- **Global DataLoader instances**: Sharing a DataLoader across requests causes data leaks between users. Fix: create DataLoader instances per-request in the context factory. [src6]

## Diagnostic Commands

```bash
# Introspect schema and check for undocumented types
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 / 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 (incremental delivery) | Spec not finalized | Apollo Server 4.x + Apollo Client 3.8+ support experimentally |
| GraphQL-over-HTTP | Proposed standard (2024) | Standardizes GET/POST, multipart for subscriptions | Most servers already comply |

## When to Use / When Not to Use

| 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 (GraphQL multipart spec exists but adds complexity) |

## Important Caveats

- `@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.
- Apollo Federation v2 is not backward-compatible with v1 -- migrating subgraphs requires updating directives and testing composition; use `rover subgraph check` to validate before deploying.
- Query complexity analysis adds ~1-3ms per query for computation -- for latency-critical APIs, consider static analysis at build time instead of runtime complexity checks.
- GraphQL subscriptions over WebSocket require sticky sessions or a pub/sub layer (Redis, NATS) in multi-instance deployments -- this is a significant operational consideration.
- The Relay connection spec is optional for non-Relay clients, but it is the de facto standard and most tools/libraries expect it -- deviating creates friction with code generators and client libraries.

## Related Units

- [REST API Pagination Patterns](/software/patterns/rest-pagination/2026)
- [API Versioning Strategies](/software/patterns/api-versioning/2026)
- [gRPC Service Design Patterns](/software/patterns/grpc-service-design/2026)
- [Polling vs SSE vs WebSocket](/software/patterns/polling-sse-websocket/2026)
- [GraphQL N+1 Problem and DataLoader](/software/debugging/graphql-n-plus-1-dataloader/2026)
- [GraphQL API Architecture](/software/system-design/graphql-api-architecture/2026)
- [REST to GraphQL Migration](/software/migrations/rest-to-graphql/2026)
