10 minute read Software Engineering

One of the biggest challenges when transitioning from relational databases to DynamoDB is figuring out how to model relationships between entities. General design principles in Amazon DynamoDB recommend that you keep the number of tables you use to a minimum. In the majority of cases, it is recommended that you use a single table. The Adjacency List pattern is a powerful technique that allows you to store multiple entity types and their relationships in a single DynamoDB table while supporting efficient queries for various access patterns.

What is the Adjacency List Pattern?

The Adjacency List pattern is inspired by graph theory, where each node in a graph maintains a list of its adjacent nodes. In DynamoDB terms, this means storing both entities and their relationships as items in the same table, using a consistent key structure that enables efficient traversal of related data. The key insight is that relationships themselves become first-class entities in your data model, stored alongside your primary entities in the same table.

Core Design Principles

The Adjacency List pattern follows these principles:

  1. Single Table Design: All entities and relationships live in one table
  2. Hierarchical Keys: Use compound keys that represent the relationship hierarchy
  3. Bidirectional Relationships: Store relationships in both directions for flexible querying
  4. Type Indicators: Use prefixes or attributes to distinguish entity types

Example: E-commerce Platform

Let’s build a comprehensive example using an e-commerce platform with these entities:

  • Users (customers and sellers)
  • Products
  • Orders
  • Categories
  • Reviews

To start designing a DynamoDB table that will scale efficiently, you must take several steps first to identify the access patterns that are required by the systems that it needs to support:

  • For new applications, review user stories about activities and objectives. Document the various use cases you identify, and analyse the access patterns that they require.
  • For existing applications, analyse query logs to find out how people are currently using the system and what the key access patterns are.

After completing this process, you should end up with a list that might look something like the following.

  1. Get user details
  2. Get user’s orders (with order details)
  3. Get product with reviews
  4. Get products in category
  5. Get order with all items

Table Schema

Table: EcommerceData
Partition Key: PK (String)
Sort Key: SK (String)
Attributes: Type, Individual entity attributes, GSI1PK, GSI1SK, CreatedAt, UpdatedAt

Global Secondary Index: GSI1
Partition Key: GSI1PK
Sort Key: GSI1SK

The partition key is the entity attribute that uniquely identifies the item and is referred to generically on all items as PK.

The sort key attribute contains an attribute value that you can use for an inverted index or global secondary index. It is generically referred to as SK.

Instead of using a nested Data attribute, we use individual attributes for each entity property. This approach provides better query performance, enables attribute-level filtering and projection, and allows individual attributes to be used as GSI keys. The GSI1PK and GSI1SK attributes enable reverse lookups and time-based queries across multiple access patterns using a single global secondary index.

The GSI1 GSI enables reverse lookups and time-based queries.

Entity and Relationship Design

1. User Entities

// Customer
{
  "PK": "USER#12345",
  "SK": "USER#12345", 
  "Type": "User",
  "userId": "12345",
  "name": "John Doe",
  "email": "john@example.com",
  "userType": "customer",
  "GSI1PK": "USER",
  "GSI1SK": "2024-01-15T10:30:00Z"
}

// Seller
{
  "PK": "USER#67890",
  "SK": "USER#67890",
  "Type": "User",
  "userId": "67890",
  "name": "TechStore Inc",
  "email": "contact@techstore.com",
  "userType": "seller",
  "GSI1PK": "USER",
  "GSI1SK": "2024-01-10T08:15:00Z"
}

2. Product Entities

{
  "PK": "PRODUCT#ABC123",
  "SK": "PRODUCT#ABC123",
  "Type": "Product",
  "productId": "ABC123",
  "name": "Wireless Headphones",
  "price": 199.99,
  "description": "Premium wireless headphones",
  "sellerId": "67890",
  "GSI1PK": "PRODUCT",
  "GSI1SK": "2024-01-16T14:20:00Z"
}

3. Category Entities and Relationships

// Category
{
  "PK": "CATEGORY#electronics",
  "SK": "CATEGORY#electronics",
  "Type": "Category",
  "categoryId": "electronics", 
  "name": "Electronics",
  "description": "Electronic devices and accessories"
}

// Product-Category Relationship
{
  "PK": "PRODUCT#ABC123",
  "SK": "CATEGORY#electronics",
  "Type": "ProductCategory",
  "productId": "ABC123",
  "categoryId": "electronics",
  "GSI1PK": "CATEGORY#electronics",
  "GSI1SK": "PRODUCT#ABC123"
}

4. Order Entities and Relationships

// Order
{
  "PK": "ORDER#ORD001",
  "SK": "ORDER#ORD001",
  "Type": "Order",
  "orderId": "ORD001",
  "customerId": "12345",
  "total": 199.99,
  "status": "shipped",
  "orderDate": "2024-01-20T12:00:00Z",
  "GSI1PK": "USER#12345",
  "GSI1SK": "ORDER#2024-01-20T12:00:00Z"
}

// Order-Product Relationship (Order Item)
{
  "PK": "ORDER#ORD001", 
  "SK": "PRODUCT#ABC123",
  "Type": "OrderItem",
  "orderId": "ORD001",
  "productId": "ABC123", 
  "quantity": 1,
  "unitPrice": 199.99,
  "totalPrice": 199.99,
  "GSI1PK": "PRODUCT#ABC123",
  "GSI1SK": "ORDER#ORD001"
}

5. Review Entities and Relationships

// Review
{
  "PK": "REVIEW#REV001",
  "SK": "REVIEW#REV001",
  "Type": "Review",
  "reviewId": "REV001",
  "customerId": "12345",
  "productId": "ABC123",
  "rating": 5,
  "comment": "Excellent sound quality!",
  "reviewDate": "2024-01-25T09:30:00Z",
  "CreatedAt": "2024-01-25T09:30:00Z"
}

// User-Review Relationship
{
  "PK": "USER#12345",
  "SK": "REVIEW#REV001", 
  "Type": "UserReview",
  "customerId": "12345",
  "reviewId": "REV001",
  "GSI1PK": "REVIEW#REV001",
  "GSI1SK": "USER#12345",
  "CreatedAt": "2024-01-25T09:30:00Z"
}

// Product-Review Relationship  
{
  "PK": "PRODUCT#ABC123",
  "SK": "REVIEW#REV001",
  "Type": "ProductReview", 
  "productId": "ABC123",
  "reviewId": "REV001",
  "rating": 5,
  "GSI1PK": "REVIEW#REV001", 
  "GSI1SK": "PRODUCT#ABC123",
  "CreatedAt": "2024-01-25T09:30:00Z"
}

Access Patterns and Queries

1. Get User Details

const params = {
  TableName: 'EcommerceData',
  Key: {
    PK: 'USER#12345',
    SK: 'USER#12345'
  }
};

2. Get User’s Orders (with order details)

const params = {
  TableName: 'EcommerceData', 
  IndexName: 'GSI1',
  KeyConditionExpression: 'GSI1PK = :pk AND begins_with(GSI1SK, :sk)',
  ExpressionAttributeValues: {
    ':pk': 'USER#12345',
    ':sk': 'ORDER#'
  }
};

3. Get Product with Reviews

// First get the product
const productParams = {
  TableName: 'EcommerceData',
  KeyConditionExpression: 'PK = :pk',
  ExpressionAttributeValues: {
    ':pk': 'PRODUCT#ABC123'
  }
};

This returns the product and all its relationships (reviews, categories, etc.)

4. Get Recent Reviews for a Product

const params = {
  TableName: 'EcommerceData',
  KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
  ExpressionAttributeValues: {
    ':pk': 'PRODUCT#ABC123',
    ':sk': 'REVIEW#'
  },
  ScanIndexForward: false, // Get most recent first
  Limit: 10
};

5. Get Products in Category

const params = {
  TableName: 'EcommerceData',
  IndexName: 'GSI1', 
  KeyConditionExpression: 'GSI1PK = :pk AND begins_with(GSI1SK, :sk)',
  ExpressionAttributeValues: {
    ':pk': 'CATEGORY#electronics',
    ':sk': 'PRODUCT#'
  }
};

6. Get Order with All Items

const params = {
  TableName: 'EcommerceData',
  KeyConditionExpression: 'PK = :pk',
  ExpressionAttributeValues: {
    ':pk': 'ORDER#ORD001'
  }
};

