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.
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
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Data shape | Fixed by endpoint | Client-defined per query | Fixed by proto message |
| Transport | HTTP/1.1 or 2 | HTTP POST (or WebSocket) | HTTP/2 only |
| Schema | OpenAPI (optional) | SDL (mandatory) | Proto3 (mandatory) |
| Versioning | URL/header | Schema evolution (deprecated) | Package + reserved fields |
| Real-time | SSE / polling | Subscriptions over WS | Server / bidi streaming |
| Tooling | Swagger UI, Postman | GraphiQL, Apollo Studio | grpcurl, Evans |
| Over/under-fetch | Common problem | Solved by design | Solved by design |
| N+1 risk | Low (batched endpoints) | High without DataLoader | Low (explicit streams) |
| Best for | Public APIs, CRUD | Mobile/BFF, many consumers | Internal 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
| Construct | SDL syntax | Purpose | Notes |
|---|---|---|---|
| Scalar | scalar DateTime | Leaf value (no sub-fields) | Built-in: Int, Float, String, Boolean, ID. Custom scalars need serialize/parse/parseLiteral coercion. |
| Object type | type User { … } | Named set of fields | All fields are nullable by default; ! makes non-null. |
| Interface | interface Node { id: ID! } | Abstract type contract | Types that implement must define all interface fields. |
| Union | union SearchResult = A | B | One-of type (no shared fields) | Use __typename or inline fragments (... on User) to distinguish. |
| Enum | enum Status { DRAFT … } | Fixed set of string values | Serialized as strings in JSON; validated server-side. |
| Input type | input CreatePost { … } | Argument objects for mutations | Cannot contain object types — only scalars, enums, and other input types. |
| Non-null | String! | Field/arg must not be null | If resolver returns null, GraphQL propagates null up to nearest nullable parent. |
| List | [String!]! | Array of values | Outer ! = list not null; inner ! = no null elements. |
| Directive | @deprecated(reason: "…") | Metadata on types/fields | Built-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
Common codes:
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
| Strategy | Query pattern | Pros | Cons |
|---|---|---|---|
| Offset + Limit | posts(offset:20, limit:10) | Simple, supports random page jumps | Skips/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 scroll | No random page access; cursor is opaque |
| Keyset | posts(after_id:42, limit:10) | O(log N) with index; most scalable | Tied 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
| Change | Safe? | Reason |
|---|---|---|
| Add nullable field to object type | ✅ Safe | Existing clients ignore unknown fields |
| Add optional argument to field | ✅ Safe | Clients that omit the arg still work |
| Add new enum value | ⚠️ Breaking for exhaustive switches | Client code doing switch/case may fail on new value |
| Remove field | ❌ Breaking | Existing queries referencing it fail validation |
| Change field type | ❌ Breaking | Type mismatch at runtime |
| Add non-null field | ❌ Breaking | Old clients may not provide required field |
| Remove enum value | ❌ Breaking | Old clients may send the removed value |
| Rename type | ❌ Breaking | Fragment 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 } query5 Implement cursor pagination on posts list with proper
PageInfo6 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.me2 Create Post subgraph: reference User via
extend type User @key(fields: "id"); implement @requires if needed3 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 tokens2 Define
GqlNode (AST node) with fields: kind, name, children[], args[]3 Write a recursive-descent parser:
parse_document → parse_operation → parse_selection_set → parse_field4 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 definition6 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