Why Single-Table Design?
In DynamoDB, you design for access patterns, not normalized relations. Single-table design:- Reduces costs - One table, one set of capacity units
- Enables transactions - TransactWriteItems across entities in same table
- Simplifies operations - One table to monitor, backup, and manage
- Improves performance - Related data often fetched in single query
Entity Definitions
User Entity
| Attribute | Type | Description |
|---|---|---|
| userId | string (UUID) | Primary identifier, matches Cognito sub |
| string | Primary email address (also in Cognito) | |
| firstName | string | User’s first name |
| lastName | string | User’s last name |
| phone | string (optional) | Phone number in E.164 format |
| status | enum | active | suspended | deleted |
| version | number | Optimistic locking counter |
| createdAt | string (ISO8601) | Account creation timestamp |
| updatedAt | string (ISO8601) | Last modification timestamp |
Email Entity
| Attribute | Type | Description |
|---|---|---|
| emailId | string (UUID) | Unique identifier for this email record |
| userId | string (UUID) | Owner of this email |
| string | The email address (normalized lowercase) | |
| isPrimary | boolean | Whether this is the user’s primary email |
| isVerified | boolean | Whether email ownership is confirmed |
| verifiedAt | string (ISO8601) | Verification timestamp |
| createdAt | string (ISO8601) | Record creation timestamp |
Relationship
Table Schema
Table Name:UserServiceTable
Primary Key Structure
| PK | SK | Description |
|---|---|---|
USER#{userId} | PROFILE | User profile record |
USER#{userId} | EMAIL#{emailId} | Email record for user |
Global Secondary Index (GSI1)
Purpose: Look up users and emails by email address| GSI1PK | GSI1SK | Description |
|---|---|---|
EMAIL#{normalizedEmail} | USER#{userId} | Email to user mapping |
Complete Table Layout
| PK | SK | GSI1PK | GSI1SK | Attributes |
|---|---|---|---|---|
| USER#abc-123 | PROFILE | - | - | firstName, lastName, status, email (denormalized), etc. |
| USER#abc-123 | EMAIL#email-001 | EMAIL#[email protected] | USER#abc-123 | isPrimary: true, isVerified: true |
| USER#abc-123 | EMAIL#email-002 | EMAIL#[email protected] | USER#abc-123 | isPrimary: false, isVerified: true |
The PROFILE record does not have GSI1PK/GSI1SK. Only EMAIL records are indexed in GSI1. This prevents duplicate results when querying by email address and ensures clean email uniqueness checks.
Access Patterns
Get user by userId
Get user by userId
Operation: GetItem
Key:
Use case: Load user profile for authenticated user
Key:
PK = USER#{userId}, SK = PROFILEUse case: Load user profile for authenticated user
Get all emails for user
Get all emails for user
Operation: Query
Key:
Use case: List all email addresses for user settings page
Key:
PK = USER#{userId}, SK begins_with EMAIL#Use case: List all email addresses for user settings page
Get user by email address
Get user by email address
Operation: Query on GSI1
Key:
Use case: Look up user during login, check email uniqueness
Key:
GSI1PK = EMAIL#{normalizedEmail}Use case: Look up user during login, check email uniqueness
Check if email exists
Check if email exists
Operation: Query on GSI1 (limit 1)
Key:
Use case: Validate email uniqueness before adding
Key:
GSI1PK = EMAIL#{normalizedEmail}Use case: Validate email uniqueness before adding
Get user profile with emails
Get user profile with emails
Operation: Query
Key:
Use case: Load complete user data (profile + all emails) in single query
Key:
PK = USER#{userId}Use case: Load complete user data (profile + all emails) in single query
Email Normalization
Emails are normalized before storage and lookup:- Convert to lowercase
- Trim whitespace
Data Integrity Constraints
Email Uniqueness
DynamoDB has no native UNIQUE constraint. We enforce uniqueness through:- Query GSI1 before insert to check if email exists
- Conditional write with
attribute_not_exists(PK)on the email record - Race condition handling via optimistic concurrency
Primary Email Invariant
Exactly one email per user must be primary. Enforced via TransactWriteItems:Optimistic Locking
Concurrent updates are handled via version number:ConditionalCheckFailedException.
Capacity Planning
| Mode | When to Use |
|---|---|
| On-Demand | Unpredictable traffic, new service, spiky workloads |
| Provisioned | Steady-state traffic, cost optimization, predictable growth |
GSI Capacity
GSI has separate capacity from the base table. Monitor GSI throttling independently:ConsumedReadCapacityUnitsfor GSI1ConsumedWriteCapacityUnitsfor GSI1ThrottledRequestsfor GSI1