GraphQL API design: Schema / Resolver / N+1 / DataLoader / Federation

puml.online

GraphQL solves REST’s over-fetching / under-fetching problem, but introduces N+1, caching, rate-limiting challenges. This is GraphQL Schema design principles, Resolver chains, DataLoader solving N+1, Apollo Federation microservices federation, Subscription real-time, security and persisted queries.

GraphQL vs REST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@startuml
title "REST vs GraphQL Request Pattern"

actor "Mobile App" as M
actor "Web App" as W

participant "REST API" as R
participant "GraphQL API" as G

== REST: multiple endpoints ==
M -> R : ① GET /users/123
R --> M : ② user info
M -> R : ③ GET /users/123/posts
R --> M : ④ posts
M -> R : ⑤ GET /users/123/followers
R --> M : ⑥ followers

note right of M : 3 requests, possible data dup

== GraphQL: one endpoint ==
W -> G : ⑦ POST /graphql\n{ user(id:123) { name, posts { title }, followers { name } } }
G --> W : ⑧ return all fields in one response

note right of W : 1 request, precise fields

@enduml

REST pain points:

  • over-fetching/users/123 returns all fields, client only uses name
  • under-fetching — need user + posts + followers, 3 requests
  • multi-client maintenance — Mobile / Web need different endpoints

GraphQL advantages:

  • one request, precise fields
  • strongly typed schema — client SDK auto-generated
  • aggregate microservices — Apollo Federation across services

GraphQL Schema design

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# schema.graphql
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
followers(limit: Int = 20): [User!]!
createdAt: DateTime!
}

type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
publishedAt: DateTime
}

type Comment {
id: ID!
body: String!
author: User!
post: Post!
}

type Query {
user(id: ID!): User
me: User
posts(limit: Int = 20, offset: Int = 0): [Post!]!
}

type Mutation {
createPost(input: CreatePostInput!): Post!
updateProfile(input: UpdateProfileInput!): User!
deletePost(id: ID!): Boolean!
}

type Subscription {
postAdded(authorId: ID): Post!
notification(userId: ID!): Notification!
}

input CreatePostInput {
title: String!
content: String!
}

input UpdateProfileInput {
name: String
email: String
}

scalar DateTime

GraphQL Resolver chain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@startuml
title "GraphQL Resolver Chain"

actor "Client" as Client
participant "GraphQL Server" as GQL
participant "Resolver: User" as R1
participant "Resolver: User.posts" as R2
participant "Resolver: Post.author" as R3
participant "Resolver: Post.comments" as R4
participant "Resolver: Comment.author" as R5
database "DB" as DB

Client -> GQL : ① query { user(id:1) { name, posts { title, author { name }, comments { body, author { name } } } } }

GQL -> R1 : ② resolve User
R1 -> DB : ③ SELECT * FROM users WHERE id=1
DB --> R1 : ④ user
R1 --> GQL : ⑤ user

par parallel resolve posts[]
GQL -> R2 : ⑥ resolve User.posts
R2 -> DB : ⑦ SELECT * FROM posts WHERE user_id=1
DB --> R2 : ⑧ posts
R2 --> GQL : ⑨ posts
end

loop each post
par post.author
GQL -> R3 : ⑩ resolve Post.author
R3 -> DB : ⑪ SELECT * FROM users WHERE id=?
DB --> R3 : ⑫ author
and post.comments
GQL -> R4 : ⑬ resolve Post.comments
R4 -> DB : ⑭ SELECT * FROM comments WHERE post_id=?
DB --> R4 : ⑮ comments
end

loop each comment
GQL -> R5 : ⑯ resolve Comment.author
R5 -> DB : ⑰ SELECT * FROM users WHERE id=?
DB --> R5 : ⑱ author
end
end

GQL --> Client : ⑲ one-shot response

@enduml

Notice potential N+1 in resolver chain!

N+1 problem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@startuml
title "N+1 Query Problem"

actor "Client" as Client
participant "GraphQL" as GQL
database "DB" as DB

Client -> GQL : ① query { posts { title, author { name } } }

GQL -> DB : ② SELECT * FROM posts LIMIT 20
DB --> GQL : ③ 20 posts

loop each post (20 iterations)
GQL -> DB : ④ SELECT * FROM users WHERE id=?
DB --> GQL : ⑤ author
end

note over DB
20 + 1 = 21 queries
1 posts + 20 users
slow!
end note

GQL --> Client : ⑥ response

@enduml

Typical scenario: 20 posts, each author is different user → 21 DB queries.

DataLoader solving N+1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@startuml
title "DataLoader Batch + Cache"

actor "Client" as Client
participant "GraphQL" as GQL
participant "DataLoader" as DL
database "DB" as DB

Client -> GQL : ① query { posts { author { name } } }

GQL -> DB : ② SELECT * FROM posts LIMIT 20
DB --> GQL : ③ 20 posts

note over DL
DataLoader receives 20 author_ids
merges into one batch query
end note

