Skip to main content

Key Design Patterns

This guide explains how to design partition keys (PK) and sort keys (SK) for your entities in DynamoDB. Proper key design is critical for performance, scalability, and query efficiency.

When to Use This Guide

Use this guide when you need to:

  • Design keys for a new entity type
  • Model parent-child relationships (Order → OrderItems)
  • Support multi-tenant data isolation
  • Enable efficient query patterns (list by tenant, filter by date)
  • Handle versioning for optimistic locking

Problems This Pattern Solves

ProblemSolution
Querying all items for a tenant is slowInclude tenant code in PK for partition-level isolation
Can't list child items without knowing all keysUse shared PK with different SK prefixes
IDs are not sortable by creation timeUse ULID which is both unique and time-sortable
Version conflicts in concurrent updatesVersion suffix in SK enables optimistic locking

Key Structure Overview

The framework uses a consistent key structure:

PK = PREFIX#TENANT_CODE
SK = IDENTIFIER[#VERSION]
ID = PK#SK (without version)

The KEY_SEPARATOR constant (#) is used to separate key components.

Basic Key Generation

Import utilities from the core package:

import {
generateId,
KEY_SEPARATOR,
removeSortKeyVersion,
addSortKeyVersion,
VERSION_FIRST,
} from "@mbc-cqrs-serverless/core";
import { ulid } from "ulid";

Generating Keys

const PRODUCT_PK_PREFIX = "PRODUCT";

// Generate PK
const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`;
// Result: "PRODUCT#tenant001"

// Generate SK (using ULID for uniqueness and sortability)
const sk = ulid();
// Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M"

// Generate ID (combination of PK and SK)
const id = generateId(pk, sk);
// Result: "PRODUCT#tenant001#01HX7MBJK3V9WQBZ7XNDK5ZT2M"

Version Handling

// Add version to SK
const skWithVersion = addSortKeyVersion(sk, 3);
// Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M#v3"

// Remove version from SK
const baseSk = removeSortKeyVersion(skWithVersion);
// Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M"

Common Key Patterns

Pattern 1: Simple Entity

Use Case: Product Catalog

Scenario: Store products that belong to a tenant with unique IDs.

When to use: Standalone entities without parent-child relationships.

// Key Structure
PK: PRODUCT#<tenantCode>
SK: <ulid>

// Example
PK: PRODUCT#tenant001
SK: 01HX7MBJK3V9WQBZ7XNDK5ZT2M
ID: PRODUCT#tenant001#01HX7MBJK3V9WQBZ7XNDK5ZT2M
const pk = `PRODUCT${KEY_SEPARATOR}${tenantCode}`;
const sk = ulid();
const id = generateId(pk, sk);

Pattern 2: Hierarchical Entity

Use Case: Order with Line Items

Scenario: An order contains multiple items. Need to query all items for an order efficiently.

Solution: Share PK between parent and children, use SK prefix to distinguish item types.

// Order Key Structure
PK: ORDER#<tenantCode>
SK: ORDER#<orderId>

// Order Item Key Structure (same PK, different SK prefix)
PK: ORDER#<tenantCode>
SK: ORDER_ITEM#<orderId>#<itemId>

// Example
Order:
PK: ORDER#tenant001
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M

Order Items:
PK: ORDER#tenant001
SK: ORDER_ITEM#01HX7MBJK3V9WQBZ7XNDK5ZT2M#001
SK: ORDER_ITEM#01HX7MBJK3V9WQBZ7XNDK5ZT2M#002
const ORDER_SK_PREFIX = "ORDER";
const ORDER_ITEM_SK_PREFIX = "ORDER_ITEM";

// Create order
const orderPk = `ORDER${KEY_SEPARATOR}${tenantCode}`;
const orderId = ulid();
const orderSk = `${ORDER_SK_PREFIX}${KEY_SEPARATOR}${orderId}`;

// Create order item
const itemSk = `${ORDER_ITEM_SK_PREFIX}${KEY_SEPARATOR}${orderId}${KEY_SEPARATOR}${itemId}`;

Pattern 3: User with Multiple Auth Providers

Use Case: Unified User Identity

Scenario: Users can sign in with local password, SSO, or OAuth. Need to link all auth methods to one user.

Solution: Same PK for all user records, SK prefix indicates authentication provider.

// Key Structure
PK: USER#<tenantCode>
SK: <provider>#<userId>

// Examples
PK: USER#common
SK: local#user123 // Local authentication
SK: sso#abc123def456 // SSO provider
SK: oauth#google789 // OAuth provider
SK: temp#session456 // Temporary session
SK: profile#user123 // User profile data
type AuthProvider = "local" | "sso" | "oauth" | "temp" | "profile";

function generateUserSk(provider: AuthProvider, userId: string): string {
return `${provider}${KEY_SEPARATOR}${userId}`;
}

const pk = `USER${KEY_SEPARATOR}common`;
const sk = generateUserSk("sso", cognitoSubId);

Pattern 4: Multi-Tenant Association

Use Case: User Belongs to Multiple Organizations

Scenario: In a SaaS application, one user can belong to multiple tenants/organizations.

Solution: Use a common tenant with SK that combines tenant and user codes.

// Key Structure
PK: USER_TENANT#<commonTenant>
SK: <tenantCode>#<userCode>

// Example
PK: USER_TENANT#common
SK: tenant001#user123
SK: tenant002#user123 // Same user in different tenant
const pk = `USER_TENANT${KEY_SEPARATOR}common`;
const sk = `${tenantCode}${KEY_SEPARATOR}${userCode}`;

Pattern 5: Master Data with Categories

Use Case: Application Settings and Configuration

Scenario: Store email templates, product categories, and application settings.

Solution: Use type prefix (SETTING, DATA) in SK to organize different configuration types.

// Key Structure
PK: MASTER#<tenantCode>
SK: <type>#<category>#<code>

// Types: SETTING, DATA, COPY
// Examples
PK: MASTER#tenant001
SK: SETTING#notification#email_template
SK: DATA#product_category#electronics
SK: DATA#product_category#clothing
SK: COPY#backup#2024-01-01
const SETTING_PREFIX = "SETTING";
const DATA_PREFIX = "DATA";

function generateMasterSk(type: string, category: string, code: string): string {
return `${type}${KEY_SEPARATOR}${category}${KEY_SEPARATOR}${code}`;
}

const pk = `MASTER${KEY_SEPARATOR}${tenantCode}`;
const sk = generateMasterSk(DATA_PREFIX, "product_category", "electronics");

Pattern 6: Time-Series Data

Use Case: Activity Logs and Audit Trail

Scenario: Store time-stamped events that need to be queried by date range.

Solution: Include date in PK for time-based partitioning, timestamp in SK for sorting.

// Key Structure
PK: LOG#<tenantCode>#<year-month>
SK: <timestamp>#<eventId>

// Example
PK: LOG#tenant001#2024-01
SK: 2024-01-15T10:30:00Z#evt001
SK: 2024-01-15T10:31:00Z#evt002
function generateLogKeys(tenantCode: string, timestamp: Date, eventId: string) {
const yearMonth = timestamp.toISOString().slice(0, 7); // "2024-01"
const pk = `LOG${KEY_SEPARATOR}${tenantCode}${KEY_SEPARATOR}${yearMonth}`;
const sk = `${timestamp.toISOString()}${KEY_SEPARATOR}${eventId}`;
return { pk, sk };
}

Key Helper Functions

Create a helpers file for consistent key generation:

// helpers/key.ts
import { KEY_SEPARATOR, generateId } from "@mbc-cqrs-serverless/core";
import { ulid } from "ulid";

// Entity prefixes
export const PRODUCT_PK_PREFIX = "PRODUCT";
export const ORDER_PK_PREFIX = "ORDER";
export const ORDER_SK_PREFIX = "ORDER";
export const ORDER_ITEM_SK_PREFIX = "ORDER_ITEM";
export const USER_PK_PREFIX = "USER";
export const MASTER_PK_PREFIX = "MASTER";
export const NOTIFICATION_PK_PREFIX = "NOTIFICATION";

// Key generators
export function generateProductPk(tenantCode: string): string {
return `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`;
}

export function generateOrderPk(tenantCode: string): string {
return `${ORDER_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`;
}

export function generateOrderSk(orderId?: string): string {
const id = orderId ?? ulid();
return `${ORDER_SK_PREFIX}${KEY_SEPARATOR}${id}`;
}

export function generateOrderItemSk(orderId: string, itemId: string): string {
return `${ORDER_ITEM_SK_PREFIX}${KEY_SEPARATOR}${orderId}${KEY_SEPARATOR}${itemId}`;
}

// Key parsers
export function parseOrderSk(sk: string): { prefix: string; orderId: string } {
const parts = sk.split(KEY_SEPARATOR);
return {
prefix: parts[0],
orderId: parts[1],
};
}

export function parseOrderItemSk(sk: string): {
prefix: string;
orderId: string;
itemId: string;
} {
const parts = sk.split(KEY_SEPARATOR);
return {
prefix: parts[0],
orderId: parts[1],
itemId: parts[2],
};
}

// ID generator with entity type
export function generateEntityId(
prefix: string,
tenantCode: string,
sk?: string,
): { pk: string; sk: string; id: string } {
const pk = `${prefix}${KEY_SEPARATOR}${tenantCode}`;
const finalSk = sk ?? ulid();
const id = generateId(pk, finalSk);
return { pk, sk: finalSk, id };
}

Version Management

The framework uses versioning for optimistic locking:

// DynamoDB Tables
// Command table: Stores all versions
// Data table: Stores latest version only
// History table: Stores all versions with @ suffix

// Version in SK
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M#v1 // Version 1
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M#v2 // Version 2
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M#v3 // Version 3

// History table SK (uses @ instead of #)
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M@v1
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M@v2
import {
VERSION_FIRST,
VERSION_LATEST,
addSortKeyVersion,
removeSortKeyVersion,
} from "@mbc-cqrs-serverless/core";

// Creating first version
const command = new OrderCommandDto({
pk,
sk,
version: VERSION_FIRST, // 1
// ...
});

// Reading specific version from history
const skWithVersion = addSortKeyVersion(baseSk, 2);
const historicalItem = await historyService.getItem({ pk, sk: skWithVersion });

// In Data Sync Handler - always remove version for RDS storage
async up(cmd: CommandModel): Promise<any> {
const sk = removeSortKeyVersion(cmd.sk);
// Store sk without version in RDS
}

Query Patterns

Query by PK

// Get all products for a tenant
const items = await dataService.listItemsByPk({
pk: `PRODUCT${KEY_SEPARATOR}${tenantCode}`,
});

Query with SK Prefix

// Get all order items for a specific order
const items = await dataService.listItemsByPk({
pk: `ORDER${KEY_SEPARATOR}${tenantCode}`,
skPrefix: `ORDER_ITEM${KEY_SEPARATOR}${orderId}`,
});

Query with SK Range

// Get orders within a date range (if using timestamp in SK)
const items = await dataService.query({
pk: `ORDER${KEY_SEPARATOR}${tenantCode}`,
sk: {
between: [startDate, endDate],
},
});

Best Practices

1. Use Consistent Prefixes

Define prefixes as constants:

// Good
export const PRODUCT_PK_PREFIX = "PRODUCT";
const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`;

// Avoid
const pk = `PRODUCT#${tenantCode}`; // Magic string

2. Use ULID for Sortable IDs

ULID provides both uniqueness and time-based sorting:

import { ulid } from "ulid";

const sk = ulid();
// Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M"
// Sortable by creation time

3. Design for Query Patterns

Structure keys based on how you'll query data:

// If you need to query all items for an order:
PK: ORDER#tenant001
SK: ORDER_ITEM#orderId#itemId // Query by SK prefix

// If you need to query items by product across orders:
// Consider a GSI or separate table

4. Keep PK Cardinality Manageable

Avoid too many unique PKs to prevent hot partitions:

// Good - bounded by tenants
PK: PRODUCT#tenant001

// Avoid - unbounded by users
PK: USER_ACTIVITY#user123 // Could create millions of partitions

5. Include Tenant in PK

Always include tenant code in PK for multi-tenant isolation:

// Good
PK: PRODUCT#tenant001

// Avoid
PK: PRODUCT // No tenant isolation
SK: tenant001#productId // Tenant in SK is less efficient

6. Use Common Tenant for Shared Data

Use a common tenant code for data shared across tenants:

// User data (shared across tenants)
PK: USER#common
SK: sso#userId

// User-tenant association
PK: USER_TENANT#common
SK: tenant001#userId

Anti-Patterns to Avoid

1. Embedding Too Much in Keys

// Avoid - too complex
SK: ORDER#2024-01-15#electronics#high-priority#01HX7M...

// Better - use attributes for filtering
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M
attributes: {
date: "2024-01-15",
category: "electronics",
priority: "high"
}

2. Using Mutable Data in Keys

// Avoid - status changes
SK: ORDER#pending#01HX7M... // What happens when status changes?

// Better - use attributes
SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M
attributes: { status: "pending" }

3. Inconsistent Separators

// Avoid - mixed separators
SK: ORDER-01HX7M_item:001

// Better - consistent separator
SK: ORDER#01HX7M#ITEM#001