Seven Schema Design Mistakes We See Over and Over

October 1, 2024

After reviewing hundreds of GraphQL schemas — in open-source projects, in contributions to this site, and in conversations on Discord — certain patterns keep showing up. These are not obscure edge cases. They are common decisions that seem reasonable at the time but create real problems as the API grows.

1. Mirroring the Database Schema

The most frequent mistake. Teams expose their database tables directly as GraphQL types, including internal IDs, join table artifacts, and column names that make no sense outside the database. The GraphQL schema should represent the domain as clients experience it, not the storage layer. A user_role_assignments join table does not need a corresponding GraphQL type — the client just needs a roles field on User.

2. Making Everything Nullable

When in doubt, developers mark fields as nullable. This pushes null-checking to every client. If a user always has an email address, email: String! communicates that guarantee. Reserve nullable fields for cases where the absence of a value has semantic meaning — a deletedAt timestamp, for instance, where null means "not deleted."

Avoid
type User {
  id: ID       # Should be ID!
  name: String  # Should be String!
  email: String # Should be String!
}
Better
type User {
  id: ID!
  name: String!
  email: String!
  deletedAt: DateTime  # Nullable on purpose
}

3. Using Generic Mutation Names

Names like updateUser that accept a bag of optional fields are a code smell. They hide the intent of the operation. Prefer specific mutations: changeEmail, deactivateAccount, assignRole. Each mutation has a clear contract, clear validation, and clear side effects. This approach scales better as the schema grows.

4. Returning Bare Arrays Instead of Connections

A field that returns [Post!]! works fine until you need pagination, total counts, or metadata about the list. By then, changing the return type is a breaking change. Start with the connection pattern — edges, node, pageInfo — even for lists that seem small. The overhead is minimal, and it avoids a schema migration later.

Connection Pattern
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  cursor: String!
  node: Post!
}

5. No Input Types for Mutations

Passing each field as a separate argument to a mutation creates long, fragile signatures. Group related fields into an input type. This makes the mutation easier to call, easier to validate on the server, and easier to extend — adding a field to an input type is not a breaking change.

6. Exposing Internal Errors to Clients

Leaking stack traces, database error messages, or internal service names in the errors array is a security risk and confuses clients. Map internal errors to application-level error codes (NOT_FOUND, VALIDATION_ERROR, FORBIDDEN) in the extensions object. Log the full error server-side.

7. Ignoring Deprecation

Fields are deprecated by adding the @deprecated directive with a reason. Too many schemas skip this step and either remove fields without warning (breaking clients) or leave dead fields in the schema indefinitely. Deprecation is the migration tool GraphQL provides — use it.

Deprecation
type User {
  id: ID!
  name: String!
  fullName: String!
  username: String! @deprecated(reason: "Use fullName instead")
}

None of these mistakes are fatal. They are correctable, and many production schemas carry some of them. The goal is to recognize these patterns early and address them before they compound.