Single-Table Design
Single-table design is a powerful DynamoDB pattern where multiple entity types share the same table, using composite keys to enable efficient access patterns. Oproto.FluentDynamoDb provides first-class support for this pattern with source-generated entity accessors and type-safe operations.
What is Single-Table Design?
In traditional relational databases, you typically create one table per entity type. In DynamoDB, single-table design stores multiple entity types in the same table, differentiated by their partition and sort key patterns.
PK: CUSTOMER#123 SK: PROFILE → Customer profile
PK: CUSTOMER#123 SK: ORDER#001 → Order 1
PK: CUSTOMER#123 SK: ORDER#002 → Order 2
PK: CUSTOMER#123 SK: ORDER#001#LINE#1 → Order 1, Line 1
PK: CUSTOMER#123 SK: ORDER#001#LINE#2 → Order 1, Line 2
Benefits
- Efficient queries: Retrieve related entities in a single query
- Reduced costs: Fewer tables means lower provisioned capacity costs
- Atomic transactions: Coordinate operations across entity types in a single transaction
- Simplified infrastructure: Manage fewer tables and indexes
- Better performance: Related data is co-located for faster access
Trade-offs
- More complex key design: Requires careful planning of partition and sort key patterns
- Less intuitive: Not as straightforward as one-table-per-entity
- Requires understanding: Team needs to understand DynamoDB access patterns
Defining Multiple Entities
To create a multi-entity table, define multiple entities with the same [DynamoDbTable] name. Mark exactly one entity as the default with IsDefault = true.
using Oproto.FluentDynamoDb.Attributes;
// Default entity - used for table-level operations
[DynamoDbTable("ecommerce", IsDefault = true)]
public partial class Order
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}")]
public string OrderId { get; set; } = string.Empty;
[DynamoDbAttribute("total")]
public decimal Total { get; set; }
[DynamoDbAttribute("status")]
public string Status { get; set; } = "pending";
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// Secondary entity - shares the same table
[DynamoDbTable("ecommerce")]
public partial class OrderLine
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}#LINE#{LineNumber:D3}")]
public string OrderId { get; set; } = string.Empty;
public int LineNumber { get; set; }
[DynamoDbAttribute("productId")]
public string ProductId { get; set; } = string.Empty;
[DynamoDbAttribute("quantity")]
public int Quantity { get; set; }
[DynamoDbAttribute("price")]
public decimal Price { get; set; }
}
The Default Entity
The IsDefault = true attribute designates which entity is used for table-level operations:
// Table-level operations use the default entity (Order)
var order = await ecommerceTable.Get()
.WithKey(OrderFields.CustomerId, "customer123")
.WithKey(OrderFields.OrderId, "ORDER#order456")
.GetItemAsync();
// This is equivalent to using the entity accessor
var order2 = await ecommerceTable.Orders.Get()
.WithKey(OrderFields.CustomerId, "customer123")
.WithKey(OrderFields.OrderId, "ORDER#order456")
.GetItemAsync();
Compile-Time Validation
The source generator enforces default entity rules:
// ❌ Error: No default specified
[DynamoDbTable("ecommerce")]
public partial class Order { }
[DynamoDbTable("ecommerce")]
public partial class OrderLine { }
// Compiler error: Table 'ecommerce' has multiple entities but no default specified.
// ❌ Error: Multiple defaults
[DynamoDbTable("ecommerce", IsDefault = true)]
public partial class Order { }
[DynamoDbTable("ecommerce", IsDefault = true)]
public partial class OrderLine { }
// Compiler error: Table 'ecommerce' has multiple entities marked as default.
Entity Relationships
The [RelatedEntity] attribute enables automatic population of related data based on sort key patterns. This provides a declarative way to model relationships between entities.
Single Related Entity (One-to-One)
Use [RelatedEntity] with an exact match pattern for one-to-one relationships:
[DynamoDbTable("invoices", IsDefault = true)]
public partial class Invoice
{
[PartitionKey]
[Computed(nameof(InvoiceId), Format = "INVOICE#{0}")]
[DynamoDbAttribute("pk")]
public string InvoiceId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "METADATA";
[DynamoDbAttribute("customerId")]
public string CustomerId { get; set; } = string.Empty;
[DynamoDbAttribute("total")]
public decimal Total { get; set; }
// Automatically populated from item with SK = "SUMMARY"
[RelatedEntity("SUMMARY")]
public InvoiceSummary? Summary { get; set; }
}
public class InvoiceSummary
{
public decimal TotalAmount { get; set; }
public int ItemCount { get; set; }
public DateTime LastUpdated { get; set; }
}
Behavior:
- If no matching item found: Property is
null - If one matching item found: Property is populated
- If multiple matching items found: First item is used
Collection Related Entities (One-to-Many)
Use [RelatedEntity] with wildcard patterns for one-to-many relationships:
[DynamoDbTable("invoices", IsDefault = true)]
public partial class Invoice
{
[PartitionKey]
[Computed(nameof(InvoiceId), Format = "INVOICE#{0}")]
[DynamoDbAttribute("pk")]
public string InvoiceId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "METADATA";
[DynamoDbAttribute("customerId")]
public string CustomerId { get; set; } = string.Empty;
// Automatically populated from items with SK starting with "ITEM#"
[RelatedEntity("ITEM#*")]
public List<InvoiceItem>? Items { get; set; }
// Automatically populated from items with SK starting with "STATUS#"
[RelatedEntity("STATUS#*")]
public List<InvoiceStatus>? StatusHistory { get; set; }
}
public class InvoiceItem
{
public string ProductId { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class InvoiceStatus
{
public string Status { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
public string ChangedBy { get; set; } = string.Empty;
}
DynamoDB Items:
PK: INVOICE#123 SK: METADATA → Invoice header
PK: INVOICE#123 SK: ITEM#001 → Line item 1
PK: INVOICE#123 SK: ITEM#002 → Line item 2
PK: INVOICE#123 SK: STATUS#001 → Status entry 1
PK: INVOICE#123 SK: STATUS#002 → Status entry 2
PK: INVOICE#123 SK: SUMMARY → Invoice summary
Sort Key Pattern Matching
| Pattern | Description | Examples |
|---|---|---|
"SUMMARY" | Exact match | Matches only SK = "SUMMARY" |
"ITEM#*" | Prefix match | Matches ITEM#001, ITEM#002, ITEM#abc |
"STATUS#*" | Prefix match | Matches STATUS#001, STATUS#pending |
Pattern Rules:
- Exact match: No wildcard, matches SK exactly
- Prefix match: Ends with
*, matches SK starting with the prefix - Case sensitive: Patterns are case-sensitive
- Order matters: Items are returned in sort key order
Compound Entities
The ToCompoundEntityAsync() method retrieves a parent entity with all its related entities populated in a single query. This is the most efficient way to load hierarchical data.
Building Nested Structures
[DynamoDbTable("invoices", IsDefault = true)]
public partial class Invoice
{
[PartitionKey]
[Computed(nameof(InvoiceId), Format = "INVOICE#{0}")]
[DynamoDbAttribute("pk")]
public string InvoiceId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "METADATA";
[DynamoDbAttribute("customerId")]
public string CustomerId { get; set; } = string.Empty;
[DynamoDbAttribute("invoiceDate")]
public DateTime InvoiceDate { get; set; }
[DynamoDbAttribute("total")]
public decimal Total { get; set; }
// Related entities
[RelatedEntity("ITEM#*")]
public List<InvoiceItem>? Items { get; set; }
[RelatedEntity("STATUS#*")]
public List<InvoiceStatus>? StatusHistory { get; set; }
}
[DynamoDbTable("invoices")]
public partial class InvoiceItem
{
[PartitionKey]
[Computed(nameof(InvoiceId), Format = "INVOICE#{0}")]
[DynamoDbAttribute("pk")]
public string InvoiceId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ITEM#{LineNumber:D3}")]
public int LineNumber { get; set; }
[DynamoDbAttribute("productId")]
public string ProductId { get; set; } = string.Empty;
[DynamoDbAttribute("description")]
public string Description { get; set; } = string.Empty;
[DynamoDbAttribute("quantity")]
public int Quantity { get; set; }
[DynamoDbAttribute("unitPrice")]
public decimal UnitPrice { get; set; }
}
[DynamoDbTable("invoices")]
public partial class InvoiceStatus
{
[PartitionKey]
[Computed(nameof(InvoiceId), Format = "INVOICE#{0}")]
[DynamoDbAttribute("pk")]
public string InvoiceId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("STATUS#{Timestamp:o}")]
public DateTime Timestamp { get; set; }
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
[DynamoDbAttribute("changedBy")]
public string ChangedBy { get; set; } = string.Empty;
[DynamoDbAttribute("notes")]
public string Notes { get; set; } = string.Empty;
}
Retrieving Compound Entities
var invoiceTable = new InvoicesTable(client, "invoices");
// Retrieve invoice with all related items and status history
var invoice = await invoiceTable.Query()
.Where<Invoice>(x => x.InvoiceId == "INVOICE#invoice123")
.ToCompoundEntityAsync();
// Access the populated related entities
Console.WriteLine($"Invoice: {invoice.InvoiceId}");
Console.WriteLine($"Items: {invoice.Items?.Count ?? 0}");
Console.WriteLine($"Status History: {invoice.StatusHistory?.Count ?? 0}");
foreach (var item in invoice.Items ?? Enumerable.Empty<InvoiceItem>())
{
Console.WriteLine($" - {item.Description}: {item.Quantity} x ${item.UnitPrice}");
}
foreach (var status in invoice.StatusHistory ?? Enumerable.Empty<InvoiceStatus>())
{
Console.WriteLine($" - {status.Timestamp}: {status.Status} by {status.ChangedBy}");
}
How It Works
- A single query retrieves all items sharing the same partition key
- The source generator identifies items matching each
[RelatedEntity]pattern - Related entities are automatically populated from matching items
- The parent entity is returned with all relationships resolved
Efficiency: This approach uses a single DynamoDB query instead of multiple round trips, significantly improving performance.
Sort Key Patterns
Effective sort key design is crucial for single-table design. Use hierarchical patterns that enable efficient queries.
Hierarchical Sort Key Design
PK: CUSTOMER#123
├── SK: PROFILE → Customer profile
├── SK: ORDER#001 → Order header
├── SK: ORDER#001#LINE#001 → Order line item
├── SK: ORDER#001#LINE#002 → Order line item
├── SK: ORDER#001#PAYMENT → Order payment
├── SK: ORDER#002 → Another order
├── SK: ORDER#002#LINE#001 → Order line item
└── SK: PREFERENCES → Customer preferences
Consistent Prefix Patterns
Use consistent prefixes to differentiate entity types:
// ✅ Good - Clear hierarchical pattern
SK: ORDER#order456 → Order
SK: ORDER#order456#LINE#001 → OrderLine
SK: ORDER#order456#PAYMENT → Payment
SK: ORDER#order456#SHIPMENT → Shipment
// ❌ Avoid - Inconsistent patterns
SK: order456 → Hard to distinguish
SK: line-001-order456 → Inconsistent format
SK: payment_order456 → Different separator
Zero-Padding for Sortable Keys
Use zero-padding for numeric values to ensure correct sort order:
// ✅ Good - sortable format with zero-padding
SK: ITEM#001
SK: ITEM#002
SK: ITEM#010
SK: ITEM#100
// ❌ Avoid - not sortable correctly
SK: ITEM#1
SK: ITEM#2
SK: ITEM#10
SK: ITEM#100
// Results in: ITEM#1, ITEM#10, ITEM#100, ITEM#2 (wrong order)
Use the format specifier in [Computed] to achieve this:
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}#LINE#{LineNumber:D3}")] // D3 = 3-digit zero-padded
public string OrderId { get; set; } = string.Empty;
public int LineNumber { get; set; }
Efficient Querying of Related Entities
Query all items for an entity using the partition key:
// Get order and all related items in one query
var allOrderItems = await ecommerceTable.Query()
.Where<Order>(x => x.CustomerId == "customer123"
&& x.OrderId.StartsWith("ORDER#order456"))
.ToListAsync();
// Query just order lines
var orderLines = await ecommerceTable.OrderLines.Query()
.Where<OrderLine>(x => x.CustomerId == "customer123"
&& x.OrderId.StartsWith("ORDER#order456#LINE#"))
.ToListAsync();
Time-Series Data Pattern
Store events with timestamps for time-based queries:
PK: ORDER#order456
├── SK: METADATA → Order header
├── SK: EVENT#2024-01-15T10:00:00Z → Order created
├── SK: EVENT#2024-01-15T14:30:00Z → Payment received
├── SK: EVENT#2024-01-16T09:00:00Z → Shipped
└── SK: EVENT#2024-01-18T11:00:00Z → Delivered
Query events within a time range:
var startDate = "EVENT#2024-01-15";
var endDate = "EVENT#2024-01-20";
var recentEvents = await table.Query()
.Where<OrderEvent>(x => x.OrderId == "ORDER#order456"
&& x.SortKey.CompareTo(startDate) >= 0
&& x.SortKey.CompareTo(endDate) <= 0)
.ToListAsync();
Transactions
Transactions are essential for single-table design, ensuring atomic operations across multiple entity types. All operations succeed together or fail together.
Creating Related Entities Atomically
var ecommerceTable = new EcommerceTable(client, "ecommerce");
var order = new Order
{
CustomerId = "customer123",
OrderId = "order456",
Total = 299.97m,
Status = "pending"
};
var line1 = new OrderLine
{
CustomerId = "customer123",
OrderId = "order456",
LineNumber = 1,
ProductId = "prod789",
Quantity = 2,
Price = 99.99m
};
var line2 = new OrderLine
{
CustomerId = "customer123",
OrderId = "order456",
LineNumber = 2,
ProductId = "prod101",
Quantity = 1,
Price = 99.99m
};
var payment = new Payment
{
CustomerId = "customer123",
OrderId = "order456",
PaymentId = "pay789",
Amount = 299.97m,
Method = "credit_card",
Status = "pending"
};
// Create everything atomically
await DynamoDbTransactions.Write
.Add(ecommerceTable.Orders.Put(order))
.Add(ecommerceTable.OrderLines.Put(line1))
.Add(ecommerceTable.OrderLines.Put(line2))
.Add(ecommerceTable.Payments.Put(payment))
.CommitAsync();
Updating Multiple Entity Types
// Update order status and create shipment atomically
await DynamoDbTransactions.Write
.Add(ecommerceTable.Orders.Update("customer123", "ORDER#order456")
.Set(x => new { Status = "shipped" }))
.Add(ecommerceTable.Shipments.Put(new Shipment
{
CustomerId = "customer123",
OrderId = "order456",
TrackingNumber = "TRACK123",
Carrier = "UPS",
Status = "in_transit",
ShippedAt = DateTime.UtcNow
}))
.CommitAsync();
Conditional Transactions
Use condition checks to verify state before making changes:
// Check inventory before confirming order
await DynamoDbTransactions.Write
.Add(inventoryTable.ConditionCheck("prod789")
.Where(x => x.Quantity >= requiredQuantity))
.Add(ecommerceTable.Orders.Update("customer123", "ORDER#order456")
.Set(x => new { Status = "confirmed" }))
.CommitAsync();
Error Handling for Transactions
using Amazon.DynamoDBv2.Model;
try
{
await DynamoDbTransactions.Write
.Add(ecommerceTable.Orders.Put(order))
.Add(ecommerceTable.OrderLines.Put(line1))
.CommitAsync();
Console.WriteLine("Transaction successful");
}
catch (TransactionCanceledException ex)
{
Console.WriteLine($"Transaction failed: {ex.Message}");
foreach (var reason in ex.CancellationReasons)
{
Console.WriteLine($"Reason: {reason.Code} - {reason.Message}");
// Common codes:
// - ConditionalCheckFailed: Condition expression failed
// - TransactionConflict: Concurrent transaction conflict
// - ProvisionedThroughputExceeded: Capacity exceeded
}
}
Why Use Transactions?
// ✅ Good - Atomic creation ensures consistency
await DynamoDbTransactions.Write
.Add(ecommerceTable.Orders.Put(order))
.Add(ecommerceTable.OrderLines.Put(line1))
.Add(ecommerceTable.OrderLines.Put(line2))
.CommitAsync();
// ❌ Bad - Non-atomic, can leave partial data if one fails
await ecommerceTable.Orders.Put(order).PutAsync();
await ecommerceTable.OrderLines.Put(line1).PutAsync();
await ecommerceTable.OrderLines.Put(line2).PutAsync();
Complete Example
Here's a complete example showing a multi-entity table for an e-commerce application:
Entity Definitions
using Oproto.FluentDynamoDb.Attributes;
// Orders - Default entity
[DynamoDbTable("ecommerce", IsDefault = true)]
public partial class Order
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}")]
public string OrderId { get; set; } = string.Empty;
[DynamoDbAttribute("total")]
public decimal Total { get; set; }
[DynamoDbAttribute("status")]
public string Status { get; set; } = "pending";
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Related entities
[RelatedEntity("ORDER#{OrderId}#LINE#*")]
public List<OrderLine>? Lines { get; set; }
[RelatedEntity("ORDER#{OrderId}#PAYMENT")]
public Payment? Payment { get; set; }
[RelatedEntity("ORDER#{OrderId}#SHIPMENT")]
public Shipment? Shipment { get; set; }
}
// Order Lines
[DynamoDbTable("ecommerce")]
public partial class OrderLine
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}#LINE#{LineNumber:D3}")]
public string OrderId { get; set; } = string.Empty;
public int LineNumber { get; set; }
[DynamoDbAttribute("productId")]
public string ProductId { get; set; } = string.Empty;
[DynamoDbAttribute("productName")]
public string ProductName { get; set; } = string.Empty;
[DynamoDbAttribute("quantity")]
public int Quantity { get; set; }
[DynamoDbAttribute("price")]
public decimal Price { get; set; }
}
// Payments
[DynamoDbTable("ecommerce")]
public partial class Payment
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}#PAYMENT")]
public string OrderId { get; set; } = string.Empty;
[DynamoDbAttribute("paymentId")]
public string PaymentId { get; set; } = string.Empty;
[DynamoDbAttribute("amount")]
public decimal Amount { get; set; }
[DynamoDbAttribute("method")]
public string Method { get; set; } = string.Empty;
[DynamoDbAttribute("status")]
public string Status { get; set; } = "pending";
[DynamoDbAttribute("processedAt")]
public DateTime? ProcessedAt { get; set; }
}
// Shipments
[DynamoDbTable("ecommerce")]
public partial class Shipment
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}#SHIPMENT")]
public string OrderId { get; set; } = string.Empty;
[DynamoDbAttribute("trackingNumber")]
public string TrackingNumber { get; set; } = string.Empty;
[DynamoDbAttribute("carrier")]
public string Carrier { get; set; } = string.Empty;
[DynamoDbAttribute("status")]
public string Status { get; set; } = "preparing";
[DynamoDbAttribute("shippedAt")]
public DateTime? ShippedAt { get; set; }
[DynamoDbAttribute("deliveredAt")]
public DateTime? DeliveredAt { get; set; }
}
Usage
var client = new AmazonDynamoDBClient();
var ecommerceTable = new EcommerceTable(client, "ecommerce");
// Create a complete order with all related entities
var order = new Order
{
CustomerId = "customer123",
OrderId = Guid.NewGuid().ToString(),
Total = 299.97m,
Status = "pending"
};
var line1 = new OrderLine
{
CustomerId = order.CustomerId,
OrderId = order.OrderId,
LineNumber = 1,
ProductId = "prod789",
ProductName = "Widget Pro",
Quantity = 2,
Price = 99.99m
};
var line2 = new OrderLine
{
CustomerId = order.CustomerId,
OrderId = order.OrderId,
LineNumber = 2,
ProductId = "prod101",
ProductName = "Gadget Plus",
Quantity = 1,
Price = 99.99m
};
var payment = new Payment
{
CustomerId = order.CustomerId,
OrderId = order.OrderId,
PaymentId = Guid.NewGuid().ToString(),
Amount = 299.97m,
Method = "credit_card",
Status = "pending"
};
// Create everything atomically
await DynamoDbTransactions.Write
.Add(ecommerceTable.Orders.Put(order))
.Add(ecommerceTable.OrderLines.Put(line1))
.Add(ecommerceTable.OrderLines.Put(line2))
.Add(ecommerceTable.Payments.Put(payment))
.CommitAsync();
// Retrieve complete order with all related entities
var completeOrder = await ecommerceTable.Query()
.Where<Order>(x => x.CustomerId == order.CustomerId
&& x.OrderId.StartsWith($"ORDER#{order.OrderId}"))
.ToCompoundEntityAsync();
Console.WriteLine($"Order: {completeOrder.OrderId}");
Console.WriteLine($"Total: ${completeOrder.Total}");
Console.WriteLine($"Lines: {completeOrder.Lines?.Count ?? 0}");
Console.WriteLine($"Payment: {completeOrder.Payment?.Method ?? "None"}");
When to Use Single-Table Design
Use Single-Table Design When:
✅ Multiple entities share access patterns
✅ Entities have hierarchical relationships (Order → OrderLines)
✅ You need efficient related entity queries
✅ Cost optimization is important
✅ Atomic transactions across entity types are required
Use Separate Tables When:
❌ Entities have completely independent access patterns
❌ Entities have different scaling characteristics
❌ Simplicity is more important than optimization
❌ Different teams own different entities
Next Steps
- Entities - Learn about entity declaration
- Tables - Understand generated table classes
- Access Patterns - Master query approaches
- Repositories - Extend with custom methods