Siddhant Deval
Siddhant Deval
backend16 min read

Schema Design: The SDL as a Domain Contract

An SDL schema is not a mirror of your database — it is a contract between what your domain owns and what clients can request. Every field you expose is a commitment you must maintain forever, and every field you omit is a boundary you are consciously enforcing.

Series·Part 1 of 8

GraphQL Backend & API Design

Schema Design: The SDL as a Domain Contract

You own the schema and the resolvers — the schema is a public contract you can never silently break, and every resolver is a performance commitment you make on every query. Most GraphQL schemas ship as database mirrors with a thin query layer on top: fields named after columns, types that match join tables, and queries that look like getOrdersByUserId(userId: ID!). That approach passes code review, passes your integration tests, and then quietly traps your API in the shape of your current database forever. The discipline of SDL authoring is not about syntax — it is about deciding what your domain owns, what clients are allowed to traverse, and what implementation details you are consciously refusing to expose.

Architectural Note

New to GraphQL? This series assumes you understand the graph mental model (nodes, edges, SDL reading). If you haven't worked with GraphQL before, read GraphQL Frontend Engineering, Part 1: The Graph Mental Model first — it establishes the foundation that makes this article significantly clearer.


1. The Schema-First vs. Code-First Decision

Before writing a single field, your team must choose how the SDL is authored and how its type safety is maintained.

1.1 Schema-First SDL (Manual Resolver Types)

In schema-first, you write SDL directly and then write TypeScript resolver types by hand (or generate them via @graphql-codegen/cli). The SDL is the canonical source of truth; the resolver file must stay in sync with it manually.

GRAPHQL
# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!
}

type Order {
  id: ID!
  total: Float!
  items: [OrderItem!]!
}

type Query {
  user(id: ID!): User
}
TYPESCRIPT
// ❌ Schema-first with hand-written types — drift is guaranteed at scale
// The resolver type here is manually maintained; any SDL change that isn't
// reflected here compiles fine but fails at runtime.
const resolvers: Resolvers = {
  Query: {
    user: async (_, { id }, context) => {
      return context.db.users.findById(id);
    },
  },
};

The problem: as schemas grow across dozens of types and hundreds of fields, the manual sync between SDL and TypeScript types becomes a full-time maintenance burden. SDL changes break resolvers at runtime, not at compile time.

Pothos v4 is the current TypeScript-first schema builder for Node.js. You define your schema programmatically using a builder API, and Pothos generates the SDL from your type-safe resolver definitions. SDL/resolver type mismatches become compile-time errors, not runtime surprises.

TYPESCRIPT
// ✅ Code-first with Pothos v4 — SDL is generated from type-safe definitions
import SchemaBuilder from '@pothos/core';

const builder = new SchemaBuilder<{
  Context: { db: DatabaseClient };
}>();

const UserRef = builder.objectRef<{ id: string; name: string; email: string }>('User');

UserRef.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    email: t.exposeString('email'),
    orders: t.field({
      type: [OrderRef],
      resolve: async (user, _, ctx) => ctx.db.orders.findByUserId(user.id),
    }),
  }),
});

builder.queryField('user', (t) =>
  t.field({
    type: UserRef,
    nullable: true,
    args: { id: t.arg.id({ required: true }) },
    resolve: async (_, { id }, ctx) => ctx.db.users.findById(id),
  })
);
Crucial Requirement

Pothos v4 is the recommended schema builder for new TypeScript GraphQL projects. Nexus, the previous alternative, entered maintenance mode and is no longer actively developed. Do not start a new project with Nexus.

Dimension Schema-First + codegen Code-First (Pothos v4) Code-First (Nexus)
Type safety Strong (generated) Strongest (native) Strong (generated)
SDL/resolver drift Codegen run required Impossible (compile error) Codegen run required
Team scale DX Good Excellent Fair
Schema evolution cost SDL edit → regenerate Builder API edit → compile SDL edit → regenerate
IDE support Good Excellent Fair (maintenance mode)
Active development ✅ Active ✅ Active ❌ Maintenance only

2. Designing for Graph Traversal, Not Database Shape

This is the most common and most damaging SDL anti-pattern: designing the schema to mirror the database rather than the domain.

2.1 The Database-Mirror Anti-Pattern

