A multi-entity invoice management application demonstrating single-table design with FluentDynamoDb.
- Single-Table Design: Multiple entity types (Customer, Invoice, InvoiceLine) in one table
- Hierarchical Composite Keys: Using sort key patterns for related data
- ToCompositeEntityAsync: Automatic assembly of complex entities from query results
- RelatedEntity Attribute: Declarative configuration for entity relationships
- Lambda Expression Queries: Type-safe query building with IntelliSense
- Generated Entity Accessors: Type-safe table operations via
table.Customers,table.Invoices
In DynamoDB, single-table design stores multiple entity types in one table using composite keys:
| Entity | Partition Key (pk) | Sort Key (sk) |
|---|---|---|
| Customer | CUSTOMER#{customerId} |
PROFILE |
| Invoice | CUSTOMER#{customerId} |
INVOICE#{invoiceNumber} |
| InvoiceLine | CUSTOMER#{customerId} |
INVOICE#{invoiceNumber}#LINE#{lineNumber} |
This design enables:
- Fetching related entities in a single query
- Atomic transactions across entity types
- Reduced operational overhead
The sort key design uses a hierarchical pattern where line items extend the invoice's sort key:
INVOICE#INV-001 <- Invoice header
INVOICE#INV-001#LINE#1 <- Line item 1
INVOICE#INV-001#LINE#2 <- Line item 2
This allows using begins_with("INVOICE#INV-001") to fetch an invoice and all its lines in one query.
The ToCompositeEntityAsync method automatically assembles complex entities from query results:
// Single query fetches invoice + all line items
// Framework automatically populates Invoice.Lines collection
var invoice = await table.Invoices.Query()
.Where(x => x.Pk == pk && x.Sk.StartsWith(skPrefix))
.ToCompositeEntityAsync<Invoice>();The [RelatedEntity] attribute tells the framework how to populate related collections:
[DynamoDbTable("invoices")]
public partial class Invoice
{
// ... other properties ...
// Automatically populated from InvoiceLine entities matching the pattern
[RelatedEntity("INVOICE#*#LINE#*", EntityType = typeof(InvoiceLine))]
public List<InvoiceLine> Lines { get; set; } = new();
}-
DynamoDB Local must be running on port 8000:
# Using the included DynamoDB Local cd dynamodb-local java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb
-
.NET 8.0 SDK installed
cd examples/InvoiceManager
dotnet runThe application provides an interactive menu:
- Create Customer - Add a new customer
- Create Invoice - Create an invoice for a customer
- Add Line Item - Add items to an invoice
- View Invoice - Display complete invoice with all lines (demonstrates ToCompositeEntityAsync)
- List Customer Invoices - Show all invoices for a customer
- List All Customers - View all customers
- Exit - Close the application
InvoiceManager/
├── Entities/
│ ├── Customer.cs # Customer entity (pk=CUSTOMER#id, sk=PROFILE)
│ ├── Invoice.cs # Invoice entity with [RelatedEntity] Lines
│ └── InvoiceLine.cs # Line item entity
├── Program.cs # Interactive console application (table configuration here)
├── InvoiceManager.csproj # Project file
└── README.md # This file
Note: The InvoicesTable class is source-generated from the entity definitions. The table name
is configured in Program.cs and passed to the constructor, demonstrating the recommended
pattern for runtime-configurable table names.
// Customer entity - uses [DynamoDbTable] with key prefix attributes
[DynamoDbTable("invoices")]
[GenerateEntityProperty(Name = "Customers")]
[Scannable]
public partial class Customer
{
// Key prefix generates "CUSTOMER#{value}" automatically
[PartitionKey(Prefix = "CUSTOMER")]
[DynamoDbAttribute("pk")]
public string Pk { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string Sk { get; set; } = string.Empty;
[DynamoDbAttribute("customerId")]
public string CustomerId { get; set; } = string.Empty;
[DynamoDbAttribute("customerName")]
public string Name { get; set; } = string.Empty;
// Constant for the profile sort key value
public const string ProfileSk = "PROFILE";
}
// Invoice entity with related lines
[DynamoDbTable("invoices", IsDefault = true)]
[GenerateEntityProperty(Name = "Invoices")]
public partial class Invoice
{
// Same partition key prefix as Customer - enables single-query retrieval
[PartitionKey(Prefix = "CUSTOMER")]
[DynamoDbAttribute("pk")]
public string Pk { get; set; } = string.Empty;
// Sort key prefix generates "INVOICE#{value}"
[SortKey(Prefix = "INVOICE")]
[DynamoDbAttribute("sk")]
public string Sk { get; set; } = string.Empty;
[DynamoDbAttribute("invoiceNumber")]
public string InvoiceNumber { get; set; } = string.Empty;
// Automatically populated by ToCompositeEntityAsync
[RelatedEntity("INVOICE#*#LINE#*", EntityType = typeof(InvoiceLine))]
public List<InvoiceLine> Lines { get; set; } = new();
// Computed property
public decimal Total => Lines.Sum(l => l.Amount);
}The source generator creates a Keys class for each entity with Pk() and Sk() methods:
// Use the generated Keys class for key construction
var customerPk = Customer.Keys.Pk(customerId); // Returns "CUSTOMER#{customerId}"
var invoiceSk = Invoice.Keys.Sk(invoiceNumber); // Returns "INVOICE#{invoiceNumber}"
// Creating entities with proper keys
var customer = new Customer
{
Pk = Customer.Keys.Pk(customerId),
Sk = Customer.ProfileSk,
CustomerId = customerId,
Name = name
};
var invoice = new Invoice
{
Pk = Invoice.Keys.Pk(customerId),
Sk = Invoice.Keys.Sk(invoiceNumber),
InvoiceNumber = invoiceNumber,
CustomerId = customerId
};// Get complete invoice with all lines in a single query
public async Task<Invoice?> GetCompleteInvoiceAsync(InvoicesTable table, string customerId, string invoiceNumber)
{
var pk = Customer.Keys.Pk(customerId);
var skPrefix = Invoice.Keys.Sk(invoiceNumber);
// PREFERRED: Lambda expression with generated entity accessor
var invoice = await table.Invoices.Query()
.Where(x => x.Pk == pk && x.Sk.StartsWith(skPrefix))
.ToCompositeEntityAsync<Invoice>();
return invoice;
}
// List invoices (without line items)
public async Task<List<Invoice>> GetCustomerInvoicesAsync(InvoicesTable table, string customerId)
{
var pk = Customer.Keys.Pk(customerId);
// ToListAsync returns only Invoice entities
var invoices = await table.Invoices.Query()
.Where(x => x.Pk == pk && x.Sk.StartsWith("INVOICE#"))
.ToListAsync();
return invoices.OrderByDescending(i => i.Date).ToList();
}
// List all customers using Scan
public async Task<List<Customer>> GetAllCustomersAsync(InvoicesTable table)
{
// PREFERRED: Using the generated entity accessor Scan method
var customers = await table.Customers.Scan().ToListAsync();
return customers;
}// Create a customer
await table.Customers.PutAsync(customer);
// Get a customer by key
var customer = await table.Customers.GetAsync(
Customer.Keys.Pk(customerId),
Customer.ProfileSk);
// Create an invoice
await table.Invoices.PutAsync(invoice);
// Add a line item
await table.InvoiceLines.PutAsync(line);| Pattern | Query |
|---|---|
| Get customer | pk = CUSTOMER#{id}, sk = PROFILE |
| Get invoice with lines | pk = CUSTOMER#{id}, sk begins_with INVOICE#{num} |
| List customer invoices | pk = CUSTOMER#{id}, sk begins_with INVOICE# |
| List all customers | Scan with [Scannable] attribute |