Field note / architecture

publishedupdated 2026-05-01#dynamodb#single-table-design#gsi#dynamodb-streams#nosql

DynamoDB Patterns

Single-table design, partition key selection, GSIs, DynamoDB Streams, TTL, capacity modes, and access patterns for high-scale applications.

Overview

DynamoDB is a fully managed NoSQL database with single-digit millisecond latency at any scale. Unlike relational databases, you design the schema around access patterns, not data normalization. The key design decisions — partition key, sort key, GSIs — must be made upfront based on how the application reads data. Single-table design collapses an entire service's data model into one table with overloaded keys.


Architecture

DYNAMODB DATA MODEL
════════════════════════════════════════════════════════

  Table: "ecommerce" (Single-Table Design)
  ┌──────────────────┬──────────────────┬─────────────────────────────┐
  │    PK             │    SK             │    Other Attributes          │
  ├──────────────────┼──────────────────┼─────────────────────────────┤
  │ USER#u1          │ PROFILE           │ name, email, createdAt      │
  │ USER#u1          │ ORDER#o1          │ total, status, items        │
  │ USER#u1          │ ORDER#o2          │ total, status, items        │
  │ ORDER#o1         │ ITEM#p1           │ qty, price, productName     │
  │ ORDER#o1         │ ITEM#p2           │ qty, price, productName     │
  │ PRODUCT#p1       │ DETAILS           │ name, description, stock    │
  │ PRODUCT#p1       │ REVIEW#r1         │ rating, text, userId        │
  └──────────────────┴──────────────────┴─────────────────────────────┘

  Access patterns supported by this design:
  ✓ Get user profile:         PK=USER#u1, SK=PROFILE
  ✓ Get all orders for user:  PK=USER#u1, SK begins_with ORDER#
  ✓ Get all items in order:   PK=ORDER#o1, SK begins_with ITEM#
  ✓ Get product reviews:      PK=PRODUCT#p1, SK begins_with REVIEW#

GSI (GLOBAL SECONDARY INDEX)
════════════════════════════════════════════════════════
  GSI: alternate PK/SK projection for different query patterns

  GSI1:
  ┌──────────────────┬─────────────┬──────────────────────────┐
  │ GSI1PK (status)  │ GSI1SK (dt) │ (projects PK,SK,total)   │
  ├──────────────────┼─────────────┼──────────────────────────┤
  │ ORDER#PENDING    │ 2026-05-01  │ PK=USER#u1, SK=ORDER#o1  │
  │ ORDER#SHIPPED    │ 2026-04-28  │ PK=USER#u1, SK=ORDER#o2  │
  └──────────────────┴─────────────┴──────────────────────────┘

  Enables: Get all PENDING orders sorted by date (admin view)
  Without GSI: would require a Scan (full table, expensive)

CAPACITY MODES
════════════════════════════════════════════════════════
  On-Demand:  pay per request, auto-scales, no capacity planning
              2× cost per RCU/WCU vs provisioned
              Use for: unpredictable traffic, new apps

  Provisioned: specify RCU/WCU, cheaper at scale, can burst 5×
               Set with Auto Scaling: min/max/target utilization
               Use for: predictable traffic, cost optimization

Technical Implementation

Single-Table Design with DynamoDB Client

import { DynamoDBClient, QueryCommand, PutItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommandInput } from '@aws-sdk/lib-dynamodb';

const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region: 'us-east-1' }));
const TABLE = 'ecommerce';

// Get user profile
async function getUserProfile(userId: string) {
  const res = await docClient.send(new GetItemCommand({
    TableName: TABLE,
    Key: { PK: `USER#${userId}`, SK: 'PROFILE' },
  }));
  return res.Item;
}

// Get all orders for a user (using begins_with on SK)
async function getUserOrders(userId: string, status?: string) {
  const params: QueryCommandInput = {
    TableName: TABLE,
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
    ExpressionAttributeValues: {
      ':pk': `USER#${userId}`,
      ':prefix': 'ORDER#',
    },
  };

  if (status) {
    // Filter after query (not as efficient as GSI, but no extra index needed)
    params.FilterExpression = '#s = :status';
    params.ExpressionAttributeNames = { '#s': 'status' };
    params.ExpressionAttributeValues![':status'] = status;
  }

  const res = await docClient.send(new QueryCommand(params));
  return res.Items ?? [];
}

