Setting Up DynamoDB for Web Application
DynamoDB is a managed NoSQL database from AWS with guaranteed millisecond latency at any scale. No servers to maintain, no manual sharding, no replica lag issues. Ideal for serverless architectures and applications with unpredictable load spikes.
Key concepts before design
DynamoDB is neither a relational database nor MongoDB. There are no JOINs, no aggregate queries without full scans, no flexible filters. All queries are built around Partition Key (PK) and Sort Key (SK). Single-table design is the standard approach: one table for all application entities with overloaded PK/SK.
Creating a table via AWS CDK (TypeScript)
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'
import { RemovalPolicy } from 'aws-cdk-lib'
const table = new dynamodb.Table(this, 'AppTable', {
tableName: 'MyApp',
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // or PROVISIONED + auto scaling
pointInTimeRecovery: true,
deletionProtection: true,
removalPolicy: RemovalPolicy.RETAIN,
// Streams to react to changes
stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
})
// GSI for email lookup
table.addGlobalSecondaryIndex({
indexName: 'GSI1',
partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.ALL,
})
Single-table design: schema example
Entity: User
PK: USER#<userId> SK: METADATA
GSI1PK: EMAIL#<email> GSI1SK: USER#<userId>
Entity: Order
PK: USER#<userId> SK: ORDER#<orderId>
GSI1PK: ORDER#<orderId> GSI1SK: USER#<userId>
Entity: OrderItem
PK: ORDER#<orderId> SK: ITEM#<itemId>
Entity: Product
PK: PRODUCT#<productId> SK: METADATA
GSI1PK: CATEGORY#<cat> GSI1SK: PRODUCT#<productId>
DynamoDB client (AWS SDK v3)
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
QueryCommand,
UpdateCommand,
DeleteCommand,
TransactWriteCommand
} from '@aws-sdk/lib-dynamodb'
const client = new DynamoDBClient({ region: process.env.AWS_REGION })
const db = DynamoDBDocumentClient.from(client, {
marshallOptions: { removeUndefinedValues: true }
})
const TABLE = 'MyApp'
User repository
export class UserRepository {
async create(user: CreateUserInput): Promise<User> {
const id = crypto.randomUUID()
const now = new Date().toISOString()
await db.send(new TransactWriteCommand({
TransactItems: [
{
Put: {
TableName: TABLE,
Item: {
PK: `USER#${id}`,
SK: 'METADATA',
GSI1PK: `EMAIL#${user.email}`,
GSI1SK: `USER#${id}`,
id,
email: user.email,
name: user.name,
passwordHash: user.passwordHash,
role: 'user',
createdAt: now,
updatedAt: now,
_type: 'User'
},
ConditionExpression: 'attribute_not_exists(PK)'
}
}
]
}))
return { id, ...user, role: 'user', createdAt: now, updatedAt: now }
}
async findByEmail(email: string): Promise<User | null> {
const result = await db.send(new QueryCommand({
TableName: TABLE,
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk',
ExpressionAttributeValues: { ':pk': `EMAIL#${email}` },
Limit: 1
}))
return (result.Items?.[0] as User) ?? null
}
async findById(id: string): Promise<User | null> {
const result = await db.send(new GetCommand({
TableName: TABLE,
Key: { PK: `USER#${id}`, SK: 'METADATA' }
}))
return (result.Item as User) ?? null
}
}
Orders with pagination
export class OrderRepository {
async listForUser(userId: string, limit = 25, lastKey?: Record<string, unknown>) {
const result = await db.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
ExpressionAttributeValues: {
':pk': `USER#${userId}`,
':prefix': 'ORDER#'
},
ScanIndexForward: false, // newest first
Limit: limit,
ExclusiveStartKey: lastKey
}))
return {
items: result.Items as Order[],
nextKey: result.LastEvaluatedKey
}
}
async createWithItems(order: CreateOrderInput): Promise<Order> {
const orderId = crypto.randomUUID()
const now = new Date().toISOString()
const items: TransactWriteItem[] = [
{
Put: {
TableName: TABLE,
Item: {
PK: `USER#${order.userId}`,
SK: `ORDER#${now}#${orderId}`, // now in SK for date sorting
GSI1PK: `ORDER#${orderId}`,
GSI1SK: `USER#${order.userId}`,
id: orderId,
userId: order.userId,
status: 'pending',
total: order.total,
createdAt: now,
_type: 'Order'
}
}
},
...order.items.map(item => ({
Put: {
TableName: TABLE,
Item: {
PK: `ORDER#${orderId}`,
SK: `ITEM#${item.productId}`,
orderId,
productId: item.productId,
quantity: item.quantity,
price: item.price,
_type: 'OrderItem'
}
}
}))
]
await db.send(new TransactWriteCommand({ TransactItems: items }))
return { id: orderId, ...order, status: 'pending', createdAt: now }
}
}
DynamoDB Streams + Lambda setup
// Process changes in real-time
export const handler = async (event: DynamoDBStreamEvent) => {
for (const record of event.Records) {
if (record.eventName !== 'MODIFY') continue
const newImage = unmarshall(record.dynamodb!.NewImage!)
const oldImage = unmarshall(record.dynamodb!.OldImage!)
if (newImage._type === 'Order' && newImage.status !== oldImage.status) {
await notifyOrderStatusChange(newImage.id, newImage.status)
}
}
}
CloudWatch monitoring
Key metrics: ConsumedReadCapacityUnits, ConsumedWriteCapacityUnits, SuccessfulRequestLatency, SystemErrors, ThrottledRequests. Alert immediately on ThrottledRequests > 0.
Timelines
Designing single-table schema + basic Node.js/Python integration: 3–5 days. Adding GSI, Streams, Lambda handlers and monitoring: 3–5 more days. Migration from relational database to DynamoDB with access pattern review: 2–4 weeks — it's not mechanical conversion but rethinking of data model.







