M05 — GraphQL & API Contracts

Phase 1 SDL type system · Query / mutation / subscription · Resolver execution model · N+1 & DataLoader · Cursor pagination · Schema federation · Persisted queries · GraphQL parser in C
🌐 Why GraphQL?
GraphQL is a query language for APIs and a runtime for executing those queries. Clients describe exactly what data they need — the server returns precisely that structure. This eliminates over-fetching (getting more fields than needed) and under-fetching (needing multiple round-trips).

Conceived at Facebook in 2012, open-sourced in 2015, and now governed by the GraphQL Foundation. It sits above your transport (HTTP POST by convention) and serialisation (JSON) layers.
REST vs GraphQL vs gRPC
DimensionRESTGraphQLgRPC
Data shapeFixed by endpointClient-defined per queryFixed by proto message
TransportHTTP/1.1 or 2HTTP POST (or WebSocket)HTTP/2 only
SchemaOpenAPI (optional)SDL (mandatory)Proto3 (mandatory)
VersioningURL/headerSchema evolution (deprecated)Package + reserved fields
Real-timeSSE / pollingSubscriptions over WSServer / bidi streaming
ToolingSwagger UI, PostmanGraphiQL, Apollo Studiogrpcurl, Evans
Over/under-fetchCommon problemSolved by designSolved by design
N+1 riskLow (batched endpoints)High without DataLoaderLow (explicit streams)
Best forPublic APIs, CRUDMobile/BFF, many consumersInternal microservices
GraphQL Request / Response Lifecycle
Client Server Runtime Resolvers │ │ │ │─── HTTP POST /graphql ──▶│ │ { query, variables } │ │ │──── 1. Parse query ──────────│ │ │ (AST Document node) │ │ │──── 2. Validate ─────────────│ │ │ (against SDL schema) │ │ │──── 3. Execute ──────────────│ │ │ Query.user() ─────────▶ db.findUser(id) │ │ User.posts() ─────────▶ db.postsByUser(id) │ │ Post.author() ────────▶ db.findUser(authorId) │ │──── 4. Coerce & shape ────────│ │◀─── { data, errors } ───│
✅ Use GraphQL when…
  • Multiple clients (mobile, web, TV) need different shapes
  • Building a BFF (Backend For Frontend) layer
  • Rapid product iteration — add fields without breaking old clients
  • Schema-driven development with strong type contracts
  • Exposing a public, self-documenting developer API
⚠️ Prefer REST / gRPC when…
  • Simple CRUD with few consumers
  • HTTP caching is important (GET semantics)
  • File uploads are a primary use case
  • Tight performance budget on edge/embedded devices
  • Internal microservice calls (prefer gRPC)
