| title | Composite Entities | ||||||
|---|---|---|---|---|---|---|---|
| category | advanced-topics | ||||||
| order | 1 | ||||||
| keywords |
|
||||||
| related |
|
Documentation > Advanced Topics > Composite Entities
Next: Global Secondary Indexes
Composite entities are DynamoDB entities that span multiple items in a table, allowing you to model complex relationships and collections efficiently. This pattern is essential for single-table design and enables powerful query patterns.
A composite entity is a C# object that represents data stored across multiple DynamoDB items sharing the same partition key but with different sort keys. This pattern allows you to:
- Store collections as separate items (one-to-many relationships)
- Model hierarchical data structures
- Implement efficient query patterns
- Maintain data consistency within a partition
1. Order with Line Items
PK: ORDER#123 SK: METADATA → Order header
PK: ORDER#123 SK: ITEM#001 → Line item 1
PK: ORDER#123 SK: ITEM#002 → Line item 2
PK: ORDER#123 SK: ITEM#003 → Line item 3
2. Customer with Addresses
PK: CUSTOMER#456 SK: PROFILE → Customer profile
PK: CUSTOMER#456 SK: ADDRESS#HOME → Home address
PK: CUSTOMER#456 SK: ADDRESS#WORK → Work address
3. Transaction with Audit Trail
PK: TXN#789 SK: SUMMARY → Transaction summary
PK: TXN#789 SK: AUDIT#001 → Audit entry 1
PK: TXN#789 SK: AUDIT#002 → Audit entry 2
Multi-item entities store collections as separate DynamoDB items, where each collection element becomes its own item with a unique sort key.
using Oproto.FluentDynamoDb.Attributes;
[DynamoDbTable("orders")]
public partial class Order
{
// Partition key - groups all related items
[PartitionKey]
[Computed(nameof(OrderId), Format = "ORDER#{0}")]
[DynamoDbAttribute("pk")]
public string OrderId { get; set; } = string.Empty;
// Sort key - differentiates item types
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "METADATA";
// Order header data
[DynamoDbAttribute("customerId")]
public string CustomerId { get; set; } = string.Empty;
[DynamoDbAttribute("orderDate")]
public DateTime OrderDate { get; set; }
[DynamoDbAttribute("status")]
public string Status { get; set; } = "pending";
// Collection stored as separate items
public List<OrderItem> Items { get; set; } = new();
}
public class OrderItem
{
public string ProductId { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal Price { get; set; }
public decimal Subtotal => Quantity * Price;
}When storing an order with items, each item becomes a separate DynamoDB item:
var order = new Order
{
OrderId = "order123",
CustomerId = "customer456",
OrderDate = DateTime.UtcNow,
Status = "pending",
Items = new List<OrderItem>
{
new() { ProductId = "prod1", ProductName = "Widget", Quantity = 2, Price = 10.00m },
new() { ProductId = "prod2", ProductName = "Gadget", Quantity = 1, Price = 25.00m }
}
};
// Store order header
await table.Put
.WithItem(new Dictionary<string, AttributeValue>
{
[OrderFields.OrderId] = new AttributeValue { S = OrderKeys.Pk(order.OrderId) },
[OrderFields.SortKey] = new AttributeValue { S = "METADATA" },
[OrderFields.CustomerId] = new AttributeValue { S = order.CustomerId },
[OrderFields.OrderDate] = new AttributeValue { S = order.OrderDate.ToString("o") },
[OrderFields.Status] = new AttributeValue { S = order.Status }
})
.PutAsync();
// Store each item separately
foreach (var (item, index) in order.Items.Select((item, i) => (item, i)))
{
await table.Put
.WithItem(new Dictionary<string, AttributeValue>
{
[OrderFields.OrderId] = new AttributeValue { S = OrderKeys.Pk(order.OrderId) },
[OrderFields.SortKey] = new AttributeValue { S = $"ITEM#{index:D3}" },
["productId"] = new AttributeValue { S = item.ProductId },
["productName"] = new AttributeValue { S = item.ProductName },
["quantity"] = new AttributeValue { N = item.Quantity.ToString() },
["price"] = new AttributeValue { N = item.Price.ToString() }
})
.PutAsync();
}Query all items for an order using the partition key:
// Query all items for the order
var response = await table.Query
.Where($"{OrderFields.OrderId} = {{0}}", OrderKeys.Pk("order123"))
.ToListAsync();
// Group items by sort key pattern
var orderHeader = response.Items
.FirstOrDefault(item => item[OrderFields.SortKey].S == "METADATA");
var orderItems = response.Items
.Where(item => item[OrderFields.SortKey].S.StartsWith("ITEM#"))
.Select(item => new OrderItem
{
ProductId = item["productId"].S,
ProductName = item["productName"].S,
Quantity = int.Parse(item["quantity"].N),
Price = decimal.Parse(item["price"].N)
})
.ToList();
// Reconstruct the composite entity
var order = new Order
{
OrderId = "order123",
CustomerId = orderHeader?["customerId"]?.S ?? string.Empty,
OrderDate = DateTime.Parse(orderHeader?["orderDate"]?.S ?? DateTime.UtcNow.ToString("o")),
Status = orderHeader?["status"]?.S ?? "unknown",
Items = orderItems
};The [RelatedEntity] attribute enables automatic population of related data based on sort key patterns. This is a more declarative approach than manual grouping.
Use [RelatedEntity] for one-to-one relationships:
[DynamoDbTable("transactions")]
public partial class Transaction
{
[PartitionKey]
[Computed(nameof(TransactionId), Format = "TXN#{0}")]
[DynamoDbAttribute("pk")]
public string TransactionId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "SUMMARY";
[DynamoDbAttribute("amount")]
public decimal Amount { get; set; }
[DynamoDbAttribute("description")]
public string Description { get; set; } = string.Empty;
// Automatically populated from item with SK = "SUMMARY"
[RelatedEntity("summary")]
public TransactionSummary? Summary { get; set; }
}
public class TransactionSummary
{
public decimal TotalAmount { get; set; }
public int ItemCount { get; set; }
public DateTime LastUpdated { get; set; }
}How It Works:
- Query returns multiple items with the same partition key
- Source generator identifies items matching the sort key pattern
- Related entity is automatically populated from matching items
Use [RelatedEntity] with wildcard patterns for one-to-many relationships:
[DynamoDbTable("transactions")]
public partial class TransactionWithAudit
{
[PartitionKey]
[Computed(nameof(TransactionId), Format = "TXN#{0}")]
[DynamoDbAttribute("pk")]
public string TransactionId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "SUMMARY";
[DynamoDbAttribute("amount")]
public decimal Amount { get; set; }
// Automatically populated from items with SK starting with "audit#"
[RelatedEntity("audit#*")]
public List<AuditEntry>? AuditEntries { get; set; }
}
public class AuditEntry
{
public string Action { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
public string UserId { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
}Sort key patterns define how related entities are identified and grouped.
Match a specific sort key value:
// Matches only items with SK = "summary"
[RelatedEntity("summary")]
public TransactionSummary? Summary { get; set; }
// Matches only items with SK = "PROFILE"
[RelatedEntity("PROFILE")]
public UserProfile? Profile { get; set; }Match multiple items using wildcards:
// Matches all items with SK starting with "audit#"
// Examples: "audit#001", "audit#002", "audit#abc"
[RelatedEntity("audit#*")]
public List<AuditEntry>? AuditEntries { get; set; }
// Matches all items with SK starting with "ITEM#"
// Examples: "ITEM#001", "ITEM#002", "ITEM#999"
[RelatedEntity("ITEM#*")]
public List<OrderItem>? Items { get; set; }
// Matches all items with SK starting with "ADDRESS#"
// Examples: "ADDRESS#HOME", "ADDRESS#WORK", "ADDRESS#BILLING"
[RelatedEntity("ADDRESS#*")]
public List<Address>? Addresses { get; set; }- 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
Define multiple related entity patterns on the same entity:
[DynamoDbTable("customers")]
public partial class Customer
{
[PartitionKey]
[Computed(nameof(CustomerId), Format = "CUSTOMER#{0}")]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "PROFILE";
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
[DynamoDbAttribute("email")]
public string Email { get; set; } = string.Empty;
// Multiple related entity patterns
[RelatedEntity("ADDRESS#*")]
public List<Address>? Addresses { get; set; }
[RelatedEntity("ORDER#*")]
public List<OrderSummary>? RecentOrders { get; set; }
[RelatedEntity("PREFERENCE")]
public CustomerPreferences? Preferences { get; set; }
}DynamoDB Items:
PK: CUSTOMER#123 SK: PROFILE → Customer profile
PK: CUSTOMER#123 SK: ADDRESS#HOME → Home address
PK: CUSTOMER#123 SK: ADDRESS#WORK → Work address
PK: CUSTOMER#123 SK: ORDER#001 → Recent order 1
PK: CUSTOMER#123 SK: ORDER#002 → Recent order 2
PK: CUSTOMER#123 SK: PREFERENCE → Preferences
Use a nullable property for optional one-to-one relationships:
// Single related entity - expects 0 or 1 matching item
[RelatedEntity("summary")]
public TransactionSummary? Summary { get; set; }
[RelatedEntity("PROFILE")]
public UserProfile? Profile { 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 (warning logged)
Use a List<T> for one-to-many relationships:
// Collection related entity - expects 0 or more matching items
[RelatedEntity("audit#*")]
public List<AuditEntry>? AuditEntries { get; set; }
[RelatedEntity("ITEM#*")]
public List<OrderItem>? Items { get; set; }Behavior:
- If no matching items found: Property is
nullor empty list - If matching items found: All matching items are added to the list
- Items are ordered by sort key
✅ Efficient: Single Query for Composite Entity
// One query retrieves all related items
var response = await table.Query<Order>()
.Where($"{OrderFields.OrderId} = {{0}}", OrderKeys.Pk("order123"))
.ToListAsync();
// All related entities populated automatically
var order = response.Items.First();
Console.WriteLine($"Order has {order.Items?.Count ?? 0} items");❌ Inefficient: Multiple Queries
// Avoid: Multiple round trips to DynamoDB
var orderHeader = await table.Get<Order>()
.WithKey(OrderFields.OrderId, OrderKeys.Pk("order123"))
.WithKey(OrderFields.SortKey, "METADATA")
.GetItemAsync();
// Separate query for each item type
var items = await table.Query
.Where($"{OrderFields.OrderId} = {{0}} AND begins_with({OrderFields.SortKey}, {{1}})",
OrderKeys.Pk("order123"), "ITEM#")
.ToListAsync();DynamoDB has a 400KB item size limit. For composite entities:
Best Practices:
- Keep individual items small - Each item (header, line item, audit entry) should be well under 400KB
- Use pagination for large collections - If you have hundreds of line items, consider pagination
- Monitor item sizes - Use CloudWatch metrics to track item sizes
// Good: Each item is small
PK: ORDER#123 SK: METADATA → 5KB order header
PK: ORDER#123 SK: ITEM#001 → 1KB line item
PK: ORDER#123 SK: ITEM#002 → 1KB line item
// ... 100 more items, each 1KB
// Total: 105KB across 102 items (well within limits)Querying composite entities consumes read capacity based on:
- Number of items returned
- Size of items
- Consistency level (eventually consistent vs strongly consistent)
Example:
// Query returns 10 items totaling 40KB
// Eventually consistent: 5 RCUs (40KB / 8KB, rounded up)
// Strongly consistent: 10 RCUs (40KB / 4KB, rounded up)
var response = await table.Query<Order>()
.Where($"{OrderFields.OrderId} = {{0}}", OrderKeys.Pk("order123"))
.UsingConsistentRead() // Optional: Use strongly consistent reads
.ToListAsync();For entities with many related items, use pagination:
var allItems = new List<OrderItem>();
string? lastEvaluatedKey = null;
do
{
var response = await table.Query
.Where($"{OrderFields.OrderId} = {{0}}", OrderKeys.Pk("order123"))
.Take(100) // Limit items per page
.WithExclusiveStartKey(lastEvaluatedKey)
.ToListAsync();
// Process items
var pageItems = response.Items
.Where(item => item[OrderFields.SortKey].S.StartsWith("ITEM#"))
.Select(item => /* map to OrderItem */)
.ToList();
allItems.AddRange(pageItems);
lastEvaluatedKey = response.LastEvaluatedKey;
} while (lastEvaluatedKey != null);Use batch operations for efficient writes:
// Batch write for composite entity
var batchBuilder = new BatchWriteItemRequestBuilder(client);
// Add order header
batchBuilder.Put(table, builder => builder
.WithItem(/* order header attributes */));
// Add all line items in batch
foreach (var item in order.Items)
{
batchBuilder.Put(table, builder => builder
.WithItem(/* line item attributes */));
}
// Execute batch (up to 25 items per batch)
await batchBuilder.ExecuteAsync();Complete implementation of an order with line items:
using Oproto.FluentDynamoDb.Attributes;
[DynamoDbTable("orders")]
public partial class Order
{
[PartitionKey]
[Computed(nameof(OrderId), Format = "ORDER#{0}")]
[DynamoDbAttribute("pk")]
public string OrderId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "METADATA";
// Order header fields
[DynamoDbAttribute("customerId")]
public string CustomerId { get; set; } = string.Empty;
[DynamoDbAttribute("orderDate")]
public DateTime OrderDate { get; set; }
[DynamoDbAttribute("status")]
public string Status { get; set; } = "pending";
[DynamoDbAttribute("shippingAddress")]
public string ShippingAddress { get; set; } = string.Empty;
[DynamoDbAttribute("total")]
public decimal Total { get; set; }
// Related entities
[RelatedEntity("ITEM#*")]
public List<OrderItem>? Items { get; set; }
[RelatedEntity("PAYMENT")]
public PaymentInfo? Payment { get; set; }
}
public class OrderItem
{
public string ProductId { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
public decimal Subtotal => (UnitPrice * Quantity) - Discount;
}
public class PaymentInfo
{
public string PaymentMethod { get; set; } = string.Empty;
public string TransactionId { get; set; } = string.Empty;
public DateTime PaymentDate { get; set; }
public decimal Amount { get; set; }
}Usage:
// Create order with items
var order = new Order
{
OrderId = Guid.NewGuid().ToString(),
CustomerId = "customer123",
OrderDate = DateTime.UtcNow,
Status = "pending",
ShippingAddress = "123 Main St, City, State 12345",
Items = new List<OrderItem>
{
new() { ProductId = "prod1", ProductName = "Widget", Quantity = 2, UnitPrice = 10.00m, Discount = 0 },
new() { ProductId = "prod2", ProductName = "Gadget", Quantity = 1, UnitPrice = 25.00m, Discount = 2.50m }
}
};
order.Total = order.Items.Sum(i => i.Subtotal);
// Store order (header + items)
await StoreOrderAsync(table, order);
// Retrieve complete order
var retrievedOrder = await GetOrderAsync(table, order.OrderId);
Console.WriteLine($"Order {retrievedOrder.OrderId} has {retrievedOrder.Items?.Count} items");
Console.WriteLine($"Total: ${retrievedOrder.Total}");Multi-relationship composite entity:
[DynamoDbTable("customers")]
public partial class Customer
{
[PartitionKey]
[Computed(nameof(CustomerId), Format = "CUSTOMER#{0}")]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "PROFILE";
// Customer profile fields
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
[DynamoDbAttribute("email")]
public string Email { get; set; } = string.Empty;
[DynamoDbAttribute("phone")]
public string Phone { get; set; } = string.Empty;
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
// Related entities
[RelatedEntity("ADDRESS#*")]
public List<Address>? Addresses { get; set; }
[RelatedEntity("PREFERENCES")]
public CustomerPreferences? Preferences { get; set; }
}
public class Address
{
public string Type { get; set; } = string.Empty; // HOME, WORK, BILLING
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string ZipCode { get; set; } = string.Empty;
public bool IsDefault { get; set; }
}
public class CustomerPreferences
{
public string Theme { get; set; } = "light";
public string Language { get; set; } = "en";
public bool EmailNotifications { get; set; } = true;
public bool SmsNotifications { get; set; } = false;
}DynamoDB Structure:
PK: CUSTOMER#123 SK: PROFILE → Customer profile
PK: CUSTOMER#123 SK: ADDRESS#HOME → Home address
PK: CUSTOMER#123 SK: ADDRESS#WORK → Work address
PK: CUSTOMER#123 SK: ADDRESS#BILLING → Billing address
PK: CUSTOMER#123 SK: PREFERENCES → Preferences
Usage:
// Query customer with all related data
var response = await table.Query<Customer>()
.Where($"{CustomerFields.CustomerId} = {{0}}", CustomerKeys.Pk("customer123"))
.ToListAsync();
var customer = response.Items.First();
// Access related entities
Console.WriteLine($"Customer: {customer.Name}");
Console.WriteLine($"Addresses: {customer.Addresses?.Count ?? 0}");
Console.WriteLine($"Theme: {customer.Preferences?.Theme ?? "default"}");
// Find default address
var defaultAddress = customer.Addresses?.FirstOrDefault(a => a.IsDefault);
if (defaultAddress != null)
{
Console.WriteLine($"Default: {defaultAddress.Street}, {defaultAddress.City}");
}Financial transaction with multiple related collections:
[DynamoDbTable("transactions")]
public partial class Transaction
{
[PartitionKey]
[Computed(nameof(TenantId), nameof(TransactionId), Format = "TENANT#{0}#TXN#{1}")]
[DynamoDbAttribute("pk")]
public string TenantId { get; set; } = string.Empty;
public string TransactionId { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string SortKey { get; set; } = "SUMMARY";
// Transaction summary fields
[DynamoDbAttribute("description")]
public string Description { get; set; } = string.Empty;
[DynamoDbAttribute("status")]
public string Status { get; set; } = "draft";
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
[DynamoDbAttribute("createdBy")]
public string CreatedBy { get; set; } = string.Empty;
// Related entities
[RelatedEntity("LEDGER#*")]
public List<LedgerEntry>? LedgerEntries { get; set; }
[RelatedEntity("AUDIT#*")]
public List<AuditEntry>? AuditTrail { get; set; }
}
public class LedgerEntry
{
public string LedgerId { get; set; } = string.Empty;
public string AccountId { get; set; } = string.Empty;
public decimal DebitAmount { get; set; }
public decimal CreditAmount { get; set; }
public string Description { get; set; } = string.Empty;
}
public class AuditEntry
{
public string Action { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
public string UserId { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
public Dictionary<string, string> Changes { get; set; } = new();
}DynamoDB Structure:
PK: TENANT#abc#TXN#123 SK: SUMMARY → Transaction summary
PK: TENANT#abc#TXN#123 SK: LEDGER#001 → Ledger entry 1
PK: TENANT#abc#TXN#123 SK: LEDGER#002 → Ledger entry 2
PK: TENANT#abc#TXN#123 SK: AUDIT#001 → Audit entry 1
PK: TENANT#abc#TXN#123 SK: AUDIT#002 → Audit entry 2
PK: TENANT#abc#TXN#123 SK: AUDIT#003 → Audit entry 3
Usage:
// Create transaction with ledger entries
var transaction = new Transaction
{
TenantId = "tenant123",
TransactionId = Guid.NewGuid().ToString(),
Description = "Payment received",
Status = "draft",
CreatedAt = DateTime.UtcNow,
CreatedBy = "user456",
LedgerEntries = new List<LedgerEntry>
{
new() { LedgerId = "ledger1", AccountId = "cash", DebitAmount = 100.00m, CreditAmount = 0 },
new() { LedgerId = "ledger2", AccountId = "revenue", DebitAmount = 0, CreditAmount = 100.00m }
}
};
// Store transaction
await StoreTransactionAsync(table, transaction);
// Add audit entry
await AddAuditEntryAsync(table, transaction.TenantId, transaction.TransactionId, new AuditEntry
{
Action = "CREATED",
Timestamp = DateTime.UtcNow,
UserId = "user456",
Details = "Transaction created"
});
// Retrieve complete transaction with audit trail
var fullTransaction = await GetTransactionAsync(table, transaction.TenantId, transaction.TransactionId);
Console.WriteLine($"Transaction has {fullTransaction.LedgerEntries?.Count} ledger entries");
Console.WriteLine($"Audit trail has {fullTransaction.AuditTrail?.Count} entries");// ✅ Good - consistent prefix pattern
METADATA → Main entity
ITEM#001 → Collection items
ITEM#002
ADDRESS#HOME → Related entities
ADDRESS#WORK
AUDIT#001 → Audit trail
AUDIT#002
// ❌ Avoid - inconsistent patterns
MAIN → Hard to distinguish
item_1 → Inconsistent casing
addr-home → Different separator
audit001 → No separator// ✅ Good - sortable format with zero-padding
ITEM#001
ITEM#002
ITEM#010
ITEM#100
// ❌ Avoid - not sortable
ITEM#1
ITEM#2
ITEM#10
ITEM#100
// Results in: ITEM#1, ITEM#10, ITEM#100, ITEM#2 (wrong order)// ✅ Good - clear separation
[RelatedEntity("ITEM#*")]
public List<OrderItem>? Items { get; set; }
[RelatedEntity("PAYMENT#*")]
public List<Payment>? Payments { get; set; }
// ❌ Avoid - overlapping patterns
[RelatedEntity("*")] // Matches everything
public List<object>? AllRelated { get; set; }// ✅ Good - atomic write of composite entity
var txnBuilder = new TransactWriteItemsRequestBuilder(client);
// Add order header
txnBuilder.Put(table, builder => builder.WithItem(/* order header */));
// Add all items in same transaction
foreach (var item in order.Items)
{
txnBuilder.Put(table, builder => builder.WithItem(/* item */));
}
await txnBuilder.CommitAsync();DynamoDB queries return up to 1MB of data. For large composite entities:
// ✅ Good - paginate large collections
var allItems = new List<OrderItem>();
string? lastKey = null;
do
{
var response = await table.Query
.Where($"{OrderFields.OrderId} = {{0}}", OrderKeys.Pk("order123"))
.Take(100)
.WithExclusiveStartKey(lastKey)
.ToListAsync();
// Process page
allItems.AddRange(/* extract items */);
lastKey = response.LastEvaluatedKey;
} while (lastKey != null);- Global Secondary Indexes - Query composite entities by different attributes
- Performance Optimization - Optimize composite entity queries
- Querying Data - Advanced query patterns
- Batch Operations - Efficient batch writes
Previous: Advanced Topics | Next: Global Secondary Indexes
See Also: