Single Table Design¶
Single-table design is a DynamoDB best practice that stores multiple entity types in a single table using composite keys. DynamoMapper's customization hooks make implementing single-table patterns straightforward and type-safe.
Why Single Table Design?¶
Single-table design offers several advantages:
- Cost efficiency - Fewer tables to provision
- Atomic transactions - Multiple entities in one transaction
- Query flexibility - GSIs enable diverse access patterns
- Scalability - Better partition distribution
DynamoMapper's hooks enable these patterns without polluting domain models with persistence concerns.
Basic Single-Table Pattern¶
Domain Models¶
Clean domain models without DynamoDB concerns:
public class Customer
{
public string CustomerId { get; set; }
public string Email { get; set; }
public string Name { get; set; }
public DateTime CreatedAt { get; set; }
}
public class Order
{
public Guid OrderId { get; set; }
public string CustomerId { get; set; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
}
Customer Mapper¶
using Amazon.DynamoDBv2.Model;
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
public static partial class CustomerMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Customer source);
public static partial Customer FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(Customer source, Dictionary<string, AttributeValue> item)
{
// PK/SK for customer entity
item["pk"] = new AttributeValue { S = $"CUSTOMER#{source.CustomerId}" };
item["sk"] = new AttributeValue { S = "METADATA" };
// Entity type for polymorphic queries
item["entityType"] = new AttributeValue { S = "Customer" };
item["recordType"] = new AttributeValue { S = "Customer" };
// GSI1: Query customers by email
item["gsi1pk"] = new AttributeValue { S = $"EMAIL#{source.Email}" };
item["gsi1sk"] = new AttributeValue { S = $"CUSTOMER#{source.CustomerId}" };
}
static partial void BeforeFromItem(Dictionary<string, AttributeValue> item)
{
// Validate entity type before mapping
if (item.TryGetValue("entityType", out var typeAttr) && typeAttr.S != "Customer")
{
throw new DynamoMappingException(
targetType: "Customer",
details: $"Cannot deserialize {typeAttr.S} as Customer");
}
}
}
Order Mapper¶
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
[DynamoField(nameof(Order.Status), ToMethod = nameof(ToOrderStatus), FromMethod = nameof(FromOrderStatus))]
public static partial class OrderMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Order source);
public static partial Order FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(Order source, Dictionary<string, AttributeValue> item)
{
// PK/SK for order under customer
item["pk"] = new AttributeValue { S = $"CUSTOMER#{source.CustomerId}" };
item["sk"] = new AttributeValue { S = $"ORDER#{source.OrderId}#{source.CreatedAt:yyyy-MM-dd}" };
// Entity type discrimination
item["entityType"] = new AttributeValue { S = "Order" };
item["recordType"] = new AttributeValue { S = "Order" };
// GSI1: Query orders by status
item["gsi1pk"] = new AttributeValue { S = $"STATUS#{source.Status.Name}" };
item["gsi1sk"] = new AttributeValue { S = source.CreatedAt.ToString("O") };
// GSI2: Query all orders by date
item["gsi2pk"] = new AttributeValue { S = "ORDER" };
item["gsi2sk"] = new AttributeValue { S = source.CreatedAt.ToString("O") };
}
static partial void BeforeFromItem(Dictionary<string, AttributeValue> item)
{
if (item.TryGetValue("entityType", out var typeAttr) && typeAttr.S != "Order")
{
throw new DynamoMappingException(
targetType: "Order",
details: $"Cannot deserialize {typeAttr.S} as Order");
}
}
static AttributeValue ToOrderStatus(OrderStatus status) =>
new() { S = status.Name };
static OrderStatus FromOrderStatus(AttributeValue value) =>
OrderStatus.FromName(value.S);
}
Table Structure¶
Base Table¶
| PK | SK | entityType | Attributes |
|---|---|---|---|
| CUSTOMER#C1 | METADATA | Customer | email, name, createdAt |
| CUSTOMER#C1 | ORDER#O1#2024-01-15 | Order | orderId, status, total, createdAt |
| CUSTOMER#C1 | ORDER#O2#2024-02-20 | Order | orderId, status, total, createdAt |
GSI1: Email Lookup / Status Queries¶
| gsi1pk | gsi1sk | entityType |
|---|---|---|
| EMAIL#user@example.com | CUSTOMER#C1 | Customer |
| STATUS#Pending | 2024-01-15T10:30:00Z | Order |
| STATUS#Shipped | 2024-02-20T14:45:00Z | Order |
GSI2: All Orders by Date¶
| gsi2pk | gsi2sk | entityType |
|---|---|---|
| ORDER | 2024-01-15T10:30:00Z | Order |
| ORDER | 2024-02-20T14:45:00Z | Order |
Access Patterns¶
1. Get Customer by ID¶
var request = new GetItemRequest
{
TableName = "AppTable",
Key = new Dictionary<string, AttributeValue>
{
["pk"] = new AttributeValue { S = $"CUSTOMER#{customerId}" },
["sk"] = new AttributeValue { S = "METADATA" }
}
};
var response = await dynamoDb.GetItemAsync(request);
var customer = CustomerMapper.FromItem(response.Item);
2. Get All Orders for Customer¶
var request = new QueryRequest
{
TableName = "AppTable",
KeyConditionExpression = "pk = :pk AND begins_with(sk, :sk)",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":pk"] = new AttributeValue { S = $"CUSTOMER#{customerId}" },
[":sk"] = new AttributeValue { S = "ORDER#" }
}
};
var response = await dynamoDb.QueryAsync(request);
var orders = response.Items.Select(OrderMapper.FromItem).ToList();
3. Get Customer by Email (GSI1)¶
var request = new QueryRequest
{
TableName = "AppTable",
IndexName = "GSI1",
KeyConditionExpression = "gsi1pk = :email",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":email"] = new AttributeValue { S = $"EMAIL#{email}" }
}
};
var response = await dynamoDb.QueryAsync(request);
var customer = CustomerMapper.FromItem(response.Items.First());
4. Get Orders by Status (GSI1)¶
var request = new QueryRequest
{
TableName = "AppTable",
IndexName = "GSI1",
KeyConditionExpression = "gsi1pk = :status",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":status"] = new AttributeValue { S = $"STATUS#{status}" }
}
};
var response = await dynamoDb.QueryAsync(request);
var orders = response.Items.Select(OrderMapper.FromItem).ToList();
Advanced Patterns¶
Composite Entity with TTL¶
public class Session
{
public Guid SessionId { get; set; }
public string UserId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
public static partial class SessionMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Session source);
public static partial Session FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(Session source, Dictionary<string, AttributeValue> item)
{
// Session keys
item["pk"] = new AttributeValue { S = $"USER#{source.UserId}" };
item["sk"] = new AttributeValue { S = $"SESSION#{source.SessionId}" };
item["entityType"] = new AttributeValue { S = "Session" };
// TTL for automatic deletion
var ttl = new DateTimeOffset(source.ExpiresAt).ToUnixTimeSeconds();
item["ttl"] = new AttributeValue { N = ttl.ToString() };
// GSI1: Query active sessions
if (source.ExpiresAt > DateTime.UtcNow)
{
item["gsi1pk"] = new AttributeValue { S = "ACTIVE_SESSION" };
item["gsi1sk"] = new AttributeValue { S = source.ExpiresAt.ToString("O") };
}
}
}
Hierarchical Data¶
public class Product
{
public Guid ProductId { get; set; }
public string CategoryId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public class ProductReview
{
public Guid ReviewId { get; set; }
public Guid ProductId { get; set; }
public string UserId { get; set; }
public int Rating { get; set; }
public string Comment { get; set; }
public DateTime CreatedAt { get; set; }
}
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
public static partial class ProductMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Product source);
public static partial Product FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(Product source, Dictionary<string, AttributeValue> item)
{
// Product hierarchy under category
item["pk"] = new AttributeValue { S = $"CATEGORY#{source.CategoryId}" };
item["sk"] = new AttributeValue { S = $"PRODUCT#{source.ProductId}" };
item["entityType"] = new AttributeValue { S = "Product" };
// GSI1: All products by price
item["gsi1pk"] = new AttributeValue { S = "PRODUCT" };
item["gsi1sk"] = new AttributeValue { S = source.Price.ToString("0000000.00") };
}
}
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
public static partial class ProductReviewMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(ProductReview source);
public static partial ProductReview FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(ProductReview source, Dictionary<string, AttributeValue> item)
{
// Reviews under product
item["pk"] = new AttributeValue { S = $"PRODUCT#{source.ProductId}" };
item["sk"] = new AttributeValue { S = $"REVIEW#{source.ReviewId}#{source.CreatedAt:yyyy-MM-dd}" };
item["entityType"] = new AttributeValue { S = "ProductReview" };
// GSI1: User's reviews
item["gsi1pk"] = new AttributeValue { S = $"USER#{source.UserId}" };
item["gsi1sk"] = new AttributeValue { S = source.CreatedAt.ToString("O") };
// GSI2: Reviews by rating
item["gsi2pk"] = new AttributeValue { S = "REVIEW" };
item["gsi2sk"] = new AttributeValue { S = $"{source.Rating}#{source.CreatedAt:O}" };
}
}
Attribute Bag Pattern¶
For forward compatibility, capture unmapped attributes:
public class Product
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
// Capture additional attributes for forward compatibility
public Dictionary<string, AttributeValue>? Metadata { get; set; }
}
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
[DynamoIgnore(nameof(Product.Metadata))]
public static partial class ProductMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Product source);
public static partial Product FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(Product source, Dictionary<string, AttributeValue> item)
{
// Single-table keys
item["pk"] = new AttributeValue { S = $"PRODUCT#{source.ProductId}" };
item["sk"] = new AttributeValue { S = "METADATA" };
item["entityType"] = new AttributeValue { S = "Product" };
// Merge metadata bag
if (source.Metadata != null)
{
foreach (var kvp in source.Metadata)
{
item[kvp.Key] = kvp.Value;
}
}
}
static partial void AfterFromItem(Dictionary<string, AttributeValue> item, ref Product entity)
{
// Capture unmapped attributes
var coreAttributes = new HashSet<string>
{
"pk", "sk", "entityType", "recordType",
"productId", "name", "price"
};
entity.Metadata = item
.Where(kvp => !coreAttributes.Contains(kvp.Key))
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
}
Conditional Keys¶
Keys can vary based on entity state:
public class Product
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public bool IsArchived { get; set; }
public DateTime? ArchivedAt { get; set; }
}
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
public static partial class ProductMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Product source);
public static partial Product FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(Product source, Dictionary<string, AttributeValue> item)
{
// Base keys
item["pk"] = new AttributeValue { S = $"PRODUCT#{source.ProductId}" };
item["sk"] = new AttributeValue { S = "METADATA" };
item["entityType"] = new AttributeValue { S = "Product" };
// Conditional GSI for active vs archived
if (source.IsArchived)
{
item["gsi1pk"] = new AttributeValue { S = "ARCHIVED_PRODUCT" };
item["gsi1sk"] = new AttributeValue { S = source.ArchivedAt?.ToString("O") ?? DateTime.UtcNow.ToString("O") };
}
else
{
item["gsi1pk"] = new AttributeValue { S = "ACTIVE_PRODUCT" };
item["gsi1sk"] = new AttributeValue { S = source.Name };
}
}
}
Polymorphic Queries¶
Handle multiple entity types in a single query:
public class QueryResult
{
public string EntityType { get; set; }
public Dictionary<string, AttributeValue> RawItem { get; set; }
}
// Query multiple entity types
var request = new QueryRequest
{
TableName = "AppTable",
KeyConditionExpression = "pk = :pk",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":pk"] = new AttributeValue { S = $"CUSTOMER#{customerId}" }
}
};
var response = await dynamoDb.QueryAsync(request);
var results = response.Items.Select(item =>
{
var entityType = item["entityType"].S;
return entityType switch
{
"Customer" => (object)CustomerMapper.FromItem(item),
"Order" => (object)OrderMapper.FromItem(item),
_ => throw new InvalidOperationException($"Unknown entity type: {entityType}")
};
}).ToList();
Sparse Indexes¶
Use sparse GSIs for conditional attributes:
public class Order
{
public Guid OrderId { get; set; }
public string CustomerId { get; set; }
public OrderStatus Status { get; set; }
public DateTime? ShippedAt { get; set; }
public string? TrackingNumber { get; set; }
}
[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
[DynamoField(nameof(Order.Status), ToMethod = nameof(ToOrderStatus), FromMethod = nameof(FromOrderStatus))]
public static partial class OrderMapper
{
public static partial Dictionary<string, AttributeValue> ToItem(Order source);
public static partial Order FromItem(Dictionary<string, AttributeValue> item);
static partial void AfterToItem(Order source, Dictionary<string, AttributeValue> item)
{
item["pk"] = new AttributeValue { S = $"CUSTOMER#{source.CustomerId}" };
item["sk"] = new AttributeValue { S = $"ORDER#{source.OrderId}" };
item["entityType"] = new AttributeValue { S = "Order" };
// Sparse GSI: Only shipped orders
if (source.ShippedAt.HasValue && !string.IsNullOrEmpty(source.TrackingNumber))
{
item["gsi1pk"] = new AttributeValue { S = "SHIPPED" };
item["gsi1sk"] = new AttributeValue { S = source.ShippedAt.Value.ToString("O") };
}
}
static AttributeValue ToOrderStatus(OrderStatus status) =>
new() { S = status.Name };
static OrderStatus FromOrderStatus(AttributeValue value) =>
OrderStatus.FromName(value.S);
}
Best Practices¶
- Use EntityType for polymorphic queries
- Always include entity type discriminator
-
Validate on read to prevent mapping errors
-
Design access patterns first
- Plan PK/SK and GSI keys around queries
-
Use hooks to generate keys from domain properties
-
Keep domain models clean
- No PK/SK properties in domain models
-
Use hooks to add DynamoDB-specific attributes
-
Use sparse indexes for conditional data
- Only populate GSI keys when needed
-
Reduces storage and improves query performance
-
Plan for forward compatibility
- Use attribute bags to capture unmapped fields
-
Allows schema evolution without breaking changes
-
Test your access patterns
- Verify queries return expected entities
-
Test entity type validation in hooks
-
Document your key structure
- Clearly document PK/SK patterns
- Include GSI usage in mapper comments
See Also¶
- Customization Hooks - Complete hook documentation
- Phase 1 Requirements - Hook specifications
- DynamoDB Single-Table Design - AWS Best Practices