GRAPHQL
# ❌ Database-mirrored schema — exposes implementation, traps evolution
type users_table {
  user_id: Int!          # DB column name exposed directly
  created_at: String!    # Leaks storage layer
  updated_at: String!    # Leaks storage layer
}

type orders_table {
  order_id: Int!
  user_id: Int!          # Join table foreign key leaked to client
  order_total: Float!    # Column alias leaked
}

type Query {
  getOrdersByUserId(userId: Int!): [orders_table]   # Endpoint-style, not traversal
  getUserByEmail(email: String!): users_table        # GET /users?email= in disguise
}

This schema cannot evolve without breaking clients. Renaming the user_id column, migrating to UUIDs, or moving orders to a separate service — any of these causes a breaking schema change. The clients have effectively taken a dependency on your database implementation.

2.2 The Domain-Contract Schema

GRAPHQL
# ✅ Domain-contract schema — traversal model, semantic types, no implementation leak
type User {
  id: ID!                   # Stable semantic ID — UUID, not DB integer
  name: String!
  email: String!
  orders(first: Int, after: String): OrderConnection!   # Traversal, not endpoint
  createdAt: DateTime!      # Semantic scalar, not raw String
}

type Order {
  id: ID!
  total: Money!             # Semantic type, not Float
  status: OrderStatus!      # Enum — not a raw String column
  items: [OrderItem!]!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

type Query {
  user(id: ID!): User       # Node query — graph traversal entry point
  viewer: User              # Current authenticated user — no ID argument needed
}
Mental Model Check

The schema is the domain model, not the database schema. Ask: if you replaced PostgreSQL with MongoDB tomorrow, would any of these field names need to change? If yes, those fields are leaking your storage layer.

The key differences:

  • User.orders — a traversal relationship, not a standalone query. The client navigates from User to their Orders in a single operation.
  • ID! not Int! — hides whether you use integer, UUID, or composite keys. Stable across storage migrations.
  • Money! and DateTime! — semantic custom scalars from graphql-scalars, not raw Float or String.
  • OrderStatus enum — forces the client to handle all valid states explicitly; a raw string column would not.

2.3 Custom Scalars with graphql-scalars

TYPESCRIPT
// ✅ Use graphql-scalars v2 instead of defining empty scalar stubs
import { DateTimeResolver, MoneyResolver, UUIDResolver } from 'graphql-scalars';

// In Pothos:
builder.addScalarType('DateTime', DateTimeResolver, {});
builder.addScalarType('UUID', UUIDResolver, {});
Performance / Safety Warning

Defining scalar DateTime in SDL without a resolver implementation does nothing — it accepts any string value with zero validation. Always use graphql-scalars which provides serialization, parsing, and validation out of the box.


3. The Non-Null Contract: What ! Actually Means

The ! modifier is the most misunderstood SDL token. It is not a performance hint or an optimization — it is a guarantee you are making to every client.

3.1 What Non-Null Promises

GRAPHQL
# ❌ Over-aggressive non-null — a federation subgraph outage makes this a lie
type User {
  id: ID!
  name: String!        # You are guaranteeing this NEVER returns null.
                       # If your DB is down, what happens?
  loyaltyPoints: Int!  # What if the loyalty service is unavailable?
}

When a non-null field's resolver returns null (due to a partial failure, a missing record, or a service outage), GraphQL propagates the null upward through the parent tree until it hits a nullable ancestor — or the root, causing the entire response data field to become null. A single over-aggressive ! can silence an entire page of data.

GRAPHQL
# ✅ Non-null only where the guarantee is unconditional
type User {
  id: ID!              # Always present — existence precondition
  name: String!        # Core identity field — always populated at write time
  email: String!       # Always populated at registration
  loyaltyPoints: Int   # nullable — external service; may be unavailable
  preferredAddress: Address  # nullable — optional relationship
}
Crucial Requirement

The @semanticNonNull directive (Stage 2 RFC, 2025) addresses this precisely: it distinguishes "this field is non-null by design" (!) from "this field is non-null unless the resolver errors" (@semanticNonNull). Once stable, it will allow clients to treat a field as non-null for happy-path rendering while still handling resolver errors correctly.

3.2 Non-Null in Input Types

GRAPHQL
# ✅ Input type non-null semantics — required fields at write time
input CreateOrderInput {
  userId: ID!          # Required — no order without an owner
  items: [OrderItemInput!]!  # Required — no empty orders
  shippingAddressId: ID      # Optional — may use account default
  notes: String              # Optional — free-form
}

For input types, ! means the client must provide this field. Missing it is a validation error, not a runtime error. This is the safe and correct use of non-null in inputs.


4. Input Types vs. Inline Arguments

When a mutation or query takes more than two arguments, always use an input type. This is not a style preference — it is a schema evolution decision.

GRAPHQL
# ❌ Inline arguments — adding a new required arg is a breaking change
type Mutation {
  createOrder(userId: ID!, items: [ID!]!, addressId: ID!): Order!
}

# Adding `couponCode` as a new required arg breaks every existing client operation.
# Even adding it as optional changes the resolver signature.
GRAPHQL
# ✅ Input type — safe to add optional fields forever
type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
}

