GraphQL: Schema, Operations, and Federation
Table of Contents
- 1. Concepts
- 2. Custom Scalars
- 3. Library Landscape
- 4. Federation and Mesh
- 5. Observability: Telemetry and Spans
- 6. Domain Modeling
- 7. Performance Patterns
- 8. Federation
- 9. Maintaining GraphQL
- 10. Tools
- 11. Reading
- 11.1. Production Ready GraphQL
- 11.1.1. An Introduction to GraphQL
- 11.1.2. GraphQL Schema Design
- 11.1.3. Implementing GraphQL Servers
- 11.1.4. Security
- 11.1.5. Performance & Monitoring
- 11.1.6. Tooling
- 11.1.7. Workflow
- 11.1.8. Public GraphQL APIs
- 11.1.9. GraphQL in a Distributed Architecture
- 11.1.10. Versioning
- 11.1.11. Documenting GraphQL APIs
- 11.1.12. Migrating From Other API Styles
- 11.1. Production Ready GraphQL
- 12. Videos
- 13. Conferences
- 14. Request lifecycle and resolver execution
- 15. Related notes
- 16. Postscript (2026)
1. Concepts
GraphQL is a specification for an API query language and server engine capable of executing such queries.
- Document has many operations or fragments (executables) or type system information
- Operation types may be queries, mutations, and subscriptions
1.1. Contrasting REST
- partials
- versions
- fields
1.2. Parsing
- Whitespace and line terminations are as expected
- Tokens
- The "query" keyword and name may not be present for [{ field }]
1.3. Operations
- query: read-only fetch
- mutation: write + fetch
- subscription: response to source events
1.4. Selection Sets
{
id
firstName
lastName
}
- Can contain aliases
1.5. Types
- ID
- String
- DateTime
1.6. Schemas
The SDL provides a language for describing the type system for GraphQL.
1.7. Schema Root
The schema root defines the entry points for each operation type. Every
GraphQL service must declare at least a Query type; Mutation and
Subscription are optional.
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Query {
user(id: ID!): User
users(filter: UserFilter, first: Int, after: String): UserConnection!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
type Subscription {
postAdded(authorId: ID): Post!
commentAdded(postId: ID!): Comment!
}
The Query, Mutation, and Subscription type names are conventional
defaults. The schema { } block is optional when you use these exact
names; it becomes required only when you diverge from them.
1.8. Fragments
query withFragments { user(id: 4) { friends(first: 10) { ...friendFields } mutualFriends(first: 10) { ...friendFields } } } fragment friendFields on User { id name profilePic(size: 50) }
1.9. Queries
A query operation is a read-only fetch. The query keyword and an
operation name are both optional for single-operation documents but are
strongly recommended in production: named operations appear in server
logs, APM tools, and persisted-query registries.
# Anonymous (fine for exploration, avoid in production) { user(id: "42") { name email } } # Named — preferred query GetUser($id: ID!) { user(id: $id) { name email posts(first: 5) { title publishedAt } } }
1.9.1. Variables
Variables decouple the query document from its runtime arguments. The document is parsed and validated once; only the variable values change between requests. This is a prerequisite for persisted queries (APQ / Trusted Documents).
# Operation document (sent once, cached by hash) query GetPost($postId: ID!, $commentCount: Int = 10) { post(id: $postId) { title body author { name } comments(first: $commentCount) { text createdAt } } }
Variables are transmitted as a separate JSON object:
{
"postId": "post-789",
"commentCount": 5
}
Variable typing rules:
- Non-null variables (
ID!) must be provided or have a default. - List variables (
[String!]!) require the full list;nullis not accepted. - Default values (
Int = 10) let clients omit the variable entirely.
1.10. Mutations
Mutations follow the same syntax as queries but signal a write. The spec requires that multiple root-level mutations execute serially (not in parallel), so the order of fields in a mutation document is meaningful.
mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { post { id title slug } errors { field message } } }
The Relay mutation convention wraps every mutation in an input argument
and returns a payload type. This keeps mutations extensible: new fields
can be added to CreatePostInput and CreatePostPayload without breaking
existing clients.
1.11. Enums
1.12. Abstract Types
interface Animal { name: String! } interface Plant { genus: String! species: String! }
1.13. Unions
union Entity = Plant | Animal
2. Custom Scalars
The GraphQL spec ships five built-in scalars: Int, Float, String,
Boolean, ID. Real schemas need more. Custom scalars let you encode
domain types while keeping validation and serialization logic in one
place.
2.1. Community Standard: graphql-scalars
The graphql-scalars package (maintained by The Guild) is the de-facto standard library for commonly-needed custom scalars. It ships spec-compliant implementations for:
| Scalar | Wire format | Typical use |
|---|---|---|
| DateTime | ISO 8601 string | All timestamp fields |
| Date | YYYY-MM-DD string | Calendar dates without time |
| JSON | Any JSON value | Freeform metadata / JSONB columns |
| JSONObject | JSON object | Typed freeform maps |
| URL | String (RFC 3986) | Links, webhooks, avatar hrefs |
| EmailAddress | String (RFC 5321) | User email fields |
| UUID | String (RFC 4122) | Stable identifiers |
| BigInt | String or number | 64-bit integers beyond JS Number |
| PositiveInt | Int > 0 | Pagination counts, quantities |
| NonEmptyString | String, len >= 1 | User-facing required text fields |
scalar DateTime scalar UUID scalar JSON scalar EmailAddress scalar URL type User { id: UUID! email: EmailAddress! createdAt: DateTime! profile: JSON avatarUrl: URL }
2.2. Registering Custom Scalars per Ecosystem
2.2.1. JavaScript / TypeScript
import { makeExecutableSchema } from '@graphql-tools/schema'; import { DateTimeResolver, UUIDResolver } from 'graphql-scalars'; const typeDefs = `#graphql scalar DateTime scalar UUID `; const resolvers = { DateTime: DateTimeResolver, UUID: UUIDResolver, }; const schema = makeExecutableSchema({ typeDefs, resolvers });
2.2.2. Python (Strawberry)
import strawberry from datetime import datetime # Strawberry maps Python datetime -> ISO 8601 string automatically. # For fully custom scalars, use strawberry.scalar(): from uuid import UUID UUIDScalar = strawberry.scalar( UUID, serialize=str, parse_value=UUID, description="UUID string in RFC 4122 format", ) @strawberry.type class User: id: strawberry.ID email: str created_at: datetime
2.2.3. Clojure (Lacinia)
Lacinia registers custom scalars in the schema EDN map under
:scalars. Each scalar must provide :parse and :serialize transformer
functions:
{:scalars
{:DateTime
{:description "ISO 8601 UTC timestamp"
:parse com.example.scalars/parse-datetime
:serialize com.example.scalars/serialize-datetime}
:UUID
{:description "RFC 4122 UUID string"
:parse com.example.scalars/parse-uuid
:serialize str}}
:objects
{:User
{:fields
{:id {:type (non-null :UUID)}
:email {:type (non-null String)}
:createdAt {:type (non-null :DateTime)}}}}}
2.2.4. Ruby (graphql-ruby)
# app/graphql/scalars/date_time_scalar.rb class Scalars::DateTimeScalar < GraphQL::Schema::Scalar description "ISO 8601 UTC timestamp" def self.coerce_input(value, _ctx) Time.parse(value) rescue ArgumentError raise GraphQL::CoercionError, "#{value.inspect} is not a valid DateTime" end def self.coerce_result(value, _ctx) value.utc.iso8601 end end
3. Library Landscape
3.1. Clojure
| Library | Role | Approach | Status (2026) |
|---|---|---|---|
| Lacinia | Server | Schema-first (EDN) | Active; v0.45+ |
| Hodur | Domain modeling | Model-first | Stable; low-churn |
| re-graph | ClojureScript client | Subscriptions OK | Active |
| Pathom 3 | EQL / resolver mesh | Code-first | Active; expanding fast |
3.1.1. Lacinia (Walmart Labs)
Lacinia is the dominant Clojure GraphQL server library. The schema is expressed as EDN data (not code), which makes it easy to generate, transform, and test the schema independently of any resolver logic.
Key design decisions:
- Resolvers are pure functions
(fn [ctx args val] ...)— no protocol implementation required. - Field resolvers receive
context, the field'sargsmap, and the parent object'sval. - Batching uses
com.walmartlabs.lacinia.executor/selects-field?and the built-inBatchedFieldResolverprotocol for DataLoader-style coalescing. - Subscriptions use a
SourceStreamFactorythat integrates with core.async channels.
;; Minimal Lacinia schema fragment (EDN) {:objects {:Post {:fields {:id {:type (non-null ID)} :title {:type (non-null String)} :body {:type String} :author {:type :User :resolve :resolve-post-author}}}} :User {:fields {:id {:type (non-null ID)} :name {:type (non-null String)}}}} :queries {:post {:type :Post :description "Fetch a post by its ID" :args {:id {:type (non-null ID)}} :resolve :resolve-post}}}
;; Resolver function — pure data in, pure data out (defn resolve-post [ctx {:keys [id]} _parent] (db/find-post (:db ctx) id))
3.1.2. Hodur
Hodur models a domain once as annotated Clojure data, then generates multiple artefacts from that single source of truth: Lacinia schemas, Datomic schemas, and documentation. Useful when the domain model is shared across persistence and API layers.
3.1.3. re-graph
ClojureScript GraphQL client with support for queries, mutations, and subscriptions over WebSocket. Works with Apollo Server's subscription protocol and with Lacinia-pedestal's subscription support.
3.1.4. Pathom 3
Pathom is not a GraphQL server in the traditional sense; it is a
resolver engine that understands EQL (a superset of EDN Path Language).
Its pathom-graphql bridge exposes a Pathom graph as a GraphQL endpoint.
The key advantage is that resolvers declare their input/output
attributes rather than their position in a fixed object graph — Pathom
plans the traversal automatically.
3.2. Python
| Library | Approach | Async | Django / SQLAlchemy | Status (2026) |
|---|---|---|---|---|
| Strawberry | Code-first | Native | Optional extras | Active; momentum |
| Graphene | Code-first | Add-on | First-class | Maintained; legacy |
| Ariadne | Schema-first | Native | Framework-agnostic | Active |
| sgqlc | Client only | No | N/A | Stable, low-churn |
3.2.1. Strawberry
Strawberry is the current momentum pick for new Python GraphQL servers.
It uses Python type hints and @strawberry.type / @strawberry.field
decorators to derive the GraphQL schema from the type system, with no
separate schema definition file needed.
import strawberry from typing import Optional from datetime import datetime @strawberry.type class Post: id: strawberry.ID title: str published_at: datetime body: Optional[str] = None @strawberry.type class Query: @strawberry.field async def post(self, id: strawberry.ID) -> Optional[Post]: return await Post.objects.get(id=id) schema = strawberry.Schema(query=Query)
Strawberry ships a Django view, a FastAPI router, and an ASGI handler
out of the box. The strawberry-graphql-django package adds automatic
DjangoObjectType-style integration comparable to Graphene's.
3.2.2. Graphene
Graphene remains widely deployed; its Django, SQLAlchemy, and MongoDB integrations are battle-tested. New projects increasingly choose Strawberry, but the Graphene installed base is large and the library is actively maintained for bug fixes and minor additions.
3.2.3. Ariadne
Ariadne takes the schema-first (SDL-first) route: you write the SDL in
a .graphql file and bind Python resolver functions to it. This is the
preferred pattern when the SDL is a design contract agreed upon before
any code is written.
from ariadne import QueryType, make_executable_schema type_defs = """ type Query { post(id: ID!): Post } type Post { id: ID! title: String! body: String } """ query = QueryType() @query.field("post") async def resolve_post(*_, id): return await fetch_post(id) schema = make_executable_schema(type_defs, query)
3.3. Ruby
| Library | Role | Approach | Status (2026) |
|---|---|---|---|
| graphql-ruby | Server | Code-first + SDL | Active; v2.x |
| graphiti | REST-to-GraphQL | Resource-based | Stable |
3.3.1. graphql-ruby
graphql-ruby (Robert Mosolgo) is the dominant Ruby GraphQL library.
Version 2.x uses a class-based DSL. The DataLoader pattern is provided
by the built-in GraphQL::Dataloader which integrates with the
execution engine to batch N+1 field lookups automatically.
# Type definition class Types::PostType < Types::BaseObject field :id, ID, null: false field :title, String, null: false field :author, Types::UserType, null: false def author dataloader.with(Sources::UserSource).load(object.author_id) end end # DataLoader source class Sources::UserSource < GraphQL::Dataloader::Source def fetch(user_ids) User.where(id: user_ids).index_by(&:id) end end
Key patterns in 2026:
GraphQL::Dataloaderis the standard N+1 solution; replacebatch-loadergem usage with it for new code.- Complexity analysis via
max_complexityand per-fieldcomplexity:annotations guards against expensive queries. - The
early_returnsetting on resolvers enables short-circuit evaluation for authorization.
4. Federation and Mesh
Federation solves a specific problem: multiple teams each own a subgraph of the schema, and a gateway presents a unified graph to clients. Schema stitching solved the same problem but required centralized resolver proxying; federation pushes entity resolution into each subgraph.
4.1. Apollo Federation v2
Federation v2 uses directives to annotate types and fields across subgraphs:
| Directive | Subgraph | Meaning |
|---|---|---|
@key |
Both | Declares the entity's primary key field(s) |
@external |
Referencing | Field is defined in another subgraph |
@requires |
Referencing | Field requires @external fields to resolve |
@provides |
Providing | Field can eagerly return data from another subgraph |
@shareable |
Both | Type/field may be resolved by multiple subgraphs |
@inaccessible |
Any | Hides a field from the composed schema |
@override |
Subgraph | Migrates a field from another subgraph |
# Users subgraph type User @key(fields: "id") { id: ID! name: String! email: String! } # Posts subgraph type Post @key(fields: "id") { id: ID! title: String! author: User! @requires(fields: "authorId") authorId: ID! @external } extend type User @key(fields: "id") { id: ID! @external posts: [Post!]! }
The composed supergraph schema (produced by the Rover CLI or automatically by GraphOS) is validated for type compatibility before deployment.
4.1.1. Apollo Router vs Cosmo
| Runtime | Language | Licence | Status (2026) |
|---|---|---|---|
| Apollo Router | Rust | Elastic v2 | Production-grade; dominant |
| Cosmo | Go | Apache 2.0 | Growing; WunderGraph-backed |
| Apollo Gateway | Node.js | MIT | Deprecated for new installs |
Apollo Router replaced the Node.js @apollo/gateway and cut
query-planning latency by roughly 10x in benchmarks. WunderGraph's
Cosmo is a fully open-source alternative — router, control plane, and
composition tooling — gaining adoption where the Elastic v2 licence on
Apollo Router is a concern.
4.2. GraphQL Mesh
GraphQL Mesh wraps arbitrary upstream protocols (REST/OpenAPI, gRPC, SOAP, OData, SQL, MongoDB) as GraphQL subgraphs, then stitches or federates them into a unified API. The pattern is: install a handler for each source type, let Mesh generate the GraphQL schema from the source's schema/spec, and then add transforms (rename types, filter fields, merge cross-source relations).
# .meshrc.yaml sources: - name: UsersREST handler: openapi: source: https://api.example.com/openapi.json - name: PostsGRPC handler: grpc: endpoint: localhost:50051 protoFilePath: proto/posts.proto transforms: - rename: renames: - from: { type: Users_User } to: { type: User }
4.3. Schema Stitching vs Federation vs Mesh
| Approach | Best for | Avoid when |
|---|---|---|
| Schema stitching | Small number of in-house services, single team | More than 3-4 services; multi-team |
| Federation v2 | Multi-team, independently deployed subgraphs | Single team; all sources are GraphQL |
| GraphQL Mesh | Wrapping non-GraphQL sources (REST, gRPC, legacy) | Sources already speak GraphQL natively |
5. Observability: Telemetry and Spans
GraphQL's flexible query structure makes traditional HTTP metrics
insufficient. Two requests to /graphql can have wildly different cost
profiles depending on the operation and the depth of the selection set.
Field-level instrumentation is required.
5.1. OpenTelemetry for GraphQL
The OpenTelemetry specification
covers GraphQL via semantic conventions under
graphql.operation.type, graphql.operation.name, and
graphql.document. A compliant GraphQL server should emit:
- A root span per request (HTTP layer, labelled with operation name)
- A child span per resolver invocation (field resolution)
- A child span per DataLoader batch flush (grouped by loader name)
- A child span per downstream I/O call (DB query, REST call, gRPC)
POST /graphql
graphql.operation.type = query
graphql.operation.name = GetUserWithPosts
resolver: Query.user (2ms)
resolver: User.posts (1ms)
dataloader: postsByUser batch(ids=[42]) -> 12 rows (8ms)
resolver: Post.author x12 (0ms each — cache hits)
5.2. Per-Language Tracing Hooks
5.2.1. Lacinia
Lacinia exposes an executor/tracer hook: a map of functions called at
parse, validate, execute, and field-resolve phases. Integrate with
OpenTelemetry by recording spans in the tracer callbacks:
(defn otel-tracer [] {:start-parse (fn [ctx] (start-span ctx :parse)) :finish-parse (fn [ctx] (end-span ctx :parse)) :start-execute (fn [ctx] (start-span ctx :execute)) :finish-execute (fn [ctx] (end-span ctx :execute)) :start-resolve-field (fn [ctx] (start-span ctx (:com.walmartlabs.lacinia/field-name ctx))) :finish-resolve-field (fn [ctx] (end-span ctx (:com.walmartlabs.lacinia/field-name ctx)))})
5.2.2. Strawberry
Strawberry has a first-class extensions API. The
strawberry-django-plus package ships an OpenTelemetryExtension that
instruments resolver invocations automatically:
import strawberry from strawberry.extensions.tracing import OpenTelemetryExtension schema = strawberry.Schema( query=Query, extensions=[OpenTelemetryExtension], )
5.2.3. graphql-ruby
graphql-ruby exposes a tracer interface. Pass an OpenTelemetry-aware
tracer to the schema:
class AppSchema < GraphQL::Schema tracer(GraphQL::Tracing::OpenTelemetryTracing) end
The built-in DatadogTracing, PrometheusTracing, and NewRelicTracing
modules follow the same pattern.
5.3. Field-Level Metrics
Field-level metrics (latency histogram, error count, usage rate per field) are used to:
- Drive schema evolution (safe to remove a field with 0 usage)
- Set resolver SLOs
- Detect slow resolvers before they become incidents
Apollo GraphOS collects these metrics server-side. Open-source alternatives:
- GraphQL Hive — schema registry
- usage analytics; self-hostable.
- Grafana with a Prometheus scrape of the
graphql_field_execution_timehistogram exposed by Strawberry or graphql-ruby's Prometheus tracer.
5.4. Federation + Telemetry Lifecycle Diagram
// GraphQL federation + telemetry + DataLoader lifecycle // Palette: wal.sh style guide (Tailwind 100/700 pairs, full 6-digit hex) digraph graphql_federation { rankdir=LR; bgcolor=white; graph [fontname="Helvetica", fontsize=11, pad="0.4", nodesep="0.35", ranksep="0.55"]; node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=10, fillcolor="#dbeafe", color="#888888", fontcolor="#555555"]; edge [color="#888888", fontcolor="#555555", fontname="Helvetica", fontsize=9]; // ---- Client tier ---- subgraph cluster_client { label="Client"; style="rounded,filled"; color="#1d4ed8"; fillcolor="#eff6ff"; fontcolor="#1d4ed8"; fontname="Helvetica"; fontsize=11; client [label="Browser / App\n(query or mutation)", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"]; } // ---- Router / Gateway tier ---- subgraph cluster_router { label="Router (Apollo Router / Cosmo)"; style="rounded,filled"; color="#6b21a8"; fillcolor="#f5f3ff"; fontcolor="#6b21a8"; fontname="Helvetica"; fontsize=11; router [label="Query planner\nparse · validate · plan", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; qp [label="Execution plan\n(fetch nodes)", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; router -> qp [color="#6b21a8"]; } // ---- OTel collector ---- subgraph cluster_otel { label="OpenTelemetry Collector"; style="rounded,filled"; color="#b45309"; fillcolor="#fffbeb"; fontcolor="#b45309"; fontname="Helvetica"; fontsize=11; otel [label="Span collector\n(OTLP / gRPC)", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"]; metrics [label="Field-level metrics\n(latency, error rate)", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"]; otel -> metrics [color="#b45309"]; } // ---- Subgraph tier ---- subgraph cluster_subgraphs { label="Subgraphs (federated services)"; style="rounded,filled"; color="#15803d"; fillcolor="#f0fdf4"; fontcolor="#15803d"; fontname="Helvetica"; fontsize=11; sgA [label="Users subgraph\n@key(fields: \"id\")", fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"]; sgB [label="Posts subgraph\n@key(fields: \"id\")\n@requires(fields: \"authorId\")", fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"]; sgC [label="Media subgraph\n@key(fields: \"id\")", fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"]; } // ---- DataLoader tier ---- subgraph cluster_dl { label="DataLoader (per-request batch + cache)"; style="rounded,filled"; color="#a16207"; fillcolor="#fefce8"; fontcolor="#a16207"; fontname="Helvetica"; fontsize=11; dlU [label="userById\nbatch(ids[])", fillcolor="#fef9c3", color="#a16207", fontcolor="#a16207"]; dlP [label="postsByUser\nbatch(userIds[])", fillcolor="#fef9c3", color="#a16207", fontcolor="#a16207"]; dlHit [label="cache hit\n(same request)", fillcolor="#fef9c3", color="#a16207", fontcolor="#a16207", style="rounded,filled,dashed"]; } // ---- Data sources ---- subgraph cluster_sources { label="Data sources"; style="rounded,filled"; color="#6b21a8"; fillcolor="#f5f3ff"; fontcolor="#6b21a8"; fontname="Helvetica"; fontsize=11; db [label="Postgres\n(users, posts)", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; rest [label="REST API\n(legacy media service)", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; cache [label="Redis\n(response cache)", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; } // ---- Main flow edges ---- client -> router [label="HTTP POST\napplication/graphql-response+json", color="#1d4ed8", fontcolor="#1d4ed8"]; qp -> sgA [label="fetch plan\n(subgraph A)", color="#15803d", fontcolor="#15803d"]; qp -> sgB [label="fetch plan\n(subgraph B)", color="#15803d", fontcolor="#15803d"]; qp -> sgC [label="fetch plan\n(subgraph C)", color="#15803d", fontcolor="#15803d"]; // ---- Subgraph -> DataLoader ---- sgA -> dlU [color="#a16207", fontcolor="#a16207"]; sgB -> dlP [color="#a16207", fontcolor="#a16207"]; sgB -> dlHit [color="#a16207", fontcolor="#a16207", style=dashed]; // ---- DataLoader -> sources ---- dlU -> db [color="#6b21a8", fontcolor="#6b21a8"]; dlP -> db [color="#6b21a8", fontcolor="#6b21a8"]; sgC -> rest [color="#6b21a8", fontcolor="#6b21a8"]; router -> cache [label="APQ / response\ncache check", color="#6b21a8", fontcolor="#6b21a8", style=dashed]; // ---- Telemetry spans (all tiers emit to collector) ---- router -> otel [label="span: router", color="#b45309", fontcolor="#b45309", style=dashed]; sgA -> otel [label="span: resolver", color="#b45309", fontcolor="#b45309", style=dashed]; sgB -> otel [label="span: resolver", color="#b45309", fontcolor="#b45309", style=dashed]; dlU -> otel [label="span: batch", color="#b45309", fontcolor="#b45309", style=dashed]; }
6. Domain Modeling
6.1. Schema-First vs Code-First vs Model-First
| Approach | SDL defined by | Schema lives in | Best for |
|---|---|---|---|
| Schema-first | Human-written SDL | .graphql file |
API-contract-first teams; Ariadne |
| Code-first | Type annotations/DSL | Generated from code | Strawberry, graphql-ruby, Lacinia |
| Model-first | Domain model tool | Generated from model | Hasura, PostGraphile, Prisma |
Model-first approaches auto-generate a GraphQL schema from a database schema or domain model definition. They minimise boilerplate for CRUD-heavy APIs but constrain the schema shape to what the generator supports.
6.2. Type Generation
| Tool | Input | Output |
|---|---|---|
| Prisma | Prisma schema (model) | TypeScript types + CRUD resolvers |
| Hasura | Postgres schema | Full GraphQL + subscriptions |
| PostGraphile | Postgres schema | Schema via reflection |
| graphql-code-generator | SDL / operations | TypeScript, Kotlin, Swift types |
graphql-code-generator is the standard tool for generating typed
client code from SDL and operation documents. It supports TypeScript
React Query hooks, Kotlin data classes, Swift Codable structs, and more.
6.3. Relay Connection Spec (Cursor Pagination)
The Relay connection spec is the de-facto standard for paginated lists in GraphQL. It uses opaque cursors rather than page numbers, which works correctly with real-time data (items inserted between pages do not cause duplicates or gaps).
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } type PostEdge { cursor: String! node: Post! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } type Query { posts(first: Int, after: String, last: Int, before: String): PostConnection! }
Pagination arguments:
- Forward:
first+after(most common) - Backward:
last+before(for reverse traversal) - Omitting
after/beforestarts from the beginning / end respectively
6.4. Nullability Conventions
The GraphQL spec defaults to nullable fields. This is a safety net but creates verbose client code. Two conventions have emerged:
- Default nullable (spec default): every field is nullable unless
explicitly marked non-null with
!. Clients must null-check everything. Dominant in older schemas. - Semantic nullability (proposed): a field is non-null unless the
resolver can genuinely return null for a domain reason (e.g.,
optional relationship). Errors propagate differently from null
values. The
@semanticNonNulldirective (in progress at the GraphQL working group) formalises this.
Practical recommendation: mark fields non-null (!) whenever the
resolver will never intentionally return null for that field. Use
nullable types only to express optionality in the domain, not as a
hedge against resolver bugs.
6.5. Input Types, Interfaces, and Unions
Input types are used exclusively for mutation arguments. They cannot contain interfaces or unions; all fields must be scalars, enums, or other input types.
input CreatePostInput { title: String! body: String tags: [String!] status: PostStatus! = DRAFT } enum PostStatus { DRAFT PUBLISHED ARCHIVED }
Interfaces define a contract that concrete types implement. Use interfaces when multiple types share a field set but differ in additional fields:
interface Node { id: ID! } interface Timestamped { createdAt: DateTime! updatedAt: DateTime! } type Post implements Node & Timestamped { id: ID! createdAt: DateTime! updatedAt: DateTime! title: String! }
Unions express "one of these types" without any shared fields. Common for search results and heterogeneous feeds:
union SearchResult = Post | User | Comment type Query { search(query: String!): [SearchResult!]! }
Clients must use inline fragments to select fields on union members:
query Search($q: String!) { search(query: $q) { ... on Post { id title publishedAt } ... on User { id name avatarUrl } ... on Comment { id text post { title } } } }
7. Performance Patterns
7.1. DataLoader: Preventing N+1 Queries
The N+1 problem is the most common performance failure in GraphQL. If a
resolver for a list of 100 posts each triggers an individual database
query for post.author, the result is 101 queries (1 for the list + 100
for authors) instead of 2 (1 list + 1 batched author lookup).
The DataLoader pattern solves this with two mechanisms:
- Batching: accumulate all keys requested in a single event-loop tick, then dispatch one batch request.
- Caching: within a single request, the same key is resolved only once.
| Language | Library |
|---|---|
| JavaScript | dataloader (npm, original Facebook) |
| Python | strawberry-django auto-DataLoader |
| Ruby | graphql-ruby built-in DataLoader |
| Clojure | Lacinia BatchedFieldResolver |
| Go | graph-gophers/dataloader |
| Rust | async-graphql built-in batching |
7.2. Persisted Queries and Trusted Documents
Automatic Persisted Queries (APQ) reduce bandwidth by sending a SHA-256 hash of the query document on the first request. On cache miss, the client retransmits the full document; on hit, only the hash is needed.
Trusted Documents (previously "persisted queries" in the security sense) go further: the server only executes pre-registered operations. Arbitrary query execution is disabled in production. This closes off introspection abuse and denial-of-service via deeply-nested queries.
# APQ flow
Client ---> Server: { "extensions": { "persistedQuery": { "sha256Hash": "abc123..." } } }
Server ---> Client: { "errors": [{ "message": "PersistedQueryNotFound" }] }
Client ---> Server: { "query": "query GetUser ...", "extensions": { "persistedQuery": ... } }
Server ---> Client: { "data": { "user": ... } } # stores hash->document in cache
# Subsequent requests use hash only
7.3. Query Complexity and Depth Limiting
Unbounded queries are a denial-of-service vector. Two complementary mitigations:
- Depth limiting: reject queries with resolver depth greater than N (typical: 7-10). Protects against recursive schema traversal.
- Complexity analysis: assign a cost to each field; reject queries where total cost exceeds a budget. More expressive than depth alone because it can account for list multipliers.
class AppSchema < GraphQL::Schema max_depth 10 max_complexity 200 # Per-field complexity multiplier for lists field :posts, [Types::PostType], null: false, complexity: ->(ctx, args, child_complexity) { args[:first].to_i * child_complexity } end
7.4. Response Caching
| Level | Mechanism | Invalidation |
|---|---|---|
| CDN / edge cache | Cache-Control on GET requests (APQ) | TTL or purge API |
| Normalized cache | Apollo Client / urql (client-side) | Mutation write-through |
| Server-side cache | Redis keyed by operation + variables | Manual or TTL |
| CDN with @cacheControl | graphql-cache-control directive | Per-field TTL hints |
GET requests require APQ (the full query document is in the URL, which exceeds URL length limits; APQ substitutes the hash). CDN caching of GraphQL therefore depends on APQ being enabled.
7.5. @defer and @stream (Incremental Delivery)
@defer and @stream are incremental delivery directives that let a
server send a partial response immediately and stream the deferred
portions as they resolve. They were merged into the GraphQL spec in
2024 and are supported by Apollo Server 4+, GraphQL Yoga, and
graphql-ruby 2.x.
query GetPost($id: ID!) { post(id: $id) { id title # Expensive sentiment analysis — defer it ... @defer { sentiment { score label } } } }
The response arrives as a multipart HTTP body
(Content-Type: multipart/mixed). The client renders the post title
immediately while the sentiment analysis resolves in the background.
8. Federation
See the 4 section above for the current state of Apollo Federation v2, Cosmo, and GraphQL Mesh.
9. Maintaining GraphQL
9.1. Schema Evolution and Versioning
GraphQL's introspection capability and the lack of explicit versioning are both features and liabilities. The ecosystem converged on continuous evolution rather than API versions:
- Never remove fields without a deprecation period. Mark with
@deprecated(reason: "Use newField instead."). - Add fields freely — new fields are backward compatible.
- Rename types via aliases — keep old type names as aliases during migration.
- Track field usage with GraphQL Hive or Apollo GraphOS before removing deprecated fields.
type User { id: ID! username: String! @deprecated(reason: "Use name — username is being removed 2026-Q3") name: String! email: EmailAddress! }
9.2. Schema Registry
A schema registry records every schema version, validates composition (for federated graphs), runs breaking-change checks, and tracks field usage. Options:
| Registry | Self-hostable | Federation | Usage analytics |
|---|---|---|---|
| Apollo GraphOS | No | Yes | Yes |
| GraphQL Hive | Yes | Yes | Yes |
| WunderGraph Cosmo | Yes | Yes | Yes |
| Inigo | Yes | Partial | Yes |
9.3. CI/CD Integration
Breaking-change detection should block merges. The Rover CLI (Apollo)
and Hive CLI both expose a schema check command suitable for use in CI:
# Apollo / Rover rover graph check my-graph@current \ --schema ./schema.graphql # Hive CLI hive schema:check \ --registry.accessToken $HIVE_TOKEN \ --service users \ schema.graphql
10. Tools
10.1. GraphQL Playground
GraphQL Playground has been superseded by GraphiQL 2. Both are browser-based IDEs for exploring a GraphQL API via introspection. Current recommendations:
- GraphiQL 2: the reference implementation, maintained by the GraphQL
Foundation. Embeds in any HTML page; ships as a standalone Electron
app via
graphiql-app. - Apollo Sandbox: hosted at
sandbox.apollo.dev; connects to any locally-running GraphQL server. No installation required. - Insomnia / Postman: general-purpose API tools with GraphQL support; useful when working alongside REST and gRPC endpoints.
- Hoppscotch: open-source, self-hostable alternative to Postman with first-class GraphQL support.
10.2. GraphQL Configuration
https://github.com/prisma/graphql-config/blob/master/specification.md
The .graphqlrc.yml / graphql.config.yml format is the standard way to
configure editors, linters, and code-generation tools to find your
schema and operations:
schema: ./schema.graphql documents: ./src/**/*.graphql extensions: codegen: generates: ./src/generated/types.ts: plugins: - typescript - typescript-operations - typescript-react-query
10.3. Mocking
npm install -g get-graphql-schema graphql-cli graphql-faker mkdir t && cd t # graphql init get-graphql-schema https://$HOST/graphql/ > schema.graphql graphql-faker schema.graphql open http://localhost:9002/editor
10.4. Linting: graphql-eslint
npm install --save-dev @graphql-eslint/eslint-plugin
{
"overrides": [
{
"files": ["*.graphql"],
"parser": "@graphql-eslint/eslint-plugin",
"plugins": ["@graphql-eslint"],
"rules": {
"@graphql-eslint/known-type-names": "error",
"@graphql-eslint/no-deprecated": "warn",
"@graphql-eslint/require-description": ["warn", { "types": true }],
"@graphql-eslint/naming-convention": ["error",
{ "types": "PascalCase", "fieldDefinitions": "camelCase" }]
}
}
]
}
11. Reading
11.1. Production Ready GraphQL
11.1.1. An Introduction to GraphQL
[ ]An Introduction to GraphQL[ ]One-Size-Fits-All[ ]Let's Go Back in Time[ ]Enter GraphQL[ ]Type System[ ]Introspection
11.1.2. GraphQL Schema Design
[ ]What Makes an API Great?[ ]Design First[ ]Client First[ ]Naming[ ]Descriptions[ ]Use the Schema, Luke![ ]Expressive Schemas[ ]Specific or Generic[ ]The Relay Specification[ ]Lists & Pagination[ ]Sharing Types[ ]Global Identification[ ]Nullability[ ]Abstract Types[ ]Designing for Static Queries[ ]Mutations[ ]Fine-Grained or Coarse-Grained[ ]Errors[ ]Schema Organization[ ]Asynchronous Behavior[ ]Data-Driven Schema vs Use-Case-Driven Schema
11.1.3. Implementing GraphQL Servers
[ ]GraphQL Server Basics[ ]Code First vs SchemaFirst[ ]Generating SDL Artifacts[ ]Resolver Design[ ]Schema Metadata[ ]Multiple Schemas[ ]Modular Schemas[ ]Testing
11.1.4. Security
[ ]Rate Limiting[ ]Blocking Abusive Queries[ ]Timeouts[ ]Authentication[ ]Authorization[ ]Blocking Introspection[ ]Persisted Queries
11.1.5. Performance & Monitoring
[ ]Monitoring[ ]The N+1 Problem and the Dataloader Pattern[ ]Caching[ ]Compiled Queries
11.1.6. Tooling
[ ]Linting[ ]Analytics
11.1.7. Workflow
[ ]Design[ ]Review[ ]Development[ ]Publish[ ]Analyze Ship
11.1.8. Public GraphQL APIs
[ ]Is GraphQL a Good Choice for Public APIs[ ]Lack of Conventions[ ]With Great Power comes Great Responsibility
11.1.9. GraphQL in a Distributed Architecture
[ ]GraphQL API Gateway[ ]GraphQL as a BFF[ ]Service Communication
11.1.10. Versioning
[ ]API Versioning is Never Fun[ ]Versioning GraphQL is Possible[ ]Continuous Evolution[ ]Change Management
11.1.11. Documenting GraphQL APIs
[ ]Documentation Generators[ ]The What, Not Just the How[ ]Workflows and Use Cases[ ]Example / Pre-Made Queries[ ]Changelogs[ ]Upcoming Changes
11.1.12. Migrating From Other API Styles
[ ]Generators[ ]REST & GraphQL Alongside
12. Videos
13. Conferences
13.1. GraphQL Summit 2020
13.1.2. GraphQL in Production
- Paypal, Shopify, Priceline, American
- Invest in tooling
- Look at the execution
- Measure timing
- Public APIs can be difficult
- How teams roll out GraphQL
- Schema design shouldn't just map REST
- Error handling
- Caching
- Providence of data and finding owner of data
- Collaborating on the features with Product and Engineering
- Duplicated data
- Be patient when evangelizing GraphQL
14. Request lifecycle and resolver execution
A GraphQL request — whether query, mutation, or subscription — funnels through a single schema, then expands into a resolver tree. The N+1 problem is the dominant performance concern at the resolver layer; the DataLoader pattern (request-scoped batching + caching) is the canonical fix and is what makes a deeply-nested resolver tree viable against heterogeneous data sources.
// GraphQL request lifecycle — operations -> schema -> resolver tree -> batched sources digraph graphql_resolver { rankdir=LR; graph [bgcolor="white", fontname="Helvetica", fontsize=11, pad="0.3", nodesep="0.3", ranksep="0.45"]; node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=10, fillcolor="#dbeafe", color="#888", fontcolor="#555"]; edge [color="#888", fontcolor="#555"]; // Client operations subgraph cluster_ops { label="Client operations"; labeljust="l"; color="#1d4ed8"; fontcolor="#1d4ed8"; style="rounded"; q [label="query\n{ user { posts } }", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"]; m [label="mutation\n{ createPost(...) }", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"]; s [label="subscription\n{ postAdded }", fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"]; } // Schema gate (single funnel) schema [label="Schema\nparse · validate · plan", shape=box, style="rounded,filled,bold", fillcolor="#dbeafe", color="#333", fontsize=11]; q -> schema [color="#1d4ed8"]; m -> schema [color="#1d4ed8"]; s -> schema [color="#1d4ed8"]; // Resolver tree subgraph cluster_resolvers { label="Resolver tree (per request)"; labeljust="l"; color="#15803d"; fontcolor="#15803d"; style="rounded"; rRoot [label="Query.user\n(parent resolver)", fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"]; rPosts [label="User.posts\n(child resolver)", fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"]; rAuth [label="Post.author\n(child resolver)", fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"]; rRoot -> rPosts [color="#15803d"]; rPosts -> rAuth [color="#15803d"]; } schema -> rRoot [color="#888"]; // DataLoader batching layer subgraph cluster_loader { label="DataLoader (request-scoped batch + cache)"; labeljust="l"; color="#b45309"; fontcolor="#b45309"; style="rounded"; dlUser [label="userById\nbatch(ids)", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"]; dlPost [label="postsByUser\nbatch(uids)", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"]; dlAuth [label="userById\n(cache hit)", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"]; } rRoot -> dlUser [color="#b45309"]; rPosts -> dlPost [color="#b45309"]; rAuth -> dlAuth [color="#b45309", style=dashed]; // Data sources subgraph cluster_sources { label="Data sources"; labeljust="l"; color="#6b21a8"; fontcolor="#6b21a8"; style="rounded"; rest [label="REST\n(legacy users API)", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; db [label="Postgres\n(posts table)", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; grpc [label="gRPC\n(auth service)", fillcolor="#ede9fe", color="#6b21a8", fontcolor="#6b21a8"]; } dlUser -> rest [color="#6b21a8"]; dlPost -> db [color="#6b21a8"]; dlAuth -> grpc [color="#6b21a8"]; }
15. Related notes
- Clojure + GraphQL Integration — Lacinia: schemas as EDN, resolvers as pure functions
- Design-driven APIs — schema-first contracts; how GraphQL's SDL fits the API-first movement
- Schema format comparison — GraphQL SDL alongside JSON Schema, Avro, Protobuf
- JSON:API, Rails, AngularJS — pre-GraphQL SPA data-fetching patterns and their pain points
16. Postscript (2026)
The GraphQL ecosystem has consolidated sharply since these notes were
first written. Schema stitching is deprecated in favour of
Apollo Federation v2,
and the reference gateway is now the Rust-based
Apollo Router, which
replaced the Node.js @apollo/gateway for production deployments and
brought query-planning latency down by an order of magnitude. The
GraphQL over HTTP
specification (now a stable working draft from the GraphQL
Foundation) finally pins down status codes, content negotiation, and
the application/graphql-response+json media type — ending years of
"every server does it differently". On the security side, persisted
queries have been rebranded as
Trusted Documents:
clients send a hash, the server executes only registered operations,
and arbitrary-query introspection is closed off in production. For
servers, GraphQL Yoga
and GraphQL Mesh (the latter
exposing REST, gRPC, OpenAPI, and SOAP as a unified GraphQL surface)
have largely displaced bespoke Apollo Server stacks for new projects.
The persistent question — GraphQL vs tRPC vs
gRPC-web — has settled into rough consensus: GraphQL for cross-team
public/partner APIs, tRPC for single-team TypeScript monorepos,
gRPC-web for service-to-service where typed RPC beats query
flexibility.