Skip to content

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:

#ResolverOperation
AP1Query.user(id)GetItem
AP2Query.user(handle)Query (GSI)
AP3Query.post(id)GetItem
AP4User.postsQuery (GSI)
AP5Post.commentsQuery (primary)
AP6User.commentsQuery (GSI)
AP7User.followingQuery (primary)
AP8User.followersQuery (GSI)
AP9Post.author, Comment.author (batched)BatchGetItem
AP10Lookup 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.”
GraphQL Resolvers entity diagram showing User, Post, Comment, Follow relationships
4 entities · 2 GSIs · partitions shaped for resolver fetches

Table design

Primary key structure

EntityPKSK
UserUSER#<userId>#METADATA
PostPOST#<postId>#METADATA
CommentPOST#<postId>COMMENT#<commentId>
FollowUSER#<followerId>FOLLOWS#<followedId>

GSI design

GSIEntityKeysPurpose
GSI1UserHANDLE#<handle> / USER#<userId>Lookup by handle
GSI1PostUSER#<authorId> / POST#<postId>User’s posts
GSI1CommentUSER#<authorId> / COMMENT#<commentId>User’s comments
GSI2FollowFOLLOWERS#<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.

GraphQL Resolvers DynamoDB schema: PK, SK, GSI columns
4 entities · 2 GSIs · child-in-parent-partition for cheap nested fetches

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

pkskgsi1pkgsi1skEntity Data
USER#u_alice#METADATAHANDLE#aliceUSER#u_alice{ name: "Alice", handle: "alice", bio: "..." }
POST#01HW...#METADATAUSER#u_alicePOST#01HW...{ authorId: "u_alice", title: "Hello", body: "...", commentCount: 2 }
POST#01HW...COMMENT#01HX...USER#u_bobCOMMENT#01HX...{ authorId: "u_bob", body: "Nice post!" }
POST#01HW...COMMENT#01HX...zzUSER#u_carolCOMMENT#01HX...zz{ authorId: "u_carol", body: "Welcome!" }
USER#u_aliceFOLLOWS#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 QueryWhyIf You Need It
Personalized feed (posts from people you follow, ranked)Requires fan-out across N follows × M postsStream-based feed materialization (see social media feed)
Search (“posts containing ‘graphql’“)DynamoDB doesn’t do full-textStream to OpenSearch
Complex aggregations (top posts of the week)Cross-partition aggregationProject to ClickHouse / Athena
Post.likes { user, likedAt } with sort by recencyLikes would be a child of post, fine; sort by recency requires ULIDsAdd a Like entity in POST#<id> partition with ULID SK
Real-time subscriptionsNot a query problemUse 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.

Join the waitlist →


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.

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.