GQL -> DL : ④ loadMany([id1, id2, ..., id20])

DL -> DB : ⑤ SELECT * FROM users WHERE id IN (id1, ..., id20)
DB --> DL : ⑥ 20 users (in input order)
DL --> GQL : ⑦ return authors array

note over DB
only 2 queries:
- 1 posts
- 1 users (IN batch)
end note

GQL --> Client : ⑧ response

@enduml

DataLoader three features:

  • Batch — merge multiple loads in same tick
  • Cache — same key doesn’t refetch
  • Per-request — new loader per request, cache doesn’t cross

JS DataLoader implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const DataLoader = require('dataloader');

// batch load user
const userLoader = new DataLoader(async (userIds) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
// must return in input order
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id));
});

// resolver
const Post = {
author: (post, _, { loaders }) => loaders.user.load(post.author_id),
};

Python strawberry + aiocache:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@strawberry.type
class Post:
id: strawberry.ID

@strawberry.field
async def author(self, info) -> 'User':
# use info.context pre-loaded data
return await info.context['user_loader'].load(self.author_id)

# create loader
async def get_user_by_id(user_ids: list[int]) -> list[User]:
users = await db.query("SELECT * FROM users WHERE id = ANY($1)", [user_ids])
return [User.from_row(u) for u in users]

context = {
'user_loader': DataLoader(load_fn=get_user_by_id)
}

Apollo Federation microservices

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@startuml
title "Apollo Federation - Composed GraphQL"

actor "Client" as Client
participant "Apollo Gateway" as Gateway
participant "Users Subgraph" as Users
participant "Posts Subgraph" as Posts
participant "Reviews Subgraph" as Reviews
database "Users DB" as UDB
database "Posts DB" as PDB
database "Reviews DB" as RDB

Client -> Gateway : ① query { user(id:1) { name, posts { title, reviews { rating } } } }

Gateway -> Gateway : ② parse query plan
note right
1. Users.user
2. Posts.posts (extended field)
3. Reviews.reviews (extended field)
end note

Gateway -> Users : ③ query User
Users -> UDB : ④ SELECT
UDB --> Users : ⑤ user
Users --> Gateway : ⑥ user

Gateway -> Posts : ⑦ query Posts.userPosts
Posts -> PDB : ⑧ SELECT posts WHERE user_id
PDB --> Posts : ⑨ posts
Posts --> Gateway : ⑩ posts

Gateway -> Reviews : ⑪ query Reviews.postReviews
Reviews -> RDB : ⑫ SELECT reviews WHERE post_id IN (...)
RDB --> Reviews : ⑬ reviews
Reviews --> Gateway : ⑭ reviews

Gateway -> Gateway : ⑮ assemble nested structure
Gateway --> Client : ⑯ response

@enduml

Federation key concepts:

  • Subgraph — independent service’s GraphQL API
  • Entity — type shared across services (@key)
  • Gateway — route + compose subgraphs
  • Query Planner — parse query, decide cross-subgraph call order

Subgraph schema example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# users subgraph
type User @key(fields: "id") {
id: ID!
name: String!
}

# posts subgraph
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}

type Post @key(fields: "id") {
id: ID!
title: String!
author: User!
}

# reviews subgraph
extend type Post @key(fields: "id") {
id: ID! @external
reviews: [Review!]!
}

GraphQL Subscription real-time

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@startuml
title "GraphQL Subscription - WebSocket"

actor "Client" as Client
participant "GraphQL Server" as GQL
participant "PubSub (Redis/Postgres)" as PS
participant "Event Source" as ES

Client -> GQL : ① WS subscribe\nsubscription { postAdded(authorId:1) { id, title } }
GQL -> PS : ② SUBSCRIBE postAdded:author:1
PS --> GQL : ③ subscribed

note over ES : new event occurs
ES -> PS : ④ PUBLISH postAdded {id, title, author_id=1}
PS -> GQL : ⑤ push
GQL -> Client : ⑥ WS push new data
Client --> Client : ⑦ UI update

@enduml

PubSub implementations:

  • local — Node EventEmitter
  • Redis — Redis Pub/Sub, cross-instance
  • PostgresLISTEN/NOTIFY, simple
  • Kafka — high-throughput scenarios

Subscription resolver:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { RedisPubSub } from 'graphql-redis-subscriptions';
const pubsub = new RedisPubSub();

const Subscription = {
postAdded: {
subscribe: (_, { authorId }) =>
pubsub.asyncIterator([`postAdded:author:${authorId}`]),
},
};

// trigger
pubsub.publish(`postAdded:author:1`, {
postAdded: { id: 123, title: 'New Post' }
});

Persisted queries

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@startuml
title "Persisted Query Flow"

actor "Mobile Client" as M
participant "GraphQL Server" as GQL
database "Apollo Registry" as Reg