📄 Schema Definition Language (SDL)
The SDL is GraphQL's Interface Definition Language. Every field, type, argument, and return value is declared here. The server runtime uses it for validation and execution. Clients use it (via introspection) for type-safe code generation.
Complete SDL Example — Blog Service
# ── Scalars ────────────────────────────────────────────────────── scalar DateTime # ISO-8601 string; validated by custom scalar coercion scalar UUID # ── Enums ──────────────────────────────────────────────────────── enum PostStatus { DRAFT PUBLISHED ARCHIVED } enum SortDirection { ASC DESC } # ── Interfaces ─────────────────────────────────────────────────── interface Node { id: ID! } # ── Object types ───────────────────────────────────────────────── type User implements Node { id: ID! username: String! email: String! createdAt: DateTime! posts(first: Int, after: String): PostConnection! } type Post implements Node { id: ID! title: String! body: String! status: PostStatus! author: User! tags: [String!]! createdAt: DateTime! } # ── Connection / Edge (Relay cursor pagination) ─────────────────── type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } type PostEdge { node: Post! cursor: String! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } # ── Input types (arguments for mutations) ──────────────────────── input CreatePostInput { title: String! body: String! tags: [String!] } # ── Union ───────────────────────────────────────────────────────── union SearchResult = User | Post # ── Root types ──────────────────────────────────────────────────── type Query { user(id: ID!): User posts(first: Int, after: String, status: PostStatus): PostConnection! search(query: String!): [SearchResult!]! } type Mutation { createPost(input: CreatePostInput!): Post! publishPost(id: ID!): Post! deletePost(id: ID!): Boolean! } type Subscription { postPublished: Post! commentAdded(postId: ID!): Comment! }
Type System Reference
ConstructSDL syntaxPurposeNotes
Scalarscalar DateTimeLeaf value (no sub-fields)Built-in: Int, Float, String, Boolean, ID. Custom scalars need serialize/parse/parseLiteral coercion.
Object typetype User { … }Named set of fieldsAll fields are nullable by default; ! makes non-null.
Interfaceinterface Node { id: ID! }Abstract type contractTypes that implement must define all interface fields.
Unionunion SearchResult = A | BOne-of type (no shared fields)Use __typename or inline fragments (... on User) to distinguish.
Enumenum Status { DRAFT … }Fixed set of string valuesSerialized as strings in JSON; validated server-side.
Input typeinput CreatePost { … }Argument objects for mutationsCannot contain object types — only scalars, enums, and other input types.
Non-nullString!Field/arg must not be nullIf resolver returns null, GraphQL propagates null up to nearest nullable parent.
List[String!]!Array of valuesOuter ! = list not null; inner ! = no null elements.
Directive@deprecated(reason: "…")Metadata on types/fieldsBuilt-in: @deprecated, @skip, @include, @specifiedBy. Custom directives extend this.
💡 Non-null propagation rule: if a non-null field resolver throws or returns null, GraphQL does not return a partial object — it sets the nearest nullable parent to null. This "error bubbling" means you must think carefully about which fields to mark !.
📝 GraphQL Operations
GraphQL defines three root operation types: query (read, parallel execution), mutation (write, serial execution), and subscription (long-lived event stream). Every request document contains one or more named or anonymous operations.
Query Anatomy
# Named query with variables and fragments query GetUserWithPosts($userId: ID!, $first: Int = 10) { user(id: $userId) { ...UserCore # fragment spread posts(first: $first) { edges { node { id title status author { ...UserCore } # reuse same fragment } cursor } pageInfo { hasNextPage endCursor } } } } fragment UserCore on User { id username email }
// Variables sent as a separate JSON object (NOT interpolated into the query string) { "userId": "abc-123", "first": 5 }
Inline Fragments for Unions / Interfaces
query Search($q: String!) { search(query: $q) { __typename # always include to discriminate union members ... on User { id username } ... on Post { id title status } } }
Mutations
⚡ Mutation Execution Semantics
Unlike queries (which execute fields in parallel), mutation root fields execute serially — one after another, in document order. This prevents race conditions between writes. Field resolvers within the mutation response shape still run in parallel.
mutation CreateAndPublish($input: CreatePostInput!) { createPost(input: $input) { id title status author { id username } } } /* Response shape mirrors the selection set exactly */ { "data": { "createPost": { "id": "post-789", "title": "Hello World", "status": "DRAFT", "author": { "id": "abc-123", "username": "ajay" } } } }
Error Handling
⚠️ Partial Success Pattern
GraphQL can return both data AND errors in the same response. A resolver that throws populates the errors array; other resolvers still run. This is fundamentally different from HTTP 4xx/5xx.

{ "data": { "user": null }, "errors": [{ "message": "Not found", "locations": [...], "path": ["user"] }] }
🏷️ Error Extensions Pattern
Add structured error metadata via extensions:

{ "message": "Unauthorized", "extensions": { "code": "UNAUTHENTICATED", "http": { "status": 401 } } }

Common codes: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT, INTERNAL_SERVER_ERROR
Directives in Queries
query ConditionalQuery($withEmail: Boolean!, $skipTags: Boolean!) { user(id: "123") { id username email @include(if: $withEmail) # field included only if true posts(first: 5) { edges { node { title tags @skip(if: $skipTags) # field omitted if true } } } } }
📡 Subscriptions — Real-time Events
Subscriptions are long-lived connections where the server pushes events to the client. The transport is typically WebSocket (graphql-ws protocol) or Server-Sent Events. The server maintains a pub/sub channel (Redis, in-memory) per subscription topic.
Subscription Operation
# Client sends this once over WebSocket subscription WatchPost($postId: ID!) { commentAdded(postId: $postId) { id body author { id username } createdAt } } /* Server pushes one event per new comment: */ { "data": { "commentAdded": { "id": "c-42", "body": "Great post!", ... } } }
graphql-ws Protocol Message Flow
Client Server │── WS upgrade ──────────────────────▶│ │◀─ 101 Switching Protocols ──────────│ │── { type: "connection_init" } ──────▶│ │◀─ { type: "connection_ack" } ───────│ │── { type: "subscribe", │ │ id: "1", │ │ payload: { query, variables } } ─▶│ ← registers AsyncIterator │◀─ { type: "next", id: "1", │ │ payload: { data: {...} } } ──────│ ← event 1 │◀─ { type: "next", id: "1", ... } ───│ ← event 2 … │── { type: "complete", id: "1" } ────▶│ ← client unsubscribes
Server-Side Subscription Resolver Pattern
// Node.js / graphql-js pattern (pseudocode) const resolvers = { Subscription: { commentAdded: { // subscribe returns an AsyncIterator subscribe: (_, { postId }, { pubsub }) => pubsub.asyncIterableIterator(`COMMENT_ADDED_${postId}`), // resolve shapes each event payload resolve: (payload) => payload.commentAdded, }, }, Mutation: { addComment: async (_, { postId, body }, { db, pubsub, user }) => { const comment = await db.createComment({ postId, body, authorId: user.id }); await pubsub.publish(`COMMENT_ADDED_${postId}`, { commentAdded: comment }); return comment; }, }, };
⚠️ Subscription scaling: In-memory pub/sub only works for single-server deployments. In production, use Redis Pub/Sub (or Kafka) so events propagate across all server instances. Each subscriber node receives the event and delivers it to its connected WebSocket clients.
SSE as Lightweight Alternative
/* HTTP response headers for SSE */ Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive /* Each event is text/plain, double-newline terminated */ data: {"data":{"commentAdded":{"id":"c-42","body":"Hello!"}}} data: {"data":{"commentAdded":{"id":"c-43","body":"Nice!"}}}
💡 SSE vs WebSocket: SSE is uni-directional (server → client) and works over standard HTTP/1.1 — simpler to set up and proxy. WebSocket is full-duplex but requires special proxy configuration. For GraphQL subscriptions (server-push only), SSE is often sufficient and easier to operate.
🔗 Resolver Execution Model
Execution walks the query AST depth-first. Each field calls a resolver function: (parent, args, context, info) → value | Promise. If no explicit resolver is provided, a default resolver reads parent[fieldName]. Leaf scalars terminate the walk.
Resolver Function Signature
/** * @param parent - resolved value of the parent object * @param args - field arguments from the query * @param context - shared request state: db, auth user, DataLoader instances * @param info - AST metadata: fieldName, returnType, path, schema */ async function userResolver(parent, { id }, { db, user }, info) { if (!user) throw new AuthenticationError('Must be logged in'); return db.users.findById(id); // returns Promise; runtime awaits it }
N+1 Problem
🚨 N+1 — The Most Common GraphQL Performance Bug
When a list query fetches N posts and each post's author resolver runs individually, you get 1 query for posts + N queries for authors — even if many posts share the same author.
Query: posts(first: 100) { edges { node { title author { username } } } } Without DataLoader: SELECT * FROM posts LIMIT 100; ← 1 query SELECT * FROM users WHERE id = 'u1'; ← post 1 SELECT * FROM users WHERE id = 'u2'; ← post 2 SELECT * FROM users WHERE id = 'u1'; ← post 3 (duplicate!) ... 100 more individual selects Total: 101 queries With DataLoader: SELECT * FROM posts LIMIT 100; ← 1 query SELECT * FROM users WHERE id IN ('u1','u2','u3',...); ← 1 batched query Total: 2 queries
DataLoader — Batch + Cache
// DataLoader batches all loads queued in the same event-loop tick import DataLoader from 'dataloader'; // Batch function: receives array of keys, returns array of values (same order!) async function batchUsers(userIds) { const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [userIds]); const map = Object.fromEntries(rows.map(r => [r.id, r])); return userIds.map(id => map[id] || new Error(`User ${id} not found`)); } // Create one DataLoader per REQUEST (not global — to avoid cross-request cache) function createContext({ req }) { return { db, user: authenticate(req), loaders: { user: new DataLoader(batchUsers), // one loader per entity type }, }; } // Resolver uses loader instead of direct DB call const resolvers = { Post: { author: (post, _, { loaders }) => loaders.user.load(post.authorId), }, };
💡 DataLoader contract: The batch function must return an array of the same length as the input keys, in the same order. DataLoader uses positional matching. Return an Error instance for missing keys — DataLoader will reject that specific load() promise.
Resolver Execution Trace — Depth-First, Breadth-Parallel
Execution order for: query { user(id:"1") { username posts { title author { username } } } } Level 0 (parallel): Query.user Level 1 (parallel): User.username, User.posts Level 2 (parallel, per edge): Post.title, Post.author Level 3 (parallel, per post): User.username ← batched by DataLoader Rules: • Siblings at same level execute in parallel (Promise.all) • Children wait for parent resolver to return • Mutations: root fields are sequential; child fields are parallel
📄 Pagination in GraphQL
The Relay Connection Spec is the de-facto standard for GraphQL pagination. It wraps results in a Connection → [Edge { node, cursor }] + PageInfo envelope, enabling both forward and backward cursor pagination without page-numbering problems.
Offset vs Cursor vs Keyset
StrategyQuery patternProsCons
Offset + Limitposts(offset:20, limit:10)Simple, supports random page jumpsSkips/duplicates on concurrent inserts; full table scan for large offsets
Cursor (Relay)posts(first:10, after:"cursor")Stable, no skips on inserts, works well with infinite scrollNo random page access; cursor is opaque
Keysetposts(after_id:42, limit:10)O(log N) with index; most scalableTied to sort column; no skip; non-standard
Relay Connection — Forward Pagination
query PaginatePosts($after: String, $first: Int = 10) { posts(first: $first, after: $after) { totalCount pageInfo { hasNextPage endCursor # pass this as $after in next request } edges { cursor # per-edge cursor (base64 opaque string) node { id title createdAt } } } }
Cursor Encoding Pattern
// Cursor = base64( "PostCursor:" + sortKey ) // sortKey is typically the column used for ORDER BY function encodeCursor(sortValue) { return Buffer.from(`PostCursor:${sortValue}`).toString('base64'); } function decodeCursor(cursor) { const raw = Buffer.from(cursor, 'base64').toString('utf8'); // "PostCursor:2024-01-15T..." return raw.replace('PostCursor:', ''); } // Resolver builds SQL using decoded cursor async function postsResolver(_, { first = 10, after }, { db }) { const cursorValue = after ? decodeCursor(after) : null; const rows = await db.query( `SELECT * FROM posts WHERE ($1::timestamptz IS NULL OR created_at < $1) ORDER BY created_at DESC LIMIT $2`, [cursorValue, first + 1] // fetch +1 to detect hasNextPage ); const hasNextPage = rows.length > first; if (hasNextPage) rows.pop(); return { edges: rows.map(r => ({ node: r, cursor: encodeCursor(r.created_at) })), pageInfo: { hasNextPage, endCursor: rows.length ? encodeCursor(rows.at(-1).created_at) : null, }, totalCount: await db.count('posts'), }; }
Filtering & Sorting Pattern
# SDL for flexible filtering input PostFilter { status: PostStatus authorId: ID tags: [String!] createdAfter: DateTime createdBefore: DateTime } input PostSort { field: PostSortField! direction: SortDirection! } enum PostSortField { CREATED_AT TITLE AUTHOR_NAME } type Query { posts( first: Int, after: String, filter: PostFilter, sort: PostSort ): PostConnection! }
🧠 Mental model: Think of a cursor as a bookmark in a sorted list — it points to the last item you read. Next time you open the book, you pick up exactly where you left off, regardless of what was added or removed elsewhere in the list.
🏛️ Schema Federation
Apollo Federation lets you split a GraphQL schema across multiple independent services (subgraphs). A gateway composes them into a unified supergraph. Each subgraph owns its types and can extend types owned by other subgraphs via @key + @external directives.
Federation Architecture
Client │── POST /graphql ──────────────▶ Gateway (Router) ┌────────────────────┼───────────────────┐ ▼ ▼ ▼ User Service Post Service Comment Service (subgraph) (subgraph) (subgraph) type User @key( type Post @key( type Comment { fields:"id") { fields:"id") { postId: ID! id: ID! id: ID! body: String! username: String! author: User! author: User! } } } ↑ owns User ↑ references User ↑ references User
# Post subgraph — references User from User subgraph extend type User @key(fields: "id") { id: ID! @external # owned by User subgraph } type Post @key(fields: "id") { id: ID! title: String! author: User! # gateway will resolve via User subgraph }
Persisted Queries
🔒 Persisted Queries — Security + Performance
Instead of sending the full query string on every request, the client registers queries at build time and sends only a hash/ID at runtime. Benefits: (1) smaller payloads, (2) server can whitelist approved queries, (3) prevents arbitrary query injection.
/* Automatic Persisted Query (APQ) protocol */ /* Step 1 — Send hash only */ POST /graphql { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } } } /* Server responds with 404 if not cached */ { "errors": [{ "message": "PersistedQueryNotFound" }] } /* Step 2 — Resend with full query to register */ POST /graphql { "query": "query GetUser($id:ID!){user(id:$id){id username}}", "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } } } /* Server caches query; subsequent requests use hash only */
Introspection
# Introspection query — clients use this for schema discovery { __schema { types { name kind fields { name type { name kind } } } } } # Disable in production to prevent schema enumeration by attackers # Apollo Server: introspection: process.env.NODE_ENV !== 'production'
Schema Evolution Rules
ChangeSafe?Reason
Add nullable field to object type✅ SafeExisting clients ignore unknown fields
Add optional argument to field✅ SafeClients that omit the arg still work
Add new enum value⚠️ Breaking for exhaustive switchesClient code doing switch/case may fail on new value
Remove field❌ BreakingExisting queries referencing it fail validation
Change field type❌ BreakingType mismatch at runtime
Add non-null field❌ BreakingOld clients may not provide required field
Remove enum value❌ BreakingOld clients may send the removed value
Rename type❌ BreakingFragment spreads use type names
💡 Deprecation workflow: mark fields with @deprecated(reason: "Use newField instead") — introspection tools surface it to developers. Keep deprecated fields for at least one release cycle before removal.
Query Complexity & Depth Limiting
// Prevent deeply-nested or expensive queries from DoS-ing your server import { createComplexityRule } from 'graphql-query-complexity'; const server = new ApolloServer({ validationRules: [ createComplexityRule({ maximumComplexity: 1000, variables: {}, onComplete: (complexity) => console.log(`Query complexity: ${complexity}`), createError: (max, actual) => new Error(`Query too complex: ${actual} > ${max}`), }), ], depthLimit: 7, // reject queries deeper than 7 levels });
🔧 GraphQL Parser Sketch in C
Real-world GraphQL servers are implemented in JS/Go/Rust/Python. However, understanding how the lexer and parser work is essential for deep mastery. Below is a minimal hand-rolled lexer for GraphQL query strings.
/* gql_lexer.h — minimal GraphQL lexer in C */ #include <stdio.h> #include <string.h> #include <ctype.h> typedef enum { TOK_NAME, TOK_INT, TOK_FLOAT, TOK_STRING, TOK_LBRACE, TOK_RBRACE, TOK_LPAREN, TOK_RPAREN, TOK_COLON, TOK_BANG, TOK_DOLLAR, TOK_AT, TOK_SPREAD, /* ... */ TOK_EOF, TOK_ERR } GqlTokKind; typedef struct { GqlTokKind kind; const char *start; size_t len; } GqlToken; typedef struct { const char *src; size_t pos; size_t len; } GqlLexer; static void gql_skip_ignored(GqlLexer *l) { while (l->pos < l->len) { char c = l->src[l->pos]; if (c == '#') { /* comment: skip to end of line */ while (l->pos < l->len && l->src[l->pos] != '\n') l->pos++; } else if (isspace(c) || c == ',') { l->pos++; /* commas are whitespace in GraphQL */ } else break; } } GqlToken gql_next_token(GqlLexer *l) { gql_skip_ignored(l); if (l->pos >= l->len) return (GqlToken){ TOK_EOF, NULL, 0 }; char c = l->src[l->pos]; if (isalpha(c) || c == '_') { size_t start = l->pos++; while (l->pos < l->len && (isalnum(l->src[l->pos]) || l->src[l->pos] == '_')) l->pos++; return (GqlToken){ TOK_NAME, l->src + start, l->pos - start }; } switch (c) { case '{': l->pos++; return (GqlToken){ TOK_LBRACE, l->src+l->pos-1, 1 }; case '}': l->pos++; return (GqlToken){ TOK_RBRACE, l->src+l->pos-1, 1 }; case '(': l->pos++; return (GqlToken){ TOK_LPAREN, l->src+l->pos-1, 1 }; case ')': l->pos++; return (GqlToken){ TOK_RPAREN, l->src+l->pos-1, 1 }; case ':': l->pos++; return (GqlToken){ TOK_COLON, l->src+l->pos-1, 1 }; case '!': l->pos++; return (GqlToken){ TOK_BANG, l->src+l->pos-1, 1 }; case '$': l->pos++; return (GqlToken){ TOK_DOLLAR, l->src+l->pos-1, 1 }; case '@': l->pos++; return (GqlToken){ TOK_AT, l->src+l->pos-1, 1 }; default: l->pos++; return (GqlToken){ TOK_ERR, l->src+l->pos-1, 1 }; } } int main(void) { const char *src = "{ user(id: \"abc\") { id username } }"; GqlLexer lexer = { src, 0, strlen(src) }; GqlToken tok; while ((tok = gql_next_token(&lexer)).kind != TOK_EOF) { printf("kind=%d text=%.*s\n", tok.kind, (int)tok.len, tok.start); } return 0; }
Labs
🧪 Lab 1 — Schema-First Blog API
Goal: Design and implement a complete GraphQL schema for a blogging platform with users, posts, comments, and tags.
1 Write the full SDL: scalars, enums, interfaces (Node), all object types, input types, and root Query/Mutation/Subscription types
2 Implement all resolvers using a SQLite or in-memory data store
3 Add DataLoader for all parent→child relationships (post.author, comment.author, post.comments)
4 Verify N+1 elimination: log all SQL queries and count them for a posts { author } query
5 Implement cursor pagination on posts list with proper PageInfo
6 Test with GraphiQL or Apollo Sandbox: query, mutation, and subscription
🧪 Lab 2 — Federation Across Two Services
Goal: Split the blog API into User Service and Post Service, federate them with Apollo Router.
1 Create User subgraph: own type User @key(fields: "id"); expose Query.user(id) and Query.me
2 Create Post subgraph: reference User via extend type User @key(fields: "id"); implement @requires if needed
3 Run Apollo Router (or Apollo Gateway) to compose both subgraphs into a supergraph
4 Issue a query that spans both services: { post(id:"1") { title author { username email } } }
5 Inspect Router query plan to understand how it splits and joins the request
6 Add @deprecated to a field in the Post schema and verify it surfaces in introspection
🧪 Lab 3 — GraphQL Lexer in C
Goal: Extend the minimal C lexer above into a working parser that produces a field-selection AST.
1 Extend GqlLexer to handle string literals (quoted, escaped), integer, and float tokens
2 Define GqlNode (AST node) with fields: kind, name, children[], args[]
3 Write a recursive-descent parser: parse_document → parse_operation → parse_selection_set → parse_field
4 Pretty-print the resulting AST for input { user(id:"1") { id username posts { title } } }
5 Add variable extraction: collect all $varName: Type from the operation definition
6 Validate that every field in the selection set exists in a hard-coded schema map (key = "TypeName.fieldName")
Mastery Checklist
  • Define object types, interfaces, unions, enums, input types, and custom scalars in SDL
  • Explain non-null semantics and null propagation with an example
  • Write a named query with variables, fragments, and inline fragments for unions
  • Implement a mutation with an input type; explain serial vs parallel execution
  • Explain the N+1 problem with a concrete SQL trace; implement DataLoader batching
  • Implement cursor pagination following the Relay Connection spec
  • Set up a GraphQL subscription over WebSocket using graphql-ws protocol
  • Use @skip and @include directives in client queries
  • Explain @deprecated and describe the safe schema evolution workflow
  • Set up Apollo Federation with two subgraphs and a gateway/router
  • Explain Automatic Persisted Queries (APQ) — protocol, benefits, and security
  • Implement query complexity limiting and depth limiting
  • Disable introspection in production; explain the security risk
  • Distinguish GraphQL error handling (partial success) from HTTP status code semantics
  • Describe the GraphQL execution pipeline: parse → validate → execute → coerce