A GraphQL schema is a contract, and like any contract it is far cheaper to get right before anyone depends on it. Unlike a REST API, where each endpoint can quietly evolve its own shape, a GraphQL schema is one shared graph that every client queries against. Bad decisions early — exposing database columns directly as fields, modelling everything as a nullable String — don't stay contained. They spread into every client that ever queries that type. Schema-first design means writing the SDL before the resolvers, so the shape of the API is a deliberate decision rather than a side effect of whatever the ORM returns.
Model the domain, not the database
The most common mistake is designing a schema that mirrors database tables. A users table with a nullable manager_id foreign key becomes a schema with a nullable manager: User field — fine, that one's honest. But a status column stored as a raw integer should become a GraphQL enum, not an Int. A schema is meant to describe what a client can ask for in domain terms, not to expose your migration history. Favor specific types over generic ones: an OrderStatus enum with PENDING, PAID, SHIPPED, CANCELLED is self-documenting in a way that status: Int never will be, and it lets tooling autocomplete valid values.
type Order {
id: ID!
status: OrderStatus!
placedAt: DateTime!
customer: Customer!
lineItems: [OrderLineItem!]!
total: Money!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
CANCELLED
}
type OrderLineItem {
id: ID!
sku: String!
quantity: Int!
unitPrice: Money!
}
Nullability is a design decision
Every field in GraphQL is nullable unless you mark it with !. That exclamation point is not a minor detail — it is a promise to every client that the field will never be null, and breaking that promise later is a breaking change. The safer default is to make fields non-null only when you are certain the resolver can always produce a value, and to lean on nullable fields for anything that depends on an external service, a permission check, or a related record that might not exist. A List of a non-null type — [OrderLineItem!]! — is a common and useful pattern: the list itself is never null (an order always returns an array, even if empty), but you still guarantee no null entries inside it.
Changing a field from non-null to nullable is a breaking change for any client that doesn't defensively check for null — which most don't, because the schema told them not to. Changing nullable to non-null is technically additive but can break clients whose local caches or generated types assumed null was possible. Get nullability right at design time; loosening it later is cheap, tightening it is not.
Connections and pagination
Returning a bare list for anything that can grow unbounded — orders for a customer, comments on a post — is the second most common schema mistake. The Relay connection pattern (edges, node, pageInfo with hasNextPage and cursors) looks like ceremony the first time you write it, but it's the difference between a schema that scales to millions of rows and one that requires a breaking change the day someone's list gets too big to return in one response. Adopt cursor-based pagination for any list field from day one, even when the current data set is small — retrofitting it later means every client query changes shape.
Thinking in mutations, not CRUD verbs
REST habits push people toward a updateOrder(input: OrderInput) mutation that accepts a giant optional-everything input type and patches whatever fields are present. That's convenient to write and painful to reason about — the client has to know which combinations of fields are actually valid, and the server has to guess intent from what's absent. Prefer specific, intention-revealing mutations: cancelOrder(orderId: ID!), updateShippingAddress(orderId: ID!, address: AddressInput!). Each one has a narrow, well-typed input and a predictable effect, and each can evolve independently without the input type becoming an unreadable grab-bag of optional fields.
Deprecation, not versioning
GraphQL schemas evolve by addition and deprecation rather than by versioning the way REST APIs do (more on that trade-off separately). The @deprecated(reason: "...") directive lets you mark a field as on its way out while it keeps working for clients still using it — tooling like GraphiQL and Apollo Studio surfaces the deprecation warning directly in the editor, and usage-tracking on most GraphQL servers tells you when it's actually safe to remove.
Adding a new field, a new type, or a new enum value is non-breaking by default — existing queries don't request it, so nothing changes for them. The one exception is enums: adding a new enum value can break clients using exhaustive switch statements over the old set, which is why some teams add an UNKNOWN catch-all case up front.
Wrapping up
A well-designed GraphQL schema reads like documentation of the domain: specific types, honest nullability, paginated lists, and mutations named for what they do rather than how they're implemented. Getting this right up front costs an extra design pass before the first resolver is written. Getting it wrong costs a much longer one, later, with production clients depending on the mistake.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.