== First: register ==
M -> GQL : ① POST /graphql\nquery: ""\nextensions.persistedQuery: { version:1, sha256Hash: "abc..." }
GQL -> Reg : ② lookup hash "abc..."
Reg --> GQL : ③ not found, need full query
GQL -> Reg : ④ register {hash: "abc...", query: "full query"}
Reg --> GQL : ⑤ registered
GQL --> M : ⑥ 200 (client caches hash → query mapping)

== Subsequent: use hash ==
M -> GQL : ⑦ POST /graphql\nextensions.persistedQuery: { sha256Hash: "abc..." }
GQL -> Reg : ⑧ lookup hash
Reg --> GQL : ⑨ found
GQL -> GQL : ⑩ parse hash's query, execute
GQL --> M : ⑪ 200 data

@enduml

Persisted query benefits:

  • request size reduces 90%+ — client only sends hash, not full query
  • server can reject unregistered hashdefense against malicious queries (whitelist mode)
  • APQ (Automatic Persisted Queries) — auto-register

Rate limiting and protection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@startuml
title "GraphQL Cost Analysis + Rate Limiting"

actor "Client" as Client
participant "GraphQL" as GQL
participant "Cost Analyzer" as CA
participant "Rate Limiter" as RL

Client -> GQL : ① query { user(id:1) { posts { author { posts { comments { ... } } } } } }

GQL -> CA : ② calculate query cost
CA -> CA : ③ nesting depth = 5
CA -> CA : ④ field count = 50
CA -> CA : ⑤ alias dup = 10
CA -> CA : ⑥ total cost = 1500

alt Cost > 1000
CA --> GQL : ⑦ reject (too complex)
GQL --> Client : ⑧ 400 Bad Request
else Cost <= 1000
GQL -> RL : ⑨ check rate limit
alt Rate exceeded
RL --> GQL : ⑩ reject
GQL --> Client : ⑪ 429 Too Many Requests
else OK
GQL -> GQL : ⑫ execute
GQL --> Client : ⑬ 200
end
end

@enduml

Rate limiting strategies:

  • Query Depth — nesting depth limit (default 7-10)
  • Query Complexity — total field count + alias
  • Cost Analysis — custom weights (relational query > cache query)
  • Rate Limit — per-second requests / per-hour requests

Error handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}

type CreatePostPayload {
post: Post
errors: [Error!]
userErrors: [UserError!]!
}

type UserError {
field: String
message: String!
code: ErrorCode!
}

enum ErrorCode {
VALIDATION_ERROR
DUPLICATE_EMAIL
UNAUTHORIZED
RATE_LIMITED
}

Three error principles:

  1. don’t use HTTP 500 for business errors — use 200 + payload’s errors
  2. GraphQL errors field reserves for system errors (server crash / DB down)
  3. business errors use union type or payload’s userErrors

Field foot-guns

  • N+1 not solved — DataLoader must be per-request instance, cross-request causes inconsistency. info.context['loader'] pattern.
  • Query too deepposts.author.posts.author.posts.author... user crafts deep query. Depth limit 5-7 levels.
  • Introspection exposes schema — production should disable introspection. noIntrospection: true.
  • Subscription memory leak — client disconnects but subscription not canceled. Clean up on WebSocket close.
  • N+1 in federation — Gateway SQL JOIN cross-service impossible. Each subgraph has its own DataLoader.
  • Persisted query pollution — attacker registers malicious query permanent on server. Auto-register + manual review.
  • Missing monitoring — GraphQL errors hard to trace (whole query is one request). Apollo Studio / OpenTelemetry.
  • Auth in resolver — each resolver checks permission separately, easy to miss. Schema directive @auth.

Decision tree

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
What to do?
├─ Client flexible query → GraphQL
├─ Simple CRUD / public API → REST
├─ High-throughput / simple cache → gRPC
├─ File upload → REST (multipart)
└─ Internal service comm → gRPC

Schema design?
├─ Monolith app → single schema
├─ Microservices → Apollo Federation
└─ Existing REST API → graphql-mesh wrap

Performance?
├─ Solve N+1 → DataLoader (must)
├─ Many same queries → Redis cache resolver
├─ Complex query → query cost + depth limit
└─ Real-time data → Subscription

Security?
├─ Public API → persisted query + complexity limit
├─ Internal → JWT + resolver auth
└─ Complex permission → Schema directive

Minimum start: Apollo Server + DataLoader + depth limit. Production: add Federation + Subscription + Apollo Studio monitoring.

Remember: GraphQL is not a REST replacement, it solves over-fetching / under-fetching. Simple CRUD uses REST simpler. Complex aggregation / multi-client adaptation uses GraphQL. Don’t use GraphQL for the sake of using GraphQL.

  • Title: GraphQL API design: Schema / Resolver / N+1 / DataLoader / Federation
  • Author: puml.online
  • Created at : 2026-07-30 18:10:00
  • Updated at : 2026-08-14 21:34:29
  • Link: https://puml.online/blog/plantuml-graphql-api-design-en/
  • License: This work is licensed under CC BY-NC-SA 4.0.