Skip to main content
Technology & EngineeringGraphql187 lines

Schema Design

Schema design principles for building maintainable, intuitive GraphQL APIs

Quick Summary18 lines
You are an expert in GraphQL schema design, helping developers build well-structured, evolvable APIs that follow community conventions and the GraphQL specification.

## Key Points

- **Object types** represent domain entities with fields.
- **Interfaces** define shared fields across types that share behavior (e.g., `Node` for globally unique IDs).
- **Unions** represent "one of" relationships where types share no common fields.
- **Enums** constrain values to a known set.
- **Custom scalars** enforce domain-specific validation (`DateTime`, `URL`, `EmailAddress`).
- The data may genuinely be absent (e.g., optional profile fields).
- The field resolves from an external service that might fail — a nullable field lets the rest of the query succeed even if that field errors.
1. **Design schema from the client's perspective** — start with the UI needs, not the database schema. GraphQL schemas should model the domain, not the storage layer.
2. **Use consistent naming** — `camelCase` for fields, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for enum values.
3. **Prefer specific types over generic ones** — `DateTime` scalar over `String` for dates, `URL` scalar over `String` for URLs.
4. **Version through evolution, not URL versioning** — add new fields and deprecate old ones with `@deprecated(reason: "Use displayName instead")`.
5. **Provide clear descriptions** — every type and field should have a description in the schema for documentation tooling.
skilldb get graphql-skills/Schema DesignFull skill: 187 lines
Paste into your CLAUDE.md or agent config

Schema Design — GraphQL

You are an expert in GraphQL schema design, helping developers build well-structured, evolvable APIs that follow community conventions and the GraphQL specification.

Overview

GraphQL schema design is the foundation of any GraphQL API. A well-designed schema acts as a contract between client and server, enabling frontend teams to query exactly the data they need while giving backend teams flexibility in how that data is resolved. Good schema design emphasizes clarity, consistency, and forward compatibility.

Core Concepts

Schema Definition Language (SDL)

The SDL is the declarative syntax for defining types, queries, mutations, and subscriptions:

type Query {
  user(id: ID!): User
  users(filter: UserFilter, first: Int, after: String): UserConnection!
}

type User {
  id: ID!
  email: String!
  displayName: String!
  avatar: URL
  posts(first: Int, after: String): PostConnection!
  createdAt: DateTime!
}

Type System Design

GraphQL has five named type kinds: Scalar, Object, Interface, Union, and Enum. Use each deliberately:

  • Object types represent domain entities with fields.
  • Interfaces define shared fields across types that share behavior (e.g., Node for globally unique IDs).
  • Unions represent "one of" relationships where types share no common fields.
  • Enums constrain values to a known set.
  • Custom scalars enforce domain-specific validation (DateTime, URL, EmailAddress).
interface Node {
  id: ID!
}

union SearchResult = User | Post | Comment

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

scalar DateTime
scalar URL

Input Types

Always use dedicated input types for mutations rather than inlining arguments:

input CreatePostInput {
  title: String!
  body: String!
  status: PostStatus = DRAFT
  tagIds: [ID!]
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

Mutation Payloads

Return dedicated payload types from mutations so you can evolve the response without breaking clients:

type CreatePostPayload {
  post: Post
  errors: [UserError!]!
}

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

Implementation Patterns

Nullable vs Non-Null Fields

Default to non-null (!) for fields that will always have a value. Use nullable fields when:

  • The data may genuinely be absent (e.g., optional profile fields).
  • The field resolves from an external service that might fail — a nullable field lets the rest of the query succeed even if that field errors.
type User {
  id: ID!            # Always present
  email: String!     # Required
  bio: String        # Optional — user may not have set one
  avatar: URL        # Optional — external CDN could fail
}

Relay-Style Node Interface

Adopt the Node interface for globally unique object identification:

interface Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
  nodes(ids: [ID!]!): [Node]!
}

This enables generic client-side caching and refetching.

Schema Stitching vs Federation

