M05 — GraphQL & API Contracts
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.
| 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 |
- 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
- 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)
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! }
| 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. |
!.fragment UserCore on User { id username email }
/* Response shape mirrors the selection set exactly */ { “data”: { “createPost”: { “id”: “post-789”, “title”: “Hello World”, “status”: “DRAFT”, “author”: { “id”: “abc-123”, “username”: “ajay” } } } }
errors array; other resolvers still run. This is fundamentally different from HTTP 4xx/5xx.
{ "data": { "user": null }, "errors": [{ "message": "Not found", "locations": [...], "path": ["user"] }] }
extensions:
{ "message": "Unauthorized", "extensions": { "code": "UNAUTHENTICATED", "http": { "status": 401 } } }
Common codes:
UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT, INTERNAL_SERVER_ERROR
/* Server pushes one event per new comment: */ { “data”: { “commentAdded”: { “id”: “c-42”, “body”: “Great post!”, … } } }
// 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;
},
},
};
/* Each event is text/plain, double-newline terminated */ data: {“data”:{“commentAdded”:{“id”:“c-42”,“body”:“Hello!”}}}
data: {“data”:{“commentAdded”:{“id”:“c-43”,“body”:“Nice!”}}}
(parent, args, context, info) → value | Promise. If no explicit resolver is provided, a default resolver reads parent[fieldName]. Leaf scalars terminate the walk.
author resolver runs individually, you get 1 query for posts + N queries for authors — even if many posts share the same author.
// 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({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), }, };
Error instance for missing keys — DataLoader will reject that specific load() promise.Connection → [Edge { node, cursor }] + PageInfo envelope, enabling both forward and backward cursor pagination without page-numbering problems.
| 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 |
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’),
};
}
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! }
@key + @external directives.
type Post @key(fields: “id”) { id: ID! title: String! author: User! # gateway will resolve via User subgraph }
/* 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 */
# Disable in production to prevent schema enumeration by attackers # Apollo Server: introspection: process.env.NODE_ENV !== ‘production’
| 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 |
@deprecated(reason: "Use newField instead") — introspection tools surface it to developers. Keep deprecated fields for at least one release cycle before removal.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
});
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; }
posts { author } queryPageInfotype User @key(fields: "id"); expose Query.user(id) and Query.meextend type User @key(fields: "id"); implement @requires if needed{ post(id:"1") { title author { username email } } }@deprecated to a field in the Post schema and verify it surfaces in introspectionGqlLexer to handle string literals (quoted, escaped), integer, and float tokensGqlNode (AST node) with fields: kind, name, children[], args[]parse_document → parse_operation → parse_selection_set → parse_field{ user(id:"1") { id username posts { title } } }$varName: Type from the operation definition- 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