Part 2: Schema Design and the Type System
Designing a Schema That Lasts
The first schema I designed for GraphQL was essentially a REST API with angle brackets. I had a type per database table, queries that mapped one-to-one with GET endpoints, and mutations that mapped to POST/PUT/DELETE. It worked but missed everything GraphQL is good at.
Over time I learned that a good GraphQL schema models the domain, not the database. It is designed around how clients think about data, not how the database stores it. A field on a GraphQL type might pull from two tables, transform a value, or call a downstream service. None of that is the client's concern.
This part covers the full GraphQL type system β interfaces, unions, enums, custom scalars, directives β and the conventions I follow when designing schemas that survive changing requirements.
The Full Type System
Object Types
Object types are the most common building block. Each field declares its type and whether it is nullable.
type Product {
id: ID!
name: String!
slug: String!
description: String
price: Float!
currency: Currency!
inStock: Boolean!
stockCount: Int!
category: Category!
tags: [String!]!
variants: [ProductVariant!]!
createdAt: DateTime!
updatedAt: DateTime!
}Field design guidelines I follow:
Use
ID!for identifiers β notString!orInt!.IDcommunicates intent and many tools treat it specially.Prefer non-null (
!) when the field is always present by business logic. Reserve nullable for genuinely optional data.Use meaningful types rather than
Stringeverywhere.DateTimeinstead ofString,Currencyenum instead ofString,URLscalar instead ofString.
Enums
Enums define a closed set of values. They serialise as strings in JSON.
In TypeScript resolvers, the enum values arrive as their string representation:
Interfaces
Interfaces define a common set of fields that multiple types must implement. Useful when different types share structural overlap but are conceptually distinct.
The Node interface is a convention from the Relay specification β every type with id: ID! implements Node. This enables a generic node(id: ID!) query pattern used by Relay and a few other client libraries.
Clients can query interface fields without knowing the concrete type:
Unions
Unions let a field return one of several possible types that may not share any common fields.
The client handles each possible type with inline fragments:
Interface vs Union: Use an interface when the types share common fields the client will always want. Use a union when the types are structurally unrelated or have no meaningful shared fields.
Input Types
Input types define the shape of arguments passed to mutations (and occasionally queries). They cannot use output-only types like interfaces or unions β they must be composed of scalars, enums, and other input types.
A pattern I use for UpdateInput types: all fields are nullable (the client only sends fields that change). On the resolver side, I use a partial update pattern to apply only provided fields.
Custom Scalars
The five built-in scalars are not enough for real domains. Common custom scalars:
In TypeScript, register custom scalars using graphql-scalars (a well-maintained library with implementations of 50+ scalars):
Directives
GraphQL has three built-in directives:
Custom directives are powerful for cross-cutting concerns like auth (covered in Part 5):
Pagination: Cursor vs Offset
Pagination is one of the most important schema design decisions. The GraphQL community converged on two patterns.
Offset Pagination (Simple)
This is what I use for admin dashboards and list views where jumping to a specific page matters. The downside: if items are inserted or deleted between page requests, results can skip or duplicate items.
Cursor Pagination (Relay-compatible)
Cursor pagination is stable under concurrent inserts/deletes and is required by the Relay client. I use this for infinite scroll lists and any public-facing API.
Schema Evolution Without Versioning
GraphQL's answer to versioning is field-by-field evolution using @deprecated.
Deprecating a Field
Clients using the old price field continue to work. The deprecation is visible in tooling (GraphQL Playground, Apollo Studio) and client teams can migrate at their own pace. Once usage drops to zero (tracked via Apollo Studio field usage metrics), remove the field.
Adding Fields is Always Safe
Adding new fields and types to a GraphQL schema is non-breaking. Existing queries that do not request the new fields are unaffected.
What Breaks Compatibility
Removing a field
Changing a field's type (e.g.,
StringtoInt)Making a nullable field non-null (clients expecting null will break)
Removing an enum value
Renaming a type (if clients use it by name in fragments)
Naming Conventions
Conventions I follow consistently β inconsistency in naming is the fastest way to confuse API consumers:
Types
PascalCase
ProductVariant, OrderStatus
Fields
camelCase
createdAt, firstName
Enums
SCREAMING_SNAKE_CASE for values
ORDER_STATUS, DRAFT
Mutations
verb + noun
createProduct, updateStock, deleteUser
Queries
noun (no verb)
product, products, me
Input types
PascalCase + Input suffix
CreateProductInput
Connection types
PascalCase + Connection
ProductConnection
Edge types
PascalCase + Edge
ProductEdge
A Complete Domain Schema Example
Putting it all together β a product catalogue schema designed for a microservices context:
This schema models a real domain. It supports cursor pagination, filtering, sorting, subscriptions, and is designed to evolve field-by-field without version bumps.
What's Next
You now understand:
Object types, interfaces, unions, enums, and input types
Custom scalars and the
graphql-scalarslibraryBuilt-in and custom directives
Cursor vs offset pagination patterns
Schema evolution strategy using
@deprecatedNaming conventions for consistent SDL
In Part 3, we implement this schema β building the full Apollo Server with TypeScript, Prisma, DataLoader, and real resolvers.
Last updated