GraphQL Schema Design
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:
- Query root fields that fetch by ID → nullable (not found is valid)
- Fields on a resolved object → non-null unless genuinely optional
- List elements →
[Order!]!(list never null, items never null) unless the list itself can be absent
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:
- Adding fields, types, enum values, optional input fields → safe
- Removing or renaming anything → breaking
- Changing nullability (nullable → non-null) → breaking
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:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
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:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- 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
- GraphQL Schema Design Guide (GraphQL.org) — official introduction to types and fields
- Shopify GraphQL Design Tutorial — practical schema design from production experience
- Apollo Schema Design Best Practices — naming, nullability, and federation patterns
- GraphQL Specification — Type System — normative reference for types and nullability
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 →