Why DynamoDB Deserves a Second Look
Serverless, GraphQL, and DynamoDB form a strong stack for web development. The first two enjoy widespread popularity, but DynamoDB often gets dismissed as a tool only worth learning when you absolutely need its scale. That assumption led many developers, myself included, to stick with SQL databases in serverless applications. After actually learning DynamoDB, I've come to see its value at any project size.
Let's build an API from scratch without an ORM or GraphQL framework obscuring what happens under the hood. By the end, you might find DynamoDB is worth the upfront investment.
Addressing the Common Complaints
The biggest knock against DynamoDB is its steep learning curve. But SQL databases present their own challenges in a serverless world: where do you host the database, and how do you handle connection management? Those concerns don't fit naturally with the serverless model. DynamoDB is serverless-friendly by design—you trade the initial pain of learning something new for avoiding problems that compound as your application grows.
The GraphQL-DynamoDB pairing draws more nuanced criticism. Much of GraphQL's documentation assumes a relational database underneath. Alex Debrie, author of The DynamoDB Book, has even advised against using the two together, mainly because typical GraphQL resolvers run sequential independent database calls that can multiply into excessive reads.
Another concern: DynamoDB performs best when access patterns are known in advance, while GraphQL famously handles arbitrary queries well. This matters mostly for public APIs. In practice, GraphQL often powers private APIs where you control both client and server—so you know and can constrain the queries being run. Without safeguards, a GraphQL API can overload any database anyway.
Modeling the Data
For this example, we'll model an organization containing teams, users, and certifications. A team has many users, and a user holds many certifications.

In a relational database, this becomes separate tables. To handle the many-to-many user-certification relationship, we'd add an intermediate table called "Credential," which carries an expiration date as its only unique attribute. For simplicity, every other table contains just a name.

DynamoDB doesn't support joins, so instead of starting with normalized data, you shape the model around your access patterns from the start. This requires an iterative approach:
Most frequently accessed:
- User by ID or name
- Team by ID or name
- Certification by ID or name
Frequently accessed:
- All users on a team by team ID
- All certifications for a given user
- All teams
- All certifications
Rarely accessed:
- All certifications of users on a team
- All users who hold a certification
- All users who hold a certification on a team
Patterns in the rarely accessed category—like an admin running a check once a week—don't need optimization. An inefficient table scan can handle those.
Single Table Design
Since DynamoDB offers no joins and only supports querying by primary keys or predefined indexes, the recommended practice is storing all item types in one table. This lets you retrieve related items together with a single query. The table below models our data using that approach:

