GraphQL Architecture: Schema Design, Federation & API Patterns
Building APIs that scale starts with choosing the right architecture. GraphQL has emerged as a compelling alternative to REST, but adopting it well requires understanding its trade-offs, patterns, and pitfalls. This guide covers everything from schema design to federation and security.
GraphQL vs REST: Trade-Offs#
REST organizes resources around URLs. GraphQL organizes them around a typed schema and a single endpoint.
| Concern | REST | GraphQL |
|---|---|---|
| Data fetching | Multiple round-trips, over/under-fetching | Single request, client specifies shape |
| Versioning | URL or header versioning (/v2/users) | Schema evolution, deprecation directives |
| Caching | HTTP caching out of the box | Requires explicit strategy |
| Tooling | Broad ecosystem, OpenAPI | Strong typing, introspection, codegen |
| File uploads | Native multipart support | Needs workarounds (multipart spec) |
REST remains a solid choice for simple CRUD services and public APIs where HTTP caching matters. GraphQL shines when clients have diverse data needs — dashboards, mobile apps, or micro-frontends composing data from many services.
Schema Design: Types, Queries, Mutations, Subscriptions#
A well-designed schema is the contract between your frontend and backend.
type User {
id: ID!
name: String!
email: String!
posts(first: Int, after: String): PostConnection!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
createdAt: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type Query {
user(id: ID!): User
posts(filter: PostFilter): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
}
type Subscription {
postCreated: Post!
}
Design principles:
- Use
ID!for primary keys, always non-null. - Adopt Relay-style connections (
edges/pageInfo) for pagination. - Group mutation arguments into
Inputtypes. - Use
Subscriptionsparingly — only for genuinely real-time needs.
Resolvers and the N+1 Problem#
Resolvers map schema fields to data. The naive approach creates N+1 queries:
// N+1 — each post triggers a separate author query
const resolvers = {
Post: {
author: (post) => db.users.findById(post.authorId),
},
};
DataLoader batches and deduplicates within a single request:
import DataLoader from "dataloader";
// Create per-request to avoid caching across users
function createLoaders() {
return {
userLoader: new DataLoader(async (ids: string[]) => {
const users = await db.users.findByIds(ids);
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) ?? null);
}),
};
}
// Resolver now batches automatically
const resolvers = {
Post: {
author: (post, _, { loaders }) => loaders.userLoader.load(post.authorId),
},
};
Attach loaders to the context so they are scoped per request and garbage-collected afterward.
Apollo Server Setup#
A minimal Apollo Server with Express:
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import express from "express";
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
});
await server.start();
app.use(
"/graphql",
express.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
user: await authenticate(req),
loaders: createLoaders(),
}),
})
);
Federation: Apollo Gateway & Supergraph#
As your API grows, a monolithic schema becomes hard to maintain. GraphQL federation lets teams own their subgraphs independently.
# Users subgraph
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
# Posts subgraph — extends User from another service
type User @key(fields: "id") {
id: ID!
posts: [Post!]!
}
type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}
Apollo Gateway composes subgraphs into a single supergraph:
import { ApolloGateway } from "@apollo/gateway";
import { ApolloServer } from "@apollo/server";
const gateway = new ApolloGateway({
supergraphSdl: readFileSync("supergraph.graphql", "utf-8"),
});
const server = new ApolloServer({ gateway });
Use Apollo Router (Rust-based) in production for better performance. Publish schema changes through a CI pipeline with rover subgraph check and rover subgraph publish.
Caching Strategies#
GraphQL does not benefit from HTTP caching by default since every request hits POST /graphql.
Persisted queries — hash the query at build time, send only the hash at runtime:
{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } } }
This enables CDN caching (via GET with query hash), reduces payload size, and acts as an allowlist in production.
Response caching — use @cacheControl directives:
type User @cacheControl(maxAge: 300) {
id: ID!
name: String!
email: String! @cacheControl(maxAge: 0)
}
Apollo Server reads these hints and sets Cache-Control headers accordingly.
Security#
Unrestricted GraphQL is an attack surface. Lock it down:
Depth limiting — prevent deeply nested queries:
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)],
});
Complexity analysis — assign cost to fields, reject expensive queries:
import { createComplexityRule, simpleEstimator } from "graphql-query-complexity";
const rule = createComplexityRule({
maximumComplexity: 1000,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
onComplete: (complexity) => {
if (complexity > 1000) throw new Error("Query too complex");
},
});
Other essentials:
- Disable introspection in production.
- Rate-limit by authenticated user, not just IP.
- Use persisted queries as an operation allowlist.
Tooling Landscape#
| Tool | Role |
|---|---|
| Apollo Server / Router | Runtime, federation gateway |
| Hasura | Instant GraphQL over Postgres, event triggers |
| Prisma | Type-safe ORM, generates DB client from schema |
| Pothos | Code-first schema builder for TypeScript |
| GraphQL Code Generator | Types and hooks from your schema |
Pothos example for code-first schemas:
import SchemaBuilder from "@pothos/core";
const builder = new SchemaBuilder({});
builder.queryType({
fields: (t) => ({
hello: t.string({ resolve: () => "world" }),
}),
});
export const schema = builder.toSchema();
Code-first (Pothos, Nexus) keeps types and resolvers co-located. Schema-first (SDL files) works better when non-engineers need to read the API contract.
When to Use What#
- REST — public APIs, simple CRUD, strong HTTP caching needs.
- GraphQL monolith — single-team apps with diverse frontend data requirements.
- Federated GraphQL — multi-team orgs where each domain owns its subgraph.
- Hasura — rapid prototyping or when your schema maps closely to your database.
Wrapping Up#
GraphQL architecture is not about replacing REST everywhere. It is about choosing the right tool for the shape of your problem — and then applying patterns like DataLoader, federation, persisted queries, and complexity analysis to make it production-ready.
Explore more system design patterns and architecture guides at codelit.io.
142 articles on system design at codelit.io/blog.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Comments