// Get pending orders sorted by date (via GSI)
async function getPendingOrders(limit = 50) {
  const res = await docClient.send(new QueryCommand({
    TableName: TABLE,
    IndexName: 'GSI1',
    KeyConditionExpression: 'GSI1PK = :status',
    ExpressionAttributeValues: { ':status': 'ORDER#PENDING' },
    ScanIndexForward: false,  // descending (newest first)
    Limit: limit,
  }));
  return res.Items ?? [];
}

// Conditional write (optimistic locking)
async function updateOrderStatus(orderId: string, userId: string,
    currentStatus: string, newStatus: string) {
  await docClient.send(new PutItemCommand({
    TableName: TABLE,
    Item: {
      PK: `USER#${userId}`,
      SK: `ORDER#${orderId}`,
      status: newStatus,
      updatedAt: new Date().toISOString(),
    },
    ConditionExpression: '#s = :currentStatus',
    ExpressionAttributeNames: { '#s': 'status' },
    ExpressionAttributeValues: { ':currentStatus': currentStatus },
    // Throws ConditionalCheckFailedException if status changed concurrently
  }));
}

DynamoDB Streams + Lambda for Changelog

// DynamoDB Streams: real-time changelog of every table modification
// Lambda trigger: process inserts/updates/deletes

export const handler = async (event: DynamoDBStreamEvent) => {
  for (const record of event.Records) {
    const { eventName, dynamodb } = record;

    if (eventName === 'INSERT' || eventName === 'MODIFY') {
      const newImage = DynamoDB.Converter.unmarshall(dynamodb!.NewImage!);

      // Only process order status changes
      if (!newImage.PK?.startsWith('USER#') || !newImage.SK?.startsWith('ORDER#')) continue;

      const oldImage = dynamodb!.OldImage
        ? DynamoDB.Converter.unmarshall(dynamodb!.OldImage)
        : null;

      if (oldImage?.status !== newImage.status) {
        await notificationService.send({
          userId: newImage.PK.replace('USER#', ''),
          orderId: newImage.SK.replace('ORDER#', ''),
          event: `Order status changed: ${oldImage?.status} → ${newImage.status}`,
        });
      }
    }
  }
};

Interview Preparation

Q: What is single-table design in DynamoDB and why is it used?

Single-table design stores multiple entity types in one DynamoDB table, using overloaded partition and sort keys (e.g., PK=USER#123, SK=PROFILE and PK=USER#123, SK=ORDER#456). The reason: DynamoDB charges per API call, and a "join" requires multiple GetItem calls. By co-locating related entities in one table, you can retrieve a user and all their orders in a single Query operation (same partition key, sort key range). Multiple tables would require separate calls. The cost: the data model is harder to read without knowing the key schema, and ad-hoc queries are difficult. Single-table design is a deliberate trade-off: excellent performance for known access patterns, poor ergonomics for unknown queries.

Q: When should you use a GSI vs redesigning your primary key?

Use a GSI when you have a well-known access pattern that the primary key doesn't support — like "get all orders by status" when your PK is user-based. GSIs have their own capacity (RCU/WCU) and storage cost. Redesign the primary key (or add an LSI at table creation) when the alternative access pattern is equally primary — you're choosing between two equally frequent query patterns. GSI limitations to know: eventual consistency by default (can be strongly consistent with extra cost), cannot query across two GSIs simultaneously, cannot exceed 20 GSIs per table. If you need more than 5-6 GSIs, reconsider your data model or move to a different database for analytics workloads.

Q: How do DynamoDB Streams work and what can you build with them?

DynamoDB Streams is a time-ordered log of every item-level change in a table (inserts, updates, deletes). Each stream record contains the before and after state of the item. Streams are retained for 24 hours. You attach Lambda functions as triggers to process stream records near-real-time. Use cases: (1) Propagate changes to other systems — update Elasticsearch index when product data changes. (2) Notifications — send email when order status changes. (3) Audit log — write every change to an immutable audit table. (4) Cross-region replication — replicate table changes to another region (DynamoDB Global Tables uses streams internally). (5) Aggregation — maintain a count/sum in a separate item by reacting to inserts.


Learning Resources