| name | typescript-dynamodb-toolbox |
| description | DynamoDB single-table design using dynamodb-toolbox v2. Use when creating entities, defining key patterns, designing GSIs, writing queries, implementing pagination, or working with any DynamoDB data layer in TypeScript projects. |
| user-invocable | false |
DynamoDB with dynamodb-toolbox v2
Type-safe DynamoDB interactions with Entity and Table abstractions for single-table design.
When to Use DynamoDB
✓ Use when:
- Access patterns are known upfront and stable
- Need predictable sub-10ms performance at scale
- Microservice with clear data boundaries
- Willing to commit to single-table design
✗ Avoid when:
- Prototyping with fluid requirements
- Need ad-hoc analytical queries
- Team lacks DynamoDB expertise
- GraphQL resolvers drive access patterns
DynamoDB inverts the relational paradigm: design for known access patterns, not flexible querying.
Modeling Checklist
Before implementing:
- Define Entity Relationships - Create ERD with all entities and relationships
- Create Entity Chart - Map each entity to PK/SK patterns
- Design GSI Strategy - Plan secondary access patterns
- Document Access Patterns - List every query the application needs
See references/modeling.md for detailed methodology.
Table Configuration
import { Table } from 'dynamodb-toolbox/table'
const AppTable = new Table({
name: process.env.TABLE_NAME || "AppTable",
partitionKey: { name: "PK", type: "string" },
sortKey: { name: "SK", type: "string" },
indexes: {
GSI1: {
type: "global",
partitionKey: { name: "GSI1PK", type: "string" },
sortKey: { name: "GSI1SK", type: "string" },
},
GSI2: {
type: "global",
partitionKey: { name: "GSI2PK", type: "string" },
sortKey: { name: "GSI2SK", type: "string" },
},
GSI3: {
type: "global",
partitionKey: { name: "GSI3PK", type: "string" },
sortKey: { name: "GSI3SK", type: "string" },
},
},
entityAttributeSavedAs: "_et",
});
Index Purpose
| Index | Purpose |
|---|
| Main Table (PK/SK) | Primary entity access |
| GSI1 | Collection queries (issues by repo, members by org) |
| GSI2 | Entity-specific queries and relationships (forks) |
| GSI3 | Hierarchical queries with temporal sorting (repos by owner) |
Entity Definition (v2 syntax)
Basic Pattern with Linked Keys
import { Entity } from 'dynamodb-toolbox/entity'
import { item } from 'dynamodb-toolbox/schema/item'
import { string } from 'dynamodb-toolbox/schema/string'
const UserEntity = new Entity({
name: "USER",
table: AppTable,
schema: item({
username: string().required().key(),
email: string().required(),
bio: string().optional(),
}).and(_schema => ({
PK: string().key().link<typeof _schema>(
({ username }) => `ACCOUNT#${username}`
),
SK: string().key().link<typeof _schema>(
({ username }) => `ACCOUNT#${username}`
),
GSI1PK: string().link<typeof _schema>(
({ username }) => `ACCOUNT#${username}`
),
GSI1SK: string().link<typeof _schema>(
({ username }) => `ACCOUNT#${username}`
),
})),
});
With Validation
const RepoEntity = new Entity({
name: "REPO",
table: AppTable,
schema: item({
owner: string()
.required()
.validate((value: string) => /^[a-zA-Z0-9_-]+$/.test(value))
.key(),
repo_name: string()
.required()
.validate((value: string) => /^[a-zA-Z0-9_-]+$/.test(value))
.key(),
description: string().optional(),
is_private: boolean().default(false),
}).and(_schema => ({
PK: string().key().link<typeof _schema>(
({ owner, repo_name }) => `REPO#${owner}#${repo_name}`
),
SK: string().key().link<typeof _schema>(
({ owner, repo_name }) => `REPO#${owner}#${repo_name}`
),
GSI3PK: string().link<typeof _schema>(
({ owner }) => `ACCOUNT#${owner}`
),
GSI3SK: string()
.default(() => `#${new Date().toISOString()}`)
.savedAs("GSI3SK"),
})),
});
Entity Chart (Key Patterns)
| Entity | PK | SK | Purpose |
|---|
| User | ACCOUNT#{username} | ACCOUNT#{username} | Direct access |
| Repository | REPO#{owner}#{name} | REPO#{owner}#{name} | Direct access |
| Issue | ISSUE#{owner}#{repo}#{padded_num} | Same as PK | Direct access + enumeration |
| Comment | REPO#{owner}#{repo} | ISSUE#{padded_num}#COMMENT#{id} | Comments under issue |
| Star | ACCOUNT#{username} | STAR#{owner}#{repo}#{timestamp} | Adjacency list pattern |
Key Pattern Rules:
ENTITY#{id} - Simple identifier
PARENT#{id}#CHILD#{id} - Hierarchy
TYPE#{category}#{identifier} - Categorization
#{timestamp} - Temporal sorting (# prefix ensures ordering)
Type Safety
import { type InputItem, type FormattedItem } from 'dynamodb-toolbox/entity'
type UserRecord = typeof UserEntity
type UserInput = InputItem<typeof UserEntity>
type UserFormatted = FormattedItem<typeof UserEntity>
class User {
static fromRecord(record: UserFormatted): User { }
toRecord(): UserInput { }
}
See references/entity-layer.md for transformation patterns.
Repository Pattern
import { PutItemCommand, GetItemCommand, DeleteItemCommand } from 'dynamodb-toolbox'
class UserRepository {
constructor(private entity: UserRecord) {}
async create(user: User): Promise<User> {
try {
const result = await this.entity
.build(PutItemCommand)
.item(user.toRecord())
.options({
condition: { attr: "PK", exists: false },
})
.send()
return User.fromRecord(result.ToolboxItem)
} catch (error) {
if (error instanceof ConditionalCheckFailedException) {
throw new DuplicateEntityError("User", user.username)
}
throw error
}
}
async get(username: string): Promise<User | undefined> {
const result = await this.entity
.build(GetItemCommand)
.key({ username })
.send()
return result.Item ? User.fromRecord(result.Item) : undefined
}
async update(user: User): Promise<User> {
try {
const result = await this.entity
.build(PutItemCommand)
.item(user.toRecord())
.options({
condition: { attr: "PK", exists: true },
})
.send()
return User.fromRecord(result.ToolboxItem)
} catch (error) {
if (error instanceof ConditionalCheckFailedException) {
throw new EntityNotFoundError("User", user.username)
}
throw error
}
}
async delete(username: string): Promise<void> {
await this.entity.build(DeleteItemCommand).key({ username }).send()
}
}
See references/error-handling.md for error patterns.
Query Patterns
Query GSI
import { QueryCommand } from 'dynamodb-toolbox/table/actions/query'
async listIssues(owner: string, repoName: string): Promise<Issue[]> {
const result = await this.table
.build(QueryCommand)
.entities(this.issueEntity)
.query({
partition: `ISSUE#${owner}#${repoName}`,
index: "GSI1",
})
.send()
return result.Items?.map(item => Issue.fromRecord(item)) || []
}
Query with Range Filter
async listOpenIssues(owner: string, repoName: string): Promise<Issue[]> {
const result = await this.table
.build(QueryCommand)
.entities(this.issueEntity)
.query({
partition: `ISSUE#${owner}#${repoName}`,
index: "GSI4",
range: {
beginsWith: "ISSUE#OPEN#",
},
})
.send()
return result.Items?.map(item => Issue.fromRecord(item)) || []
}
Pagination
function encodePageToken(lastEvaluated?: Record<string, unknown>): string | undefined {
return lastEvaluated
? Buffer.from(JSON.stringify(lastEvaluated)).toString("base64")
: undefined
}
function decodePageToken(token?: string): Record<string, unknown> | undefined {
return token ? JSON.parse(Buffer.from(token, "base64").toString()) : undefined
}
async listReposByOwner(owner: string, limit = 50, offset?: string) {
const result = await this.table
.build(QueryCommand)
.entities(this.repoEntity)
.query({
partition: `ACCOUNT#${owner}`,
index: "GSI3",
range: { lt: "ACCOUNT#" },
})
.options({
reverse: true,
exclusiveStartKey: decodePageToken(offset),
limit,
})
.send()
return {
items: result.Items?.map(item => Repo.fromRecord(item)) || [],
nextOffset: encodePageToken(result.LastEvaluatedKey),
}
}
Transactions
See references/transactions.md for:
- Multi-entity transactions (PutTransaction + ConditionCheck)
- Atomic counters with
$add(1)
- TransactionCanceledException handling
Testing
See references/testing.md for:
- DynamoDB Local setup
- Test fixtures and factories
- Concurrency and temporal sorting tests
Quick Reference
Schema:
- Use
item({}) for schema definition
- Mark key attributes with
.key()
- Separate business attributes from computed keys using
.and()
- Use
.link<typeof _schema>() to compute PK/SK/GSI keys
- Use
.validate() for field validation
- Use
.savedAs() when DynamoDB name differs from schema name
Types:
InputItem<T> for writes (excludes computed attributes)
FormattedItem<T> for reads (includes all attributes)
Repository:
- Use
PutItemCommand with { attr: "PK", exists: false } for creates
- Use
PutItemCommand with { attr: "PK", exists: true } for updates
- Use
GetItemCommand with .key() for reads
- Use
QueryCommand with .entities() for type-safe queries
Errors:
ConditionalCheckFailedException → DuplicateEntityError (create) or EntityNotFoundError (update)
- Always catch and convert to domain errors
Testing:
- Use unique IDs per test run (timestamp-based)
- Clean up test data after each test
- Use DynamoDB Local for development