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.
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.
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.
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.
1.2 Code-First with Pothos v4 (Recommended for TypeScript Teams)
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.
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
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
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!notInt!— hides whether you use integer, UUID, or composite keys. Stable across storage migrations.Money!andDateTime!— semantic custom scalars fromgraphql-scalars, not rawFloatorString.OrderStatusenum — forces the client to handle all valid states explicitly; a raw string column would not.
2.3 Custom Scalars with graphql-scalars
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
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.
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
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.
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)
5.2 Unions (No Shared Fields)
On the client, union fields require inline fragments:
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.
The @deprecated directive does three things:
- Adds a deprecation warning in GraphQL tooling (Apollo Studio, IntelliJ, VS Code plugin)
- Marks the field in introspection results so clients can query for deprecated fields
- Signals intent — nothing more
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.
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."
@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.

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.

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.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.