DynamoDB Schema Pattern: GraphQL Resolvers
GraphQL has a runtime characteristic that catches teams off guard the first time they back it with DynamoDB: every field on every level of every query is a separate fetch. A query like user { posts { comments { author } } } is not “one DynamoDB call” - it’s potentially dozens, depending on how many posts and comments come back.
This is the N+1 problem, and DynamoDB is more sensitive to it than SQL because there’s no SQL-style join to fall back on. The schema has to be designed for the access shapes GraphQL actually produces.
The good news: a single-table design lays out unusually well for GraphQL. Co-locating children in their parent’s partition makes nested fields cheap. Sharing a GSI for “by author” relationships keeps fan-out queries to one read each. DataLoader batches the inevitable point lookups into BatchGetItem calls.
This pattern is a four-entity GraphQL schema (users, posts, comments, follows) with the resolver layout that makes it perform.
The GraphQL access patterns
GraphQL doesn’t define your access patterns - your resolvers do. For a typical social/blog schema, the resolver fetch shapes are:
| # | Resolver | Operation |
|---|---|---|
| AP1 | Query.user(id) | GetItem |
| AP2 | Query.user(handle) | Query (GSI) |
| AP3 | Query.post(id) | GetItem |
| AP4 | User.posts | Query (GSI) |
| AP5 | Post.comments | Query (primary) |
| AP6 | User.comments | Query (GSI) |
| AP7 | User.following | Query (primary) |
| AP8 | User.followers | Query (GSI) |
| AP9 | Post.author, Comment.author (batched) | BatchGetItem |
| AP10 | Lookup N posts by ID at once (batched) | BatchGetItem |
The point of the pattern is that every one of these is a single DynamoDB operation - never a scan, never an in-memory join. The schema is shaped so that GraphQL’s nested fetches translate to one query per nest level, with DataLoader batching everything within a level.
The shape: parents own their children’s partitions
The clearest pattern is comments live in the post’s partition:
PK: POST#<postId>
SK: COMMENT#<commentId>
When the GraphQL query asks for post { comments }, the comments resolver is one Query(pk=POST#<id>, sk begins_with COMMENT#) - regardless of how many comments the post has, regardless of who authored them.
Posts, in contrast, do NOT live in the user’s partition. They live in their own partition:
PK: POST#<postId>
SK: #METADATA
With a GSI on authorId for the User.posts resolver:
gsi1pk: USER#<authorId>
gsi1sk: POST#<postId>
Why this asymmetry? Because posts get fetched by ID much more often than they get fetched as a child of a user. Direct getPost(id) is the common GraphQL entry point. Co-locating posts in the user partition would make User.posts faster but Query.post(id) more expensive (would need a GSI to find the partition).
Comments are different: they’re almost always accessed via their parent post, never by ID alone. So co-locating them in the post’s partition is the right call.
This is the per-field decision you make for each child relationship: “is this thing usually fetched by ID, or via its parent?” The answer determines where it lives.
Entities
- User: profile, lookup by id or handle.
- Post: in its own partition, GSI’d by author.
- Comment: in the post’s partition, GSI’d by author.
- Follow: junction with both forward and reverse GSI for “I follow” / “follows me.”

Table design
Primary key structure
| Entity | PK | SK |
|---|---|---|
| User | USER#<userId> | #METADATA |
| Post | POST#<postId> | #METADATA |
| Comment | POST#<postId> | COMMENT#<commentId> |
| Follow | USER#<followerId> | FOLLOWS#<followedId> |
GSI design
| GSI | Entity | Keys | Purpose |
|---|---|---|---|
| GSI1 | User | HANDLE#<handle> / USER#<userId> | Lookup by handle |
| GSI1 | Post | USER#<authorId> / POST#<postId> | User’s posts |
| GSI1 | Comment | USER#<authorId> / COMMENT#<commentId> | User’s comments |
| GSI2 | Follow | FOLLOWERS#<followedId> / FOLLOWER#<followerId> | Reverse follow lookup |
GSI1 is overloaded: handles, posts-by-author, and comments-by-author all share it. The PK prefixes (HANDLE#, USER#) don’t collide.

Resolvers and DataLoader
The schema only matters if the resolvers use it correctly. Here’s the layer that connects them.
One fetch per nest level (with DataLoader)
A query like:
query {
user(id: "u_alice") {
name
posts {
title
comments {
body
author { name }
}
}
}
}
Would naively fan out to: 1 user fetch + 1 posts query + N comment queries (one per post) + M author fetches (one per comment author).
DataLoader batches the point lookups (users, in this case). The per-post comment queries can’t be batched (you can’t BatchQuery in DynamoDB; only BatchGetItem for point lookups), but you can fire them in parallel.
// userLoader: batch all User.id fetches in this request
const userLoader = new DataLoader<string, User>(async (userIds) => {
const result = await client.send(new BatchGetItemCommand({
RequestItems: {
[TABLE]: {
Keys: userIds.map((id) => ({ pk: { S: `USER#${id}` }, sk: { S: "#METADATA" } })),
},
},
}))
// Map result back to userIds order
const items = (result.Responses?.[TABLE] ?? []).map(unmarshall) as User[]
const byId = new Map(items.map((u) => [u.userId, u]))
return userIds.map((id) => byId.get(id) ?? null)
})
// Resolver
const Comment = {
author: (comment, _, ctx) => ctx.userLoader.load(comment.authorId),
}
DataLoader collects all userLoader.load(...) calls within one tick of the event loop and fires a single BatchGetItem for all of them. 50 comments by 30 distinct users = 1 batch call instead of 30 separate GetItems.
Parallel queries, not sequential
For Post.comments, each post needs its own Query (different partition). Run them in parallel with Promise.all:
const Post = {
comments: async (post) => {
const result = await CommentEntity.query
.primary({ postId: post.postId })
.go({ pages: "all" })
return result.data
},
}
GraphQL’s resolver execution naturally parallelizes sibling fetches at the same level, so 10 posts’ comments are 10 parallel queries - one round-trip’s worth of latency, not 10x.
Denormalize counts that are queried often
Post.commentCount is denormalized onto the Post record. The GraphQL resolver returns post.commentCount directly without counting:
const Post = {
commentCount: (post) => post.commentCount, // free
}
Maintaining commentCount correctly is the trade-off: every comment insert/delete needs a transaction that updates the parent post’s count. See transactions vs conditional writes. Worth it because GraphQL clients ask for commentCount on every post in a feed.
Sample data
| pk | sk | gsi1pk | gsi1sk | Entity Data |
|---|---|---|---|---|
USER#u_alice | #METADATA | HANDLE#alice | USER#u_alice | { name: "Alice", handle: "alice", bio: "..." } |
POST#01HW... | #METADATA | USER#u_alice | POST#01HW... | { authorId: "u_alice", title: "Hello", body: "...", commentCount: 2 } |
POST#01HW... | COMMENT#01HX... | USER#u_bob | COMMENT#01HX... | { authorId: "u_bob", body: "Nice post!" } |
POST#01HW... | COMMENT#01HX...zz | USER#u_carol | COMMENT#01HX...zz | { authorId: "u_carol", body: "Welcome!" } |
USER#u_alice | FOLLOWS#u_bob | - | - | (GSI2: FOLLOWERS#u_bob / FOLLOWER#u_alice) |
A single Query for the post fetches both the post record and all its comments in one shot:
// Get post + all comments in one Query
const result = await client.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: { ":pk": { S: "POST#01HW..." } },
}))
// Items contains the metadata item AND all comment items
This is the case where co-location pays off: even when the GraphQL query asks for both post and post.comments, the schema can serve it from a single partition read.
ElectroDB entity definitions
export const UserEntity = new Entity({
model: { entity: "user", version: "1", service: "graphql" },
attributes: {
userId: { type: "string", required: true },
name: { type: "string", required: true },
handle: { type: "string", required: true },
email: { type: "string", required: true },
bio: { type: "string" },
createdAt: {
type: "string", required: true,
default: () => new Date().toISOString(), readOnly: true,
},
},
indexes: {
primary: {
pk: { field: "pk", composite: ["userId"], template: "USER#${userId}" },
sk: { field: "sk", composite: [], template: "#METADATA" },
},
byHandle: {
index: "GSI1",
pk: { field: "gsi1pk", composite: ["handle"], template: "HANDLE#${handle}" },
sk: { field: "gsi1sk", composite: ["userId"], template: "USER#${userId}" },
},
},
}, { client, table }); Why this design
Every child relationship gets a placement decision: “is this child fetched by ID, or always via its parent?” The answer determines whether the child lives in its own partition (with a GSI back to the parent) or in the parent’s partition. Get this wrong and you pay every query. Get it right and the schema fits GraphQL like a glove.
Posts and comments both have authors; both need “all posts by user X” and “all comments by user X.” Sharing a single GSI keyed on USER#<authorId> with type-prefixed sort keys (POST#, COMMENT#) keeps the GSI count to 1 instead of 2.
DataLoader is not optional. Without it, every Post.author resolves to a separate GetItem. With it, all author fetches in a request collapse to one BatchGetItem. It’s the difference between “100 reads per page” and “5 reads per page.”
commentCount, followerCount, likeCount - these are read on every page. Compute on the write side (transactions or Stream-driven projections) and read for free.
Many-to-many relationships use a dedicated junction item, not a set attribute on User. The same junction with a reverse GSI handles both directions of the relationship - User.following (forward) and User.followers (reverse).
What this schema doesn’t support (cleanly)
| Unsupported Query | Why | If You Need It |
|---|---|---|
| Personalized feed (posts from people you follow, ranked) | Requires fan-out across N follows × M posts | Stream-based feed materialization (see social media feed) |
| Search (“posts containing ‘graphql’“) | DynamoDB doesn’t do full-text | Stream to OpenSearch |
| Complex aggregations (top posts of the week) | Cross-partition aggregation | Project to ClickHouse / Athena |
Post.likes { user, likedAt } with sort by recency | Likes would be a child of post, fine; sort by recency requires ULIDs | Add a Like entity in POST#<id> partition with ULID SK |
| Real-time subscriptions | Not a query problem | Use AppSync subscriptions or WebSocket layer |
The unsupported queries post explains why writing this list down is as important as writing the supported ones.
Common GraphQL-on-DynamoDB pitfalls
Don’t connect GraphQL Relay pagination to DynamoDB naively. Relay’s cursor format is opaque, base64-encoded, and uniform. DynamoDB’s LastEvaluatedKey is per-index. You need to translate - encode the LEK as the relay cursor. See DynamoDB pagination done correctly.
BatchGetItem has a 100-key limit. DataLoader will happily try to batch 500 keys into one call; you have to chunk. Most DataLoader DynamoDB implementations handle this; verify your wrapper does.
Watch out for @hasMany directives that secretly do scans. Some GraphQL frameworks (looking at you, AppSync auto-generated resolvers) generate Scan operations for relationship fields when the schema doesn’t have an obvious access path. Always inspect the generated VTL or resolver code; rewrite it to use a Query against your designed partitions.
Caching at the wrong layer is easy to miss. GraphQL caching (Apollo client cache, response cache) is per-query. DynamoDB caching (DAX, app-level) is per-item. The two compose, but you have to decide what’s cached where. For most apps, app-level DataLoader (request-scoped) plus a thin DAX layer (cross-request, item-scoped) is enough.
Design this visually → coming soon
Drag a User onto a canvas, then drop a Post and decide: does it live in the user’s partition, or its own? Watch the schema designer flag what each choice costs you. That’s what I’m building at singletable.dev.
Pattern #10 of 10 in the SingleTable pattern library. The “child lives in parent’s partition” trick recurs across the library - the chat messaging pattern uses it for messages-in-conversation, the content management pattern uses it for article versions, and the event sourcing pattern uses it for events-in-aggregate. GraphQL just makes the consequences visible at every nest level.