Skip to main content

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
Single-table design requires upfront access pattern analysis. Adding new access patterns later may require table restructuring or new GSIs.

Entity Definitions

User Entity

AttributeTypeDescription
userIdstring (UUID)Primary identifier, matches Cognito sub
emailstringPrimary email address (also in Cognito)
firstNamestringUser’s first name
lastNamestringUser’s last name
phonestring (optional)Phone number in E.164 format
statusenumactive | suspended | deleted
versionnumberOptimistic locking counter
createdAtstring (ISO8601)Account creation timestamp
updatedAtstring (ISO8601)Last modification timestamp

Email Entity

AttributeTypeDescription
emailIdstring (UUID)Unique identifier for this email record
userIdstring (UUID)Owner of this email
emailstringThe email address (normalized lowercase)
isPrimarybooleanWhether this is the user’s primary email
isVerifiedbooleanWhether email ownership is confirmed
verifiedAtstring (ISO8601)Verification timestamp
createdAtstring (ISO8601)Record creation timestamp

Relationship

User (1) ──────< Email (many)
A user can have multiple email addresses. Exactly one must be marked as primary. The primary email must be verified.

Table Schema

Table Name: UserServiceTable

Primary Key Structure

PKSKDescription
USER#{userId}PROFILEUser profile record
USER#{userId}EMAIL#{emailId}Email record for user

Global Secondary Index (GSI1)

Purpose: Look up users and emails by email address
GSI1PKGSI1SKDescription
EMAIL#{normalizedEmail}USER#{userId}Email to user mapping

Complete Table Layout

PKSKGSI1PKGSI1SKAttributes
USER#abc-123PROFILE--firstName, lastName, status, email (denormalized), etc.
USER#abc-123EMAIL#email-001EMAIL#[email protected]USER#abc-123isPrimary: true, isVerified: true
USER#abc-123EMAIL#email-002EMAIL#[email protected]USER#abc-123isPrimary: 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

Operation: GetItem
Key: PK = USER#{userId}, SK = PROFILE
Use case: Load user profile for authenticated user
Operation: Query
Key: PK = USER#{userId}, SK begins_with EMAIL#
Use case: List all email addresses for user settings page
Operation: Query on GSI1
Key: GSI1PK = EMAIL#{normalizedEmail}
Use case: Look up user during login, check email uniqueness
Operation: Query on GSI1 (limit 1)
Key: GSI1PK = EMAIL#{normalizedEmail}
Use case: Validate email uniqueness before adding
Operation: 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:
  1. Convert to lowercase
  2. Trim whitespace
Gmail-specific normalization (removing dots and plus-addressing) is intentionally omitted. This is a business decision that should be discussed with stakeholders. Some users rely on plus-addressing for inbox filtering, and removing it could break their workflows. If abuse becomes an issue, implement it as a configurable option per organization.

Data Integrity Constraints

Email Uniqueness

DynamoDB has no native UNIQUE constraint. We enforce uniqueness through:
  1. Query GSI1 before insert to check if email exists
  2. Conditional write with attribute_not_exists(PK) on the email record
  3. Race condition handling via optimistic concurrency
// Pseudo-code for adding email
const existing = await queryGSI1(normalizedEmail);
if (existing.length > 0) {
  throw new ConflictError('Email already in use');
}

await putItem({
  ...emailRecord,
  ConditionExpression: 'attribute_not_exists(PK)'
});

Primary Email Invariant

Exactly one email per user must be primary. Enforced via TransactWriteItems:
// Changing primary email atomically
TransactWriteItems([
  {
    Update: {
      Key: { PK: 'USER#123', SK: 'EMAIL#old-email-id' },
      UpdateExpression: 'SET isPrimary = :false',
    }
  },
  {
    Update: {
      Key: { PK: 'USER#123', SK: 'EMAIL#new-email-id' },
      UpdateExpression: 'SET isPrimary = :true',
      ConditionExpression: 'isVerified = :true'  // Must be verified
    }
  },
  {
    Update: {
      Key: { PK: 'USER#123', SK: 'PROFILE' },
      UpdateExpression: 'SET email = :newEmail, updatedAt = :now'
    }
  }
])

Optimistic Locking

Concurrent updates are handled via version number:
await updateItem({
  Key: { PK: 'USER#123', SK: 'PROFILE' },
  UpdateExpression: 'SET firstName = :name, version = version + 1',
  ConditionExpression: 'version = :expectedVersion'
});
If another request updated the record, the condition fails with ConditionalCheckFailedException.

Capacity Planning

ModeWhen to Use
On-DemandUnpredictable traffic, new service, spiky workloads
ProvisionedSteady-state traffic, cost optimization, predictable growth
Start with on-demand capacity. After 2-4 weeks of production data, analyze CloudWatch metrics to determine if provisioned capacity with auto-scaling would be more cost-effective.

GSI Capacity

GSI has separate capacity from the base table. Monitor GSI throttling independently:
  • ConsumedReadCapacityUnits for GSI1
  • ConsumedWriteCapacityUnits for GSI1
  • ThrottledRequests for GSI1