The primary key combines a partition key (pk) and sort key (sk). You must supply the partition key exactly and provide either a specific sort key value or a range of values. This design lets a single query retrieve multiple items sharing a partition key. Generic index attribute names like gsi1pk and gsi1sk appear here deliberately, so the same index can serve different item types with different access patterns. Since sort keys can't be empty in a composite key, the placeholder "#" fills in when no sort key is needed.
| Access pattern | Query conditions |
|---|---|
| Team, User, or Certification by ID | Primary Key, pk=”T#”+ID, sk=”#” |
| Team, User, or Certification by name | Index GSI 1, gsi1pk=type, gsi1sk=name |
| All Teams, Users, or Certifications | Index GSI 1, gsi1pk=type |
| All Users on a Team by ID | Index GSI 2, gsi2pk=”T#”+teamID |
| All Certifications for a User by ID | Primary Key, pk=”U#”+userID, sk=”C#”+certID |
| All Users with a Certification by ID | Index GSI 1, gsi1pk=”C#”+certID, gsi1sk=”U#”+userID |
The application enforces the schema. While the DynamoDB API is powerful, it's also verbose, which tempts many developers toward an ORM. Instead, we'll use the helper functions below to build the schema directly and access the database directly for a Team item:
const DB_MAP = {
TEAM: {
get: ({ teamId }) => ({
pk: 'T#'+teamId,
sk: '#',
}),
put: ({ teamId, teamName }) => ({
pk: 'T#'+teamId,
sk: '#',
gsi1pk: 'Team',
gsi1sk: teamName,
_tp: 'Team',
tn: teamName,
}),
parse: ({ pk, tn, _tp }) => {
if (_tp === 'Team') {
return {
id: pk.slice(2),
name: tn,
};
} else return null;
},
queryByName: ({ teamName }) => ({
IndexName: 'gsi1pk-gsi1sk-index',
ExpressionAttributeNames: { '#p': 'gsi1pk', '#s': 'gsi1sk' },
KeyConditionExpression: '#p = :p AND #s = :s',
ExpressionAttributeValues: { ':p': 'Team', ':s': teamName },
ScanIndexForward: true,
}),
queryAll: {
IndexName: 'gsi1pk-gsi1sk-index',
ExpressionAttributeNames: { '#p': 'gsi1pk' },
KeyConditionExpression: '#p = :p ',
ExpressionAttributeValues: { ':p': 'Team' },
ScanIndexForward: true,
},
},
parseList: (list, type) => {
if (Array.isArray(list)) {
return list.map(i => DB_MAP[type].parse(i));
}
if (Array.isArray(list.Items)) {
return list.Items.map(i => DB_MAP[type].parse(i));
}
},
};
Putting a new team item in the database looks like:
DB_MAP.TEAM.put({teamId:"t_01",teamName:"North Team"})
These functions construct the index and key values passed to the database API. The parse method reverses the process, translating database items back into the application model.
GraphQL Schema
type Team {
id: ID!
name: String
members: [User]
}
type User {
id: ID!
name: String
team: Team
credentials: [Credential]
}
type Certification {
id: ID!
name: String
}
type Credential {
id: ID!
user: User
certification: Certification
expiration: String
}
type Query {
team(id: ID!): Team
teamByName(name: String!): [Team]
user(id: ID!): User
userByName(name: String!): [User]
certification(id: ID!): Certification
certificationByName(name: String!): [Certification]
allTeams: [Team]
allCertifications: [Certification]
allUsers: [User]
}
Making the Two Work Together: Resolvers
Resolvers execute GraphQL queries. It's entirely possible to work with GraphQL for a long time without writing a resolver, but building this API requires several. Each query in the GraphQL schema maps to a root resolver (only the team resolvers appear below), which returns either a promise or an object containing partial query results.
When a query returns a Team type, execution flows to the Team type resolver, which has a function for each field in the type. Fields without a dedicated resolver—like id—simply look for the value passed down from the root resolver.
Each query resolver receives four arguments:
root(orparent): object from the resolver above with partial resultsargs: arguments supplied to the querycontext: application state available during resolution— here, holding a database referenceinfo: unused in this example, offers deeper query details like an abstract syntax tree
In the resolvers, ctx.db.singletable points to the DynamoDB table holding all our data. The get and query methods run directly against the database, while the DB_MAP.TEAM.... constant maps the schema to the database via the helper functions. Then parse converts data back into the shape the GraphQL schema expects.
const resolverMap = {
Query: {
team: (root, args, ctx, info) => {
return ctx.db.singletable.get(DB_MAP.TEAM.get({ teamId: args.id }))
.then(data => DB_MAP.TEAM.parse(data));
},
teamByName: (root, args, ctx, info) =>; {
return ctx.db.singletable
.query(DB_MAP.TEAM.queryByName({ teamName: args.name }))
.then(data => DB_MAP.parseList(data, 'TEAM'));
},
allTeams: (root, args, ctx, info) => {
return ctx.db.singletable.query(DB_MAP.TEAM.queryAll)
.then(data => DB_MAP.parseList(data, 'TEAM'));
},
},
Team: {
name: (root, _, ctx) => {
if (root.name) {
return root.name;
} else {
return ctx.db.singletable.get(DB_MAP.TEAM.get({ teamId: root.id }))
.then(data => DB_MAP.TEAM.parse(data).name);
}
},
members: (root, _, ctx) => {
return ctx.db.singletable
.query(DB_MAP.USER.queryByTeamId({ teamId: root.id }))
.then(data => DB_MAP.parseList(data, 'USER'));
},
},
User: {
name: (root, _, ctx) => {
if (root.name) {
return root.name;
} else {
return ctx.db.singletable.get(DB_MAP.USER.get({ userId: root.id }))
.then(data => DB_MAP.USER.parse(data).name);
}
},
credentials: (root, _, ctx) => {
return ctx.db.singletable
.query(DB_MAP.CREDENTIAL.queryByUserId({ userId: root.id }))
.then(data =>DB_MAP.parseList(data, 'CREDENTIAL'));
},
},
};
Let's trace the query below. The team root resolver reads the team by id, returning id and name. The Team type resolver loads that team's members, then the User resolver runs for each user to fetch their credentials and certifications. A team with five members, each holding five credentials, produces seven database reads total. That's more than the four calls a SQL database might require—but those DynamoDB reads will often be cheaper and faster. As always, the answer depends heavily on context.
query { team( id:"t_01" ){
id
name
members{
id
name
credentials{
id
certification{
id
name
}
}
}
}}
Over-Fetching and the N+1 Problem
Optimizing GraphQL APIs means weighing tradeoffs, and two of the biggest considerations in the SQL versus DynamoDB decision are over-fetching and the N+1 problem. These are two sides of one coin. Over-fetching occurs when a resolver grabs more data from the database than the query requires—often because a higher-level resolver (like the members query in the Team type) tries to fetch as much as possible in one call. If the client never requested the name attribute, that fetch was wasteful.
The N+1 problem works the opposite way. If every read sits at the bottom-most resolver, the root team resolver and the members resolver simply pass IDs downward, so retrieving members for our query would trigger five separate reads instead of one—potentially 36 or more reads total. In practice, this rarely happens: an optimized server uses something like the DataLoader library to intercept those calls and batch them down to roughly four database operations. Small, atomic reads are exactly what DataLoader needs for efficient batching.
So with SQL, the winning approach usually means small resolvers at the lowest levels, optimized by DataLoader. For DynamoDB, the better strategy is having "smarter" resolvers higher up that align with your database's access patterns. Any resulting over-fetching generally proves the lesser of the two evils.
Running This Example
You can get the full example from the GitHub repo. It's built with Architect, an open-source tool that simplifies building serverless apps on AWS. After cloning the repo and installing dependencies with npm install, a single command launches the app for local development (complete with a local emulation of the database). Another single command deploys it to production on AWS, including DynamoDB itself.



