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 β€” not String! or Int!. ID communicates 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 String everywhere. DateTime instead of String, Currency enum instead of String, URL scalar instead of String.

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., String to Int)

  • 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:

Element
Convention
Example

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-scalars library

  • Built-in and custom directives

  • Cursor vs offset pagination patterns

  • Schema evolution strategy using @deprecated

  • Naming 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