Returns order details and all order items in a single query

7. Get Customer’s Reviews

const params = {
  TableName: TABLE_NAME,
  KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
  ExpressionAttributeValues: {
    ':pk': `USER#${customerId}`,
    ':sk': 'REVIEW#'
  }
};

8. Get Customer’s Orders (Alternative Pattern)

const params = {
  TableName: TABLE_NAME,
  KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
  ExpressionAttributeValues: {
    ':pk': `USER#${userId}`,
    ':sk': 'ORDER#'
  }
};

Complete Relationship Structure

The implementation includes these relationship types:

  1. OrderItem: Links orders to products (PK: ORDER#xxx, SK: PRODUCT#xxx)
  2. ProductCategory: Links products to categories (PK: PRODUCT#xxx, SK: CATEGORY#xxx)
  3. UserReview: Links users to their reviews (PK: USER#xxx, SK: REVIEW#xxx)
  4. ProductReview: Links products to their reviews (PK: PRODUCT#xxx, SK: REVIEW#xxx)
  5. CustomerOrder: Links customers to their orders (PK: USER#xxx, SK: ORDER#xxx)

Each relationship is stored bidirectionally with appropriate GSI keys for reverse lookups, enabling efficient queries in both directions.

Advanced Patterns

Many-to-Many Relationships

For complex many-to-many relationships, you can create junction entities:

// Product-Tag Relationship
{
  "PK": "PRODUCT#ABC123",
  "SK": "TAG#wireless",
  "Type": "ProductTag",
  "productId": "ABC123", 
  "tagName": "wireless",
  "GSI1PK": "TAG#wireless",
  "GSI1SK": "PRODUCT#ABC123",
  "CreatedAt": "2024-01-16T14:20:00Z"
}

Hierarchical Categories

For nested categories, you can model parent-child relationships:

// Subcategory relationship
{
  "PK": "CATEGORY#electronics",
  "SK": "CHILD#headphones", 
  "Type": "CategoryChild",
  "parentCategoryId": "electronics",
  "childCategoryId": "headphones",
  "childName": "Headphones",
  "GSI1PK": "CATEGORY#headphones",
  "GSI1SK": "PARENT#electronics",
  "CreatedAt": "2024-01-01T00:00:00Z"
}

Time-Series Data

Use the sort key to store time-based data:

{
  "PK": "PRODUCT#ABC123", 
  "SK": "PRICE#2024-01-20T10:00:00Z",
  "Type": "PriceHistory",
  "productId": "ABC123",
  "price": 199.99,
  "timestamp": "2024-01-20T10:00:00Z"
}

Best Practices

  1. Design for Your Access Patterns Always start by identifying your access patterns before designing the table structure. The Adjacency List pattern shines when you need to traverse relationships efficiently.

  2. Use Meaningful Prefixes Consistent prefixes make your data self-documenting and enable powerful query patterns:

  • USER# for users
  • PRODUCT# for products
  • ORDER# for orders
  • REVIEW# for reviews
  1. Leverage Sort Key Flexibility The sort key can represent different types of relationships or attributes, making single queries very powerful.

  2. Consider Hot Partitions Distribute your data to avoid hot partitions. If you have very popular products or users, consider adding random suffixes or using time-based partitioning.

  3. Plan for Growth Design your key structure to accommodate future entity types and relationships without requiring major refactoring.

Performance Considerations

The Adjacency List pattern offers several performance benefits:

  • Single Query Efficiency: Get an entity and all its relationships in one query
  • Reduced Network Round Trips: Fewer API calls compared to normalised approaches
  • Flexible Querying: Support multiple access patterns with the same data structure
  • Cost Effective: Fewer read operations mean lower costs

However, be aware of these considerations:

However, be aware of item size limits (400KB) and ensure your entities don’t grow too large when storing many relationships.

Item Size Limits

DynamoDB has a 400KB item size limit. When using adjacency lists, monitor your item sizes and ensure your entities don’t grow too large when storing entities with many relationships.

Hot Partition Management

Managing hot partitions is crucial for maintaining DynamoDB performance and scalability, especially as your application grows and certain entities become more popular. Hot partitions occur when a disproportionate amount of read or write traffic is directed to a small subset of partition keys, leading to throttling and increased latency. By proactively monitoring, designing for distribution, and implementing techniques such as write sharding, you can mitigate the risks associated with hot partitions and ensure your DynamoDB tables remain responsive under heavy load.

Monitor CloudWatch metrics for throttling and hot partition indicators

Regularly monitor Amazon CloudWatch metrics such as ConsumedReadCapacityUnits, ConsumedWriteCapacityUnits, ThrottledRequests, and ReturnedItemCount for your DynamoDB table. Pay special attention to metrics at the partition level, as spikes in throttling or uneven capacity consumption can indicate hot partitions. Set up CloudWatch alarms to alert you when thresholds are exceeded, enabling proactive scaling or refactoring of your key design. Use the DynamoDB Console’s “Explore Table” and “Capacity” dashboards to visualise partition activity and identify problematic access patterns.

Implement write sharding for high-traffic entities

Write sharding is a technique used to distribute write operations across multiple partitions or items to avoid hot spots in databases like DynamoDB. When a particular entity (such as a popular user or product) receives a large number of writes, storing all updates in a single item can lead to throttling and degraded performance. By sharding writes, you spread the load across several items, reducing contention and improving scalability.

To implement write sharding, you can append a random suffix or a time-based value to the partition key for popular items. This creates multiple versions of the same entity, each with a unique key. When writing data, you select a shard (randomly or based on the current time), and when reading, you aggregate data from all shards.

Here’s a simple example of how to implement write sharding for a high-traffic entity in DynamoDB using TypeScript and the AWS SDK v3:

import { PutCommand } from '@aws-sdk/lib-dynamodb';
import { docClient } from './config';

function getShardKey(baseKey: string, numShards: number): string {
    // Choose a random shard suffix
    const shardId = Math.floor(Math.random() * numShards);
    return `${baseKey}#SHARD${shardId}`;
}

async function writeShardedEntity(entityId: string, activityData: any, numShards: number) {
    const pk = getShardKey(`USER#${entityId}`, numShards);
    const sk = `ACTIVITY#${Date.now()}`;

    const item = {
        PK: pk,
        SK: sk,
        Type: 'UserActivity',
        userId: entityId,
        action: activityData.action,
        productId: activityData.productId,
        timestamp: Date.now(),
        CreatedAt: new Date().toISOString()
    };

    await docClient.send(new PutCommand({
        TableName: 'EcommerceData',
        Item: item
    }));
}

// Usage
await writeShardedEntity("12345", { action: "purchase", productId: "ABC123" }, 5);

This approach distributes writes for the same user across 5 shards, reducing the risk of hot partitions.

GSI Projection Strategy

Use KEYS_ONLY projections when you only need to identify related items. Use INCLUDE projections for frequently accessed attributes. Reserve ALL projections for read-heavy access patterns where you need complete item data.

Aggregation Consistency

  • Use eventually consistent aggregations for non-critical metrics
  • Implement strong consistency patterns for financial or inventory data
  • Consider the trade-offs between real-time updates and system performance

Conclusion

The Adjacency List pattern is a powerful technique for modeling relational data in DynamoDB. It enables you to maintain the flexibility of relational modeling while leveraging DynamoDB’s performance characteristics. By storing entities and relationships together using hierarchical keys, you can support complex queries with excellent performance.

The key to success with this pattern is careful planning of your access patterns and consistent use of meaningful key structures. When implemented correctly, it can handle complex relational scenarios while maintaining the scalability and performance benefits that make DynamoDB attractive for modern applications.

Remember that NoSQL data modeling is fundamentally different from relational modeling – embrace the denormalisation and design for your specific access patterns rather than trying to maintain traditional normal forms.

Complete Working Example

A complete TypeScript implementation of this adjacency list pattern is available, including:

  • Full sample data: Users, products, orders, reviews, and categories with complete relationships
  • Deployment scripts: Easy AWS setup and data loading
  • Type-safe queries: Strongly typed TypeScript interfaces for all entities and operations

The implementation demonstrates real-world patterns including write sharding, hierarchical data, and complex many-to-many relationships.

DynamoDB-Adjacency-List-Pattern-Example

Leave a comment