GraphQL Schema Design

BackendGraphQLAPIArchitecture
Share on LinkedIn

The schema is the contract. Every awkward field name, every nullable field that should be required, every mutation that returns a boolean instead of the created object — those decisions compound over years. I've refactored schemas mid-flight and the migration cost is brutal because GraphQL clients embed field selections at compile time. Getting the schema right upfront saves more pain than any resolver optimization.

Start from nouns, not endpoints

GraphQL schemas should read like a domain model, not a REST port:

# Bad — REST thinking
type Query {
  getUserById(id: ID!): User
  getAllOrdersForUser(userId: ID!): [Order!]!
}

# Good — domain nouns
type Query {
  user(id: ID!): User
  orders(filter: OrderFilter, first: Int, after: String): OrderConnection!
}

Queries name resources. Arguments express filtering and pagination. Mutations name actions on resources.

Nullability is a design decision

Non-null (!) means "if the parent exists, this field always resolves." Nullable means "this might not be there, and that's OK."

type User {
  id: ID!
  email: String!           # always present for a valid user
  phoneNumber: String      # optional — not every user has one
  avatar: Image            # nullable — user may not have uploaded one
}

type Query {
  user(id: ID!): User      # nullable — user might not exist
}

Rule of thumb:

Getting this wrong forces clients to null-check everything or miss error propagation.

Input types for mutations

Never expose raw scalars as mutation arguments at scale:

# Bad
mutation {
  updateUser(id: ID!, name: String, email: String, bio: String, ...): User
}

# Good
input UpdateUserInput {
  name: String
  email: String
  bio: String
}

type Mutation {
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}

Input types are easier to extend without breaking clients (adding optional fields is non-breaking). Payload types carry the result and errors:

type UpdateUserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  field: String
  message: String!
}

This pattern separates "business validation failed" from "GraphQL execution error."

Pagination as a first-class pattern

Don't invent pagination per type. Pick one pattern (Relay Connections recommended) and apply consistently:

type Query {
  users(first: Int, after: String): UserConnection!
  orders(first: Int, after: String, filter: OrderFilter): OrderConnection!
}

Every list that can grow unbounded gets cursor pagination. Small static enums don't need it.

Enums over strings

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

Enums are self-documenting, validated at parse time, and generate typed constants in client codegen. Use them for fixed sets. Don't create enums for values that change weekly — that's a string field with server-side validation.

Avoid these schema smells

Smell Problem Fix
JSON scalar everywhere No type safety, no tooling Define proper types
Boolean mutation returns Client can't get the created object Return payload with entity
Deeply nested input Hard to validate, hard to evolve Flatten or split mutations
ID as only identifier No public-facing slug support Add slug: String! where needed
Mirror of database schema Exposes internal joins Model client use cases

Versioning without versions

GraphQL doesn't have /v2 endpoints. Evolution rules:

Use @deprecated(reason: "...") with a sunset date:

type User {
  fullName: String! @deprecated(reason: "Use displayName instead")
  displayName: String!
}

Track field usage in your GraphQL gateway. Remove deprecated fields only when telemetry shows zero queries.

Federation considerations

If you're heading toward Apollo Federation or GraphQL Mesh, design types as bounded contexts from the start:

# User service owns User
type User @key(fields: "id") {
  id: ID!
  name: String!
}

# Order service extends User
extend type User @key(fields: "id") {
  id: ID! @external
  orders: [Order!]!
}

Types owned by one service, extended by others. Don't design a monolith schema and split later — the cut lines won't be clean.

Nullable by default

GraphQL fields nullable unless truly required — clients handle partial errors. Non-null (!) everywhere causes full query failure on single field error.

Use pagination standard (Relay connections) for all list fields — unbounded [User!]! lists don't scale.

Common production mistakes

Teams get schema design best practices wrong in predictable ways:

GraphQL APIs for schema design best practices melt down under nested queries without depth limits, N+1 resolvers hit the database per field, and schema deprecation has no usage telemetry.

Debugging and triage workflow

When schema design best practices misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

Should GraphQL fields be nullable or non-null by default?

Default to non-null for fields that always exist when the parent resolves successfully. Use nullable only when absence is meaningful (optional profile fields, resources that may not exist). Non-null fields propagate errors up — a nullable parent lets partial data through, which is often what clients want.

How should I name types and fields in GraphQL?

Use PascalCase for types and enums, camelCase for fields and arguments. Name types by what they represent (User, Order), not how they're fetched (UserDTO). Use verb-noun for mutations (createOrder, not orderCreate). Be consistent — renaming is a breaking change.

When should I use interfaces vs unions?

Use interfaces when types share common fields (SearchResult with TextResult and ImageResult both having id and title). Use unions when types have nothing in common except context (FeedItem = Post | Ad | Promotion). Prefer interfaces when clients need to query shared fields without inline fragments.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →