GraphQL: Schema, Operations, and Federation

Table of Contents

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; null is 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's args map, and the parent object's val.
  • Batching uses com.walmartlabs.lacinia.executor/selects-field? and the built-in BatchedFieldResolver protocol for DataLoader-style coalescing.
  • Subscriptions use a SourceStreamFactory that 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::Dataloader is the standard N+1 solution; replace batch-loader gem usage with it for new code.
  • Complexity analysis via max_complexity and per-field complexity: annotations guards against expensive queries.
  • The early_return setting 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_time histogram 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];
}

diagram-federation.png

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/before starts 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:

  1. Default nullable (spec default): every field is nullable unless explicitly marked non-null with !. Clients must null-check everything. Dominant in older schemas.
  2. 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 @semanticNonNull directive (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:

  1. Batching: accumulate all keys requested in a single event-loop tick, then dispatch one batch request.
  2. 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:

  1. Never remove fields without a deprecation period. Mark with @deprecated(reason: "Use newField instead.").
  2. Add fields freely — new fields are backward compatible.
  3. Rename types via aliases — keep old type names as aliases during migration.
  4. 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"];
}

diagram-resolver.png

15. Related notes

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.