Skip to main content

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.

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

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

PatternDescriptionExamples
"SUMMARY"Exact matchMatches only SK = "SUMMARY"
"ITEM#*"Prefix matchMatches ITEM#001, ITEM#002, ITEM#abc
"STATUS#*"Prefix matchMatches STATUS#001, STATUS#pending

Pattern Rules:

  1. Exact match: No wildcard, matches SK exactly
  2. Prefix match: Ends with *, matches SK starting with the prefix
  3. Case sensitive: Patterns are case-sensitive
  4. 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

  1. A single query retrieves all items sharing the same partition key
  2. The source generator identifies items matching each [RelatedEntity] pattern
  3. Related entities are automatically populated from matching items
  4. 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#001OrderLine
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; }

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.

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