input CreateOrderInput {
  userId: ID!
  items: [OrderItemInput!]!
  addressId: ID
  couponCode: String     # Added in v2 — zero breaking change for existing clients
  giftMessage: String    # Added in v3 — still zero breaking change
}

# Wrapping the return in a Payload type allows adding fields to the response
# without a breaking change as well.
type CreateOrderPayload {
  order: Order
  errors: [UserError!]!
}
Pro Tip & Optimization

Always wrap mutation return types in a dedicated Payload type. This allows you to add metadata fields (estimatedDeliveryDate, appliedDiscount, warnings) to the response over time without changing the mutation signature.


5. Interfaces and Unions for Polymorphic Fields

When a field can return different object types, interfaces and unions are the type-safe mechanism — not JSON scalars or string-tagged objects.

5.1 Interfaces (Shared Field Contract)

GRAPHQL
# Scenario: A notification feed returns different notification types
interface Notification {
  id: ID!
  createdAt: DateTime!
  read: Boolean!
}

type OrderNotification implements Notification {
  id: ID!
  createdAt: DateTime!
  read: Boolean!
  order: Order!
  message: String!
}

type SystemNotification implements Notification {
  id: ID!
  createdAt: DateTime!
  read: Boolean!
  severity: NotificationSeverity!
  body: String!
}
TYPESCRIPT
// ✅ __resolveType is required for interface resolution
const NotificationResolver = {
  Notification: {
    __resolveType(obj: { order?: unknown; severity?: unknown }) {
      if (obj.order !== undefined) return 'OrderNotification';
      if (obj.severity !== undefined) return 'SystemNotification';
      return null;
    },
  },
};

5.2 Unions (No Shared Fields)

GRAPHQL
# Scenario: Search returns heterogeneous result types with no shared fields
union SearchResult = User | Product | Article | BlogPost

type Query {
  search(query: String!, first: Int): [SearchResult!]!
}

On the client, union fields require inline fragments:

GRAPHQL
query Search($q: String!) {
  search(query: $q, first: 10) {
    ... on User { id name }
    ... on Product { id title price }
    ... on Article { id headline slug }
  }
}
Performance / Safety Warning

Never use JSON scalar for polymorphic return types. A JSON scalar bypasses the type system entirely — the client receives an untyped blob, codegen generates unknown, and any field access is a runtime guess. Use interfaces and unions; they cost one __resolveType implementation and pay dividends in type safety forever.


6. @deprecated and the Sunset Plan

@deprecated is the SDL's only built-in migration signal. It is widely misunderstood as a removal mechanism. It is not.

GRAPHQL
type User {
  # ❌ This marks the field — it does NOT remove it, hide it, or notify clients.
  # Client tooling (Apollo Studio, GraphQL IDE) will show a warning.
  # The field is still fully executable. Traffic continues until clients migrate.
  email: String @deprecated(reason: "Use `primaryEmail` instead. Removal planned: 2026-Q4.")
  primaryEmail: EmailAddress!
}

The @deprecated directive does three things:

  1. Adds a deprecation warning in GraphQL tooling (Apollo Studio, IntelliJ, VS Code plugin)
  2. Marks the field in introspection results so clients can query for deprecated fields
  3. Signals intent — nothing more
Crucial Requirement