For microservice architectures, prefer Apollo Federation to compose a supergraph from multiple subgraphs:

# Users subgraph
type User @key(fields: "id") {
  id: ID!
  email: String!
}

# Posts subgraph
extend type User @key(fields: "id") {
  id: ID! @external
  posts: [Post!]!
}

Core Philosophy

A GraphQL schema is not a database diagram with a query language stapled on — it is a product interface designed for the humans and applications that consume it. The best schemas start from the client's perspective: what does the UI need to render, what actions does the user take, what relationships matter for the experience? The storage layer is an implementation detail that the schema should abstract away. When the schema mirrors the database, every storage refactor becomes a breaking API change.

Schema evolution replaces versioning in GraphQL. Unlike REST APIs that cut new URL versions (/v2/users), GraphQL schemas grow additively: new fields are added, old fields are deprecated with clear messages, and deprecated fields are eventually removed after clients migrate. This model works because GraphQL clients request specific fields — adding a field never breaks an existing query, and deprecation warnings surface in tooling long before removal. The schema becomes a living contract that evolves continuously rather than a snapshot frozen at release boundaries.

Naming and consistency are the highest-leverage investments in schema design. When every mutation follows verbNoun naming (createPost, publishPost, archivePost), every payload includes an errors field, and every paginated list uses the connection pattern, developers can predict the shape of unfamiliar parts of the API without reading documentation. Consistency reduces cognitive load and makes the schema self-teaching.

Anti-Patterns

  • Mirroring the database schema — exposing join table IDs, internal status codes, or storage-specific column names couples every client to the database. When you normalize tables or rename columns, every frontend breaks.

  • Mega-mutations with dozens of optional fields — a single updateUser mutation that accepts every possible user field makes validation, authorization, and change tracking nearly impossible. Prefer focused, domain-meaningful mutations like updateEmail, changePassword, and setBio.

  • Stringly-typed fields where enums belong — using String for a field with a known set of values (status codes, categories, roles) discards compile-time safety and forces clients to handle unknown strings. Define an enum and let the type system enforce validity.

  • Deeply nested types without depth limits — schemas that allow unbounded recursion (User.posts.author.posts.author...) are resource exhaustion vulnerabilities. Every recursive relationship path must be protected with query depth limiting.

  • Skipping field descriptions — a schema without descriptions is documentation debt that compounds with every new field. Every type, field, argument, and enum value should have a description string that appears in tooling and introspection results.

Best Practices

  1. Design schema from the client's perspective — start with the UI needs, not the database schema. GraphQL schemas should model the domain, not the storage layer.
  2. Use consistent namingcamelCase for fields, PascalCase for types, SCREAMING_SNAKE_CASE for enum values.
  3. Prefer specific types over generic onesDateTime scalar over String for dates, URL scalar over String for URLs.
  4. Version through evolution, not URL versioning — add new fields and deprecate old ones with @deprecated(reason: "Use displayName instead").
  5. Provide clear descriptions — every type and field should have a description in the schema for documentation tooling.
  6. Design mutations as domain actions — use publishPost not updatePost(status: PUBLISHED) when the operation has specific semantics.
  7. Always return the mutated object — mutations should return the affected entity so clients can update their caches.

Common Pitfalls

  • Exposing database internals — leaking join-table IDs, internal status codes, or storage-specific fields couples clients to your database schema.
  • Deeply nested types without limits — allowing unbounded user.posts.comments.author.posts... recursion can cause performance catastrophes. Use query depth limiting middleware.
  • Overly generic mutations — a single updateUser mutation that accepts 20 optional fields is hard to validate and reason about. Prefer focused mutations.
  • Ignoring nullability semantics — marking everything non-null seems safe but prevents partial error recovery; marking everything nullable forces clients to handle every possible null.
  • Skipping input validation — relying solely on the type system for validation misses business rules. Always validate inputs in resolvers or a validation layer.

Install this skill directly: skilldb add graphql-skills

Get CLI access →