Key Patterns

Effective key design is crucial for DynamoDB performance. Turbine makes it easy to implement common patterns.

How Keys Work

In Turbine, keys are defined as functions that transform your entity data into DynamoDB attributes:

keys: {
  pk: (user) => ["user", user.id],     // "user#123"
  sk: (user) => ["profile", user.email], // "profile#alice@example.com"
}

Array Joining

Arrays are automatically joined with #:

["user", "123"]           // becomes "user#123"
["post", "2024-01-15", "abc"] // becomes "post#2024-01-15#abc"

Key Types

TypeExampleResult
String"user""user"
Number123123
Array["user", id]"user#<id>"
Function() => "static""static"

Single-Table Design

Single-table design stores multiple entity types in one table, using key prefixes to differentiate them.

Entity Type Prefix

const users = defineEntity({
  table,
  schema: z.object({
    id: z.string(),
    email: z.string(),
    name: z.string(),
  }),
  keys: {
    type: () => "user",
    pk: (u) => ["user", u.id],
    sk: () => "profile",
  },
});

const posts = defineEntity({
  table,
  schema: z.object({
    id: z.string(),
    authorId: z.string(),
    title: z.string(),
  }),
  keys: {
    type: () => "post",
    pk: (p) => ["user", p.authorId],
    sk: (p) => ["post", p.id],
  },
});

Query by Entity Type

Use a GSI with type as the hash key:

// Get all users
const allUsers = await users.queryAll({
  index: "gsi1",
  type: "user",
});

// Get all posts
const allPosts = await posts.queryAll({
  index: "gsi1",
  type: "post",
});

Common Access Patterns

One-to-Many Relationships

Store related items under the same partition key:

// User owns posts
const posts = defineEntity({
  table,
  schema: z.object({
    id: z.string(),
    authorId: z.string(),
    title: z.string(),
    createdAt: z.string().datetime(),
  }),
  keys: {
    pk: (p) => ["user", p.authorId],
    sk: (p) => ["post", p.createdAt, p.id],
  },
});

// Get all posts by user, sorted by date
const userPosts = await posts.queryAll({
  pk: ["user", userId],
  sk: { beginsWith: "post#" },
});

Lookup by ID

Create a GSI for direct ID lookups:

const posts = defineEntity({
  table,
  schema: z.object({
    id: z.string(),
    authorId: z.string(),
    title: z.string(),
  }),
  keys: {
    pk: (p) => ["user", p.authorId],
    sk: (p) => ["post", p.id],
    // GSI for ID lookup
    gsi1pk: () => "post#id",
    gsi1sk: (p) => p.id,
  },
});

// Fetch by ID (when you don't know the author)
const post = await posts.queryOne({
  index: "gsi1",
  gsi1pk: "post#id",
  gsi1sk: postId,
});

Unique Constraints

Use a GSI to enforce uniqueness:

const users = defineEntity({
  table,
  schema: z.object({
    id: z.string(),
    email: z.string().email(),
    username: z.string(),
  }),
  keys: {
    pk: (u) => ["user", u.id],
    sk: () => "profile",
    // GSI for email lookup (unique)
    gsi1pk: () => "user#email",
    gsi1sk: (u) => u.email,
    // GSI for username lookup (unique)
    gsi2pk: () => "user#username",
    gsi2sk: (u) => u.username,
  },
});

// Check if email exists
const existing = await users.queryOne({
  index: "gsi1",
  gsi1pk: "user#email",
  gsi1sk: email,
});

Timeline / Feed

Sort items by timestamp:

const posts = defineEntity({
  table,
  schema: z.object({
    id: z.string(),
    authorId: z.string(),
    title: z.string(),
    createdAt: z.string().datetime(),
  }),
  keys: {
    pk: (p) => ["user", p.authorId],
    sk: (p) => ["post", p.createdAt, p.id],
    // Global feed GSI
    type: () => "post",
    gsi1pk: () => "post#feed",
    gsi1sk: (p) => [p.createdAt, p.id],
  },
});

// User's posts (newest first)
const userPosts = await posts.query(
  { pk: ["user", userId], sk: { beginsWith: "post#" } },
  { ScanIndexForward: false, Limit: 20 }
);

// Global feed (newest first)
const feed = await posts.query(
  { index: "gsi1", gsi1pk: "post#feed" },
  { ScanIndexForward: false, Limit: 20 }
);

Hierarchical Data

Model hierarchies with composite keys:

// Organization > Team > Member
const members = defineEntity({
  table,
  schema: z.object({
    orgId: z.string(),
    teamId: z.string(),
    userId: z.string(),
    role: z.string(),
  }),
  keys: {
    pk: (m) => ["org", m.orgId],
    sk: (m) => ["team", m.teamId, "member", m.userId],
  },
});

// All members in an org
await members.queryAll({
  pk: ["org", orgId],
  sk: { beginsWith: "team#" },
});

// All members in a specific team
await members.queryAll({
  pk: ["org", orgId],
  sk: { beginsWith: ["team", teamId, "member"] },
});

Key Design Tips

Use Consistent Prefixes

Prefix partition keys with entity type for clarity:

// Good: Clear prefixes
pk: (u) => ["user", u.id]
pk: (p) => ["post", p.id]
pk: (o) => ["order", o.id]

// Avoid: Ambiguous keys
pk: (u) => u.id

Include Timestamp for Sorting

When you need chronological ordering, include timestamps in sort keys:

// Good: Sortable by time
sk: (p) => [p.createdAt, p.id]

// The ID at the end ensures uniqueness if timestamps collide

Use ISO Timestamps

ISO format strings sort correctly as strings:

createdAt: z.string().datetime().default(() => new Date().toISOString())

// Results: "2024-01-15T10:30:00.000Z" sorts before "2024-02-01T08:00:00.000Z"

Plan Your GSIs

Design GSIs based on your access patterns:

Access PatternGSI Design
Get all entities of a typehashKey: type
Lookup by unique fieldhashKey: "entity#field", rangeKey: value
Timeline/feedhashKey: "entity#feed", rangeKey: timestamp
Filter by statushashKey: status, rangeKey: timestamp

Example: Complete Entity Design

const posts = defineEntity({
  table,
  schema: z.object({
    id: z.string().uuid(),
    authorId: z.string(),
    title: z.string(),
    content: z.string(),
    status: z.enum(["draft", "published", "archived"]),
    createdAt: z.string().datetime().default(() => new Date().toISOString()),
    updatedAt: z.string().datetime().optional(),
  }),
  keys: {
    // Primary: User's posts sorted by date
    pk: (p) => ["user", p.authorId],
    sk: (p) => ["post", p.createdAt, p.id],

    // GSI1: All posts by type
    type: () => "post",

    // GSI2: Global feed
    gsi1pk: () => "post#feed",
    gsi1sk: (p) => [p.createdAt, p.id],

    // GSI3: Lookup by ID
    gsi2pk: () => "post#id",
    gsi2sk: (p) => p.id,

    // GSI4: Posts by status
    gsi3pk: (p) => ["post#status", p.status],
    gsi3sk: (p) => [p.createdAt, p.id],
  },
});

// Access patterns enabled:
// 1. Get user's posts: query pk=user#<id>
// 2. Get all posts: query gsi1 type=post
// 3. Global feed: query gsi2 gsi1pk=post#feed
// 4. Lookup by ID: query gsi3 gsi2pk=post#id, gsi2sk=<id>
// 5. Posts by status: query gsi4 gsi3pk=post#status#published

Next Steps