Never remove a deprecated field without first confirming zero client usage. graphql-inspector field-usage or Apollo GraphOS field insights provide the "last seen" date for every deprecated field across all registered client operations. Removal without this data is guesswork that will silently break a client you forgot about.

What NOT to Include in the Schema

The following categories of fields are implementation leaks — they belong in your data layer, not your API contract:

Anti-Pattern Why It's Wrong Correct Approach
user_id: Int! (DB column names) Exposes storage layer; breaks on UUID migration id: ID! — stable opaque identifier
created_at: String! (raw timestamp strings) No validation, no timezone semantics createdAt: DateTime! via graphql-scalars
Join table fields (order_user_id) Database implementation detail Expose only the traversal: order { user { ... } }
getOrdersByUserId(userId: ID!): [Order] REST endpoint disguised as GraphQL user(id: ID!) { orders { ... } } graph traversal
Internal computed fields (e.g. internalScore: Float) Leaks business logic not meant for clients Omit entirely or gate behind @requiresScopes

7. The @semanticNonNull RFC (2025)

The GraphQL Working Group Stage 2 RFC for @semanticNonNull addresses a fundamental limitation of the current type system: ! means two different things — "guaranteed non-null by domain invariant" and "non-null unless the resolver throws." There is currently no way to distinguish them.

GRAPHQL
# With @semanticNonNull (Stage 2 RFC, not yet stable):
type User {
  id: ID!                        # Hard non-null: guaranteed by existence
  name: String @semanticNonNull  # Non-null in the happy path, but nullable on resolver error
                                 # Clients can treat it as non-null for rendering
                                 # but error boundaries still catch resolver failures
}

This RFC changes how client codegen tools generate types: a @semanticNonNull field generates a non-null TypeScript type for the happy path, while also correctly modeling the null case in error conditions. When it reaches stable, it will be the preferred replacement for the current practice of marking fields nullable "just in case."

Architectural Note

@semanticNonNull is Stage 2 as of 2025 — it is not available for production use yet. Pothos and Apollo Client have both signaled intent to support it once it stabilizes. Watch the graphql/graphql-spec repository for updates.


Before/After: database-mirrored schema vs. domain-contract schema showing the correct field design
Before/After: database-mirrored schema vs. domain-contract schema showing the correct field design

Left: a database-mirrored schema exposes column names, join table artifacts, and endpoint-style queries. Right: a domain-contract schema exposes semantic types, graph traversal relationships, and no implementation details.

Comparison Matrix: Schema-first + codegen vs. Pothos v4 code-first vs. Nexus across type safety, drift risk, team scale, and evolution cost
Comparison Matrix: Schema-first + codegen vs. Pothos v4 code-first vs. Nexus across type safety, drift risk, team scale, and evolution cost

Pothos v4 eliminates SDL/resolver type drift entirely by generating the SDL from type-safe builder definitions. Nexus is in maintenance mode and should not be used for new projects.


Summary

Concept Rule
Schema is a contract A GraphQL schema is append-only in practice — removing or changing a field breaks every client operation that uses it, with no compile-time warning on the client side.
Pothos v4 The recommended TypeScript schema builder: generates SDL from type-safe resolver signatures, making SDL/resolver type mismatches a compile error rather than a runtime surprise.
! is a guarantee Mark a field non-null only if the resolver will never return null — including under partial failure conditions and subgraph outages.
Use input types Any mutation argument with more than 2 fields must use an input type — adding an optional field to an input type is non-breaking; adding a new argument to a mutation field is breaking.
@deprecated is documentation only It does not hide the field, prevent its use, or notify clients. The sunset plan (measure usage → communicate window → remove) must be managed separately.
What NOT to expose Internal database IDs, join table artifacts, raw timestamp strings, server-computed implementation fields, and endpoint-style queries disguised as GraphQL.

What's Next

In Part 2 — Resolvers, Context & DataLoader, we address the performance contract implied by every field in this schema: the N+1 problem is not a GraphQL weakness — it is the default behavior of naive resolver implementation. Every field you just added to User.orders will execute one database query per user unless you implement DataLoader correctly. Part 2 covers why DataLoader is the minimum correct implementation, not an optimization.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#API Design#Pothos#Schema Design#TypeScript
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.