# 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.

```graphql
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.

```graphql
enum Currency {
  USD
  EUR
  GBP
  THB
}

enum ProductStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

enum SortOrder {
  ASC
  DESC
}
```

In TypeScript resolvers, the enum values arrive as their string representation:

```typescript
// TypeScript enum that matches the GraphQL enum
export enum Currency {
  USD = 'USD',
  EUR = 'EUR',
  GBP = 'GBP',
  THB = 'THB',
}
```

### Interfaces

Interfaces define a common set of fields that multiple types must implement. Useful when different types share structural overlap but are conceptually distinct.

```graphql
interface Node {
  id: ID!
}

interface Timestamps {
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Product implements Node & Timestamps {
  id: ID!
  name: String!
  price: Float!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Category implements Node & Timestamps {
  id: ID!
  name: String!
  slug: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}
```

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:

```graphql
# Works on any type that implements Node
query {
  node(id: "prod_123") {
    id
    ... on Product {
      name
      price
    }
    ... on Category {
      name
      slug
    }
  }
}
```

### Unions

Unions let a field return one of several possible types that may not share any common fields.

```graphql
union SearchResult = Product | Category | Brand

type Query {
  search(query: String!): [SearchResult!]!
}
```

The client handles each possible type with inline fragments:

```graphql
query Search($q: String!) {
  search(query: $q) {
    ... on Product {
      id
      name
      price
    }
    ... on Category {
      id
      name
      slug
    }
    ... on Brand {
      id
      name
    }
  }
}
```

**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.

```graphql
input CreateProductInput {
  name: String!
  slug: String!
  description: String
  price: Float!
  currency: Currency!
  categoryId: ID!
  tags: [String!]
}

input UpdateProductInput {
  name: String
  slug: String
  description: String
  price: Float
  currency: Currency
  categoryId: ID
  tags: [String!]
}

input ProductFiltersInput {
  categoryId: ID
  minPrice: Float
  maxPrice: Float
  inStock: Boolean
  tags: [String!]
}

input PaginationInput {
  page: Int = 1
  pageSize: Int = 20
}
```

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:

```graphql
# Declare custom scalars in SDL
scalar DateTime    # ISO 8601 string — e.g., "2026-03-14T10:00:00Z"
scalar Date        # Date-only string — "2026-03-14"
scalar URL         # Validated URL string
scalar JSON        # Arbitrary JSON value
scalar UUID        # UUID v4 string
scalar PositiveInt # Integer > 0
```

In TypeScript, register custom scalars using `graphql-scalars` (a well-maintained library with implementations of 50+ scalars):

```bash
npm install graphql-scalars
```

```typescript
// src/schema.ts
import { gql } from 'graphql-tag';
import { DateTimeResolver, URLResolver, UUIDResolver } from 'graphql-scalars';

export const typeDefs = gql`
  scalar DateTime
  scalar URL
  scalar UUID

  type Product {
    id: UUID!
    name: String!
    price: Float!
    createdAt: DateTime!
  }
`;

export const scalarResolvers = {
  DateTime: DateTimeResolver,
  URL: URLResolver,
  UUID: UUIDResolver,
};
```

```typescript
// src/resolvers.ts
import { scalarResolvers } from './schema';

export const resolvers = {
  ...scalarResolvers,
  Query: {
    // ...
  },
};
```

### Directives

GraphQL has three built-in directives:

```graphql
type User {
  name: String @deprecated(reason: "Use fullName instead")
  fullName: String!
  nickname: String
}

query {
  user(id: "1") {
    name @skip(if: true)       # field omitted when condition is true
    fullName @include(if: true) # field included when condition is true
  }
}
```

Custom directives are powerful for cross-cutting concerns like auth (covered in Part 5):

```graphql
directive @auth on FIELD_DEFINITION
directive @hasRole(role: Role!) on FIELD_DEFINITION | OBJECT

type Mutation {
  createProduct(input: CreateProductInput!): Product! @auth
  deleteProduct(id: ID!): Boolean! @hasRole(role: ADMIN)
}
```

## Pagination: Cursor vs Offset

Pagination is one of the most important schema design decisions. The GraphQL community converged on two patterns.

### Offset Pagination (Simple)

```graphql
type ProductPage {
  items: [Product!]!
  total: Int!
  page: Int!
  pageSize: Int!
  hasNextPage: Boolean!
}

type Query {
  products(page: Int = 1, pageSize: Int = 20): ProductPage!
}
```

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)

```graphql
type ProductEdge {
  cursor: String!
  node: Product!
}

type PageInfo {
  startCursor: String
  endCursor: String
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type Query {
  products(
    first: Int
    after: String
    last: Int
    before: String
    filter: ProductFiltersInput
  ): ProductConnection!
}
```

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

```graphql
type Product {
  # Old field — kept for backwards compatibility
  price: Float! @deprecated(reason: "Use priceInfo for multi-currency support")

  # New, richer replacement
  priceInfo: PriceInfo!
}

type PriceInfo {
  amount: Float!
  currency: Currency!
  formattedPrice: String!
}
```

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:

```graphql
scalar DateTime
scalar UUID

# ─── Interfaces ─────────────────────────────────────
interface Node {
  id: UUID!
}

# ─── Enums ──────────────────────────────────────────
enum Currency { USD EUR GBP THB }
enum ProductStatus { DRAFT PUBLISHED ARCHIVED }
enum SortOrder { ASC DESC }
enum ProductSortField { NAME PRICE CREATED_AT }

# ─── Core Types ─────────────────────────────────────
type Product implements Node {
  id: UUID!
  name: String!
  slug: String!
  description: String
  status: ProductStatus!
  price: Float!
  currency: Currency!
  inStock: Boolean!
  stockCount: Int!
  category: Category!
  variants: [ProductVariant!]!
  tags: [String!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type ProductVariant implements Node {
  id: UUID!
  product: Product!
  sku: String!
  name: String!
  price: Float
  stockCount: Int!
  attributes: [VariantAttribute!]!
}

type VariantAttribute {
  name: String!
  value: String!
}

type Category implements Node {
  id: UUID!
  name: String!
  slug: String!
  parent: Category
  children: [Category!]!
  products: ProductConnection!
}

# ─── Pagination ──────────────────────────────────────
type ProductEdge {
  cursor: String!
  node: Product!
}

type PageInfo {
  startCursor: String
  endCursor: String
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

# ─── Inputs ──────────────────────────────────────────
input CreateProductInput {
  name: String!
  slug: String!
  description: String
  price: Float!
  currency: Currency!
  categoryId: UUID!
  tags: [String!]
}

input UpdateProductInput {
  name: String
  description: String
  price: Float
  currency: Currency
  categoryId: UUID
  tags: [String!]
  status: ProductStatus
}

input ProductFiltersInput {
  categoryId: UUID
  status: ProductStatus
  minPrice: Float
  maxPrice: Float
  inStock: Boolean
  tags: [String!]
}

# ─── Root Types ───────────────────────────────────────
type Query {
  product(id: UUID!): Product
  productBySlug(slug: String!): Product
  products(
    filter: ProductFiltersInput
    sortBy: ProductSortField = CREATED_AT
    sortOrder: SortOrder = DESC
    first: Int = 20
    after: String
  ): ProductConnection!
  categories: [Category!]!
}

type Mutation {
  createProduct(input: CreateProductInput!): Product!
  updateProduct(id: UUID!, input: UpdateProductInput!): Product!
  deleteProduct(id: UUID!): Boolean!
  updateStock(productId: UUID!, quantity: Int!): Product!
}

type Subscription {
  productStatusChanged(id: UUID!): Product!
  stockAlert(threshold: Int!): Product!
}
```

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](https://blog.htunnthuthu.com/getting-started/programming/graphql-101/part-3-building-graphql-api-typescript), we implement this schema — building the full Apollo Server with TypeScript, Prisma, DataLoader, and real resolvers.
