Repositories
Oproto.FluentDynamoDb generates table classes from your entity definitions, but you can extend these generated classes with custom methods using C#'s partial class feature. This allows you to create domain-specific repositories that encapsulate business logic while leveraging the generated type-safe operations.
Why Extend Generated Tables?
The generated table classes provide all the basic CRUD operations, but real applications often need:
- Domain-specific query methods - Methods like
GetActiveOrdersAsync()instead of raw queries - Validation and business logic - Enforce rules before database operations
- Encapsulation - Hide low-level DynamoDB details from consumers
- Clean public APIs - Expose only what consumers need
The Partial Class Approach
Since generated table classes are declared as partial, you can add your own methods in a separate file:
// Your custom repository methods
public partial class EcommerceTable
{
public async Task<List<Order>> GetActiveOrdersAsync(string customerId)
{
var response = await Orders.Query()
.Where<Order>(x => x.CustomerId == customerId && x.Status == "active")
.ToListAsync();
return response.Items;
}
public async Task<Order> CreateOrderAsync(Order order)
{
// Add validation
if (order.Total < 0)
throw new ArgumentException("Order total cannot be negative");
await Orders.Put(order).PutAsync();
return order;
}
}
This approach gives you the best of both worlds: generated type-safe operations plus custom domain logic.
Custom Accessor Methods
Add domain-specific methods to your partial table class that wrap the generated operations:
Basic Custom Methods
public partial class EcommerceTable
{
/// <summary>
/// Gets all orders for a customer.
/// </summary>
public async Task<List<Order>> GetCustomerOrdersAsync(string customerId)
{
var response = await Orders.Query()
.Where<Order>(x => x.CustomerId == customerId && x.OrderId.StartsWith("ORDER#"))
.ToListAsync();
return response.Items;
}
/// <summary>
/// Gets orders by status using a GSI.
/// </summary>
public async Task<List<Order>> GetOrdersByStatusAsync(string status)
{
var response = await Orders.Query()
.UsingIndex("StatusIndex")
.Where<Order>(x => x.Status == status)
.ToListAsync();
return response.Items;
}
/// <summary>
/// Gets a specific order by ID.
/// </summary>
public async Task<Order?> GetOrderAsync(string customerId, string orderId)
{
var response = await Orders.Get(customerId, $"ORDER#{orderId}")
.GetItemAsync();
return response.Item;
}
}
Methods with Business Logic
public partial class EcommerceTable
{
/// <summary>
/// Creates an order with automatic total calculation.
/// </summary>
public async Task<string> CreateOrderWithLinesAsync(
string customerId,
List<(string ProductId, int Quantity, decimal Price)> items)
{
var orderId = Guid.NewGuid().ToString();
var total = items.Sum(i => i.Quantity * i.Price);
var order = new Order
{
CustomerId = customerId,
OrderId = orderId,
Total = total,
Status = "pending"
};
// Create order and lines atomically using transactions
var transaction = DynamoDbTransactions.Write
.Add(Orders.Put(order));
foreach (var (item, index) in items.Select((i, idx) => (i, idx)))
{
var line = new OrderLine
{
CustomerId = customerId,
OrderId = orderId,
LineNumber = index + 1,
ProductId = item.ProductId,
Quantity = item.Quantity,
Price = item.Price
};
transaction.Add(OrderLines.Put(line));
}
await transaction.CommitAsync();
return orderId;
}
/// <summary>
/// Updates order status with validation.
/// </summary>
public async Task UpdateOrderStatusAsync(string customerId, string orderId, string newStatus)
{
var validStatuses = new[] { "pending", "processing", "shipped", "delivered", "cancelled" };
if (!validStatuses.Contains(newStatus))
throw new ArgumentException($"Invalid status: {newStatus}");
await Orders.Update(customerId, $"ORDER#{orderId}")
.Set(x => new { Status = newStatus })
.UpdateAsync();
}
}
Usage
var table = new EcommerceTable(client, "ecommerce");
// Use custom methods
var orders = await table.GetCustomerOrdersAsync("customer123");
var pendingOrders = await table.GetOrdersByStatusAsync("pending");
var orderId = await table.CreateOrderWithLinesAsync(
"customer123",
new List<(string, int, decimal)>
{
("prod789", 2, 99.99m),
("prod101", 1, 149.99m)
});
await table.UpdateOrderStatusAsync("customer123", orderId, "processing");
Visibility Control
Control what gets generated and its visibility using two key attributes:
GenerateEntityProperty Attribute
Controls the entity accessor property on the table class:
[AttributeUsage(AttributeTargets.Class)]
public class GenerateEntityPropertyAttribute : Attribute
{
public string? Name { get; set; } // Custom accessor name
public bool Generate { get; set; } = true; // Whether to generate
public AccessModifier Modifier { get; set; } = AccessModifier.Public;
}
GenerateAccessors Attribute
Controls which operations are generated and their visibility:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class GenerateAccessorsAttribute : Attribute
{
public TableOperation Operations { get; set; } = TableOperation.All;
public bool Generate { get; set; } = true;
public AccessModifier Modifier { get; set; } = AccessModifier.Public;
}
Custom Accessor Names
Rename the generated accessor property:
[DynamoDbTable("ecommerce")]
[GenerateEntityProperty(Name = "Lines")] // Custom name instead of "OrderLines"
public partial class OrderLine
{
// ...
}
Usage:
var table = new EcommerceTable(client, "ecommerce");
var response = await table.Lines.Query() // Uses custom name
.Where<OrderLine>(x => x.CustomerId == "customer123")
.ToListAsync();
Hiding Entity Accessors
Prevent accessor generation entirely:
[DynamoDbTable("ecommerce")]
[GenerateEntityProperty(Generate = false)] // No accessor generated
public partial class InternalAuditLog
{
// ...
}
This is useful for internal entities that shouldn't be exposed in the public API.
Internal Accessors
Make accessors internal to hide them from external assemblies:
[DynamoDbTable("ecommerce")]
[GenerateEntityProperty(Modifier = AccessModifier.Internal)]
public partial class OrderLine
{
// ...
}
Generated code:
public partial class EcommerceTable
{
internal OrderLineAccessor OrderLines { get; } // Internal visibility
}
Controlling Operation Visibility
Generate only specific operations:
[DynamoDbTable("ecommerce")]
[GenerateAccessors(Operations = TableOperation.Get | TableOperation.Query)]
public partial class ReadOnlyEntity
{
// Only Get() and Query() methods are generated
// No Put(), Delete(), Update(), or Scan() methods
}
Disable specific operations:
[DynamoDbTable("ecommerce")]
[GenerateAccessors(Operations = TableOperation.Delete, Generate = false)]
public partial class ImmutableEntity
{
// All operations except Delete() are generated
}
Make all operations internal except Query:
[DynamoDbTable("ecommerce")]
[GenerateAccessors(Operations = TableOperation.All, Modifier = AccessModifier.Internal)]
[GenerateAccessors(Operations = TableOperation.Query, Modifier = AccessModifier.Public)]
public partial class OrderLine
{
// All operations are internal except Query() which is public
}
Generated code:
public class OrderLineAccessor
{
internal GetItemRequestBuilder<OrderLine> Get() { }
public QueryRequestBuilder<OrderLine> Query() { } // Public
internal ScanRequestBuilder<OrderLine> Scan() { }
internal PutItemRequestBuilder<OrderLine> Put(OrderLine item) { }
internal DeleteItemRequestBuilder<OrderLine> Delete() { }
internal UpdateItemRequestBuilder<OrderLine> Update() { }
}
Encapsulation Patterns
Combine internal accessors with custom public methods to create clean, validated APIs.
Basic Encapsulation Pattern
Step 1: Define entities with internal 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";
}
[DynamoDbTable("ecommerce")]
[GenerateAccessors(Operations = TableOperation.All, Modifier = AccessModifier.Internal)]
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; }
}
Step 2: Create custom public methods with validation
public partial class EcommerceTable
{
/// <summary>
/// Gets all order lines for a specific order.
/// </summary>
public async Task<List<OrderLine>> GetOrderLinesAsync(string customerId, string orderId)
{
if (string.IsNullOrWhiteSpace(customerId))
throw new ArgumentException("Customer ID is required", nameof(customerId));
if (string.IsNullOrWhiteSpace(orderId))
throw new ArgumentException("Order ID is required", nameof(orderId));
var sortKeyPrefix = $"ORDER#{orderId}#LINE#";
var response = await OrderLines.Query() // Internal accessor
.Where<OrderLine>(x => x.CustomerId == customerId
&& x.OrderId.StartsWith(sortKeyPrefix))
.ToListAsync();
return response.Items;
}
/// <summary>
/// Adds a new order line with validation.
/// </summary>
public async Task AddOrderLineAsync(OrderLine line)
{
if (line.Quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(line));
if (line.Price < 0)
throw new ArgumentException("Price cannot be negative", nameof(line));
if (string.IsNullOrEmpty(line.ProductId))
throw new ArgumentException("ProductId is required", nameof(line));
await OrderLines.Put(line).PutAsync(); // Internal accessor
}
/// <summary>
/// Updates the quantity of an order line.
/// </summary>
public async Task UpdateOrderLineQuantityAsync(
string customerId,
string orderId,
int lineNumber,
int newQuantity)
{
if (newQuantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(newQuantity));
await OrderLines.Update(customerId, $"ORDER#{orderId}#LINE#{lineNumber:D3}")
.Set(x => new { Quantity = newQuantity })
.UpdateAsync();
}
}
Step 3: Use the clean public API
var table = new EcommerceTable(client, "ecommerce");
// ✅ Clean public API with validation
var lines = await table.GetOrderLinesAsync("customer123", "order456");
await table.AddOrderLineAsync(new OrderLine
{
CustomerId = "customer123",
OrderId = "order456",
LineNumber = 1,
ProductId = "prod789",
Quantity = 2,
Price = 99.99m
});
await table.UpdateOrderLineQuantityAsync("customer123", "order456", 1, newQuantity: 3);
// ❌ Compile error - internal accessor not accessible from external assembly
// await table.OrderLines.Put(line).PutAsync();
Business Logic Encapsulation
Create rich domain methods that coordinate multiple operations:
public partial class EcommerceTable
{
/// <summary>
/// Cancels an order and removes all associated lines.
/// </summary>
public async Task CancelOrderAsync(string customerId, string orderId)
{
// Get all order lines
var lines = await GetOrderLinesAsync(customerId, orderId);
// Delete everything atomically
var transaction = DynamoDbTransactions.Write
.Add(Orders.Update(customerId, $"ORDER#{orderId}")
.Set(x => new { Status = "cancelled" }));
foreach (var line in lines)
{
transaction.Add(
OrderLines.Delete(customerId, $"ORDER#{orderId}#LINE#{line.LineNumber:D3}"));
}
await transaction.CommitAsync();
}
/// <summary>
/// Recalculates and updates the order total based on current lines.
/// </summary>
public async Task RecalculateOrderTotalAsync(string customerId, string orderId)
{
var lines = await GetOrderLinesAsync(customerId, orderId);
var newTotal = lines.Sum(l => l.Quantity * l.Price);
await Orders.Update(customerId, $"ORDER#{orderId}")
.Set(x => new { Total = newTotal })
.UpdateAsync();
}
}
Dependency Injection
Register your custom repository class in the DI container for use throughout your application.
Basic Registration
// Program.cs or Startup.cs
using Amazon.DynamoDBv2;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Register DynamoDB client
builder.Services.AddSingleton<IAmazonDynamoDB, AmazonDynamoDBClient>();
// Register your custom table/repository
builder.Services.AddSingleton<EcommerceTable>(sp =>
{
var client = sp.GetRequiredService<IAmazonDynamoDB>();
var tableName = builder.Configuration["DynamoDB:TableName"] ?? "ecommerce";
return new EcommerceTable(client, tableName);
});
var app = builder.Build();
Using FluentDynamoDbOptions
For more configuration options:
builder.Services.AddSingleton<EcommerceTable>(sp =>
{
var client = sp.GetRequiredService<IAmazonDynamoDB>();
var options = new FluentDynamoDbOptions
{
TableName = builder.Configuration["DynamoDB:TableName"] ?? "ecommerce",
ConsistentRead = true // Enable consistent reads by default
};
return new EcommerceTable(client, options);
});
Constructor Injection
Inject the repository into your services:
public class OrderService
{
private readonly EcommerceTable _table;
public OrderService(EcommerceTable table)
{
_table = table;
}
public async Task<Order?> GetOrderAsync(string customerId, string orderId)
{
return await _table.GetOrderAsync(customerId, orderId);
}
public async Task<string> PlaceOrderAsync(
string customerId,
List<(string ProductId, int Quantity, decimal Price)> items)
{
return await _table.CreateOrderWithLinesAsync(customerId, items);
}
}
Registering Multiple Tables
If you have multiple tables:
// Register each table
builder.Services.AddSingleton<EcommerceTable>(sp =>
{
var client = sp.GetRequiredService<IAmazonDynamoDB>();
return new EcommerceTable(client, "ecommerce");
});
builder.Services.AddSingleton<InventoryTable>(sp =>
{
var client = sp.GetRequiredService<IAmazonDynamoDB>();
return new InventoryTable(client, "inventory");
});
// Use in services
public class ProductService
{
private readonly EcommerceTable _ecommerce;
private readonly InventoryTable _inventory;
public ProductService(EcommerceTable ecommerce, InventoryTable inventory)
{
_ecommerce = ecommerce;
_inventory = inventory;
}
}
Environment-Specific Table Names
Use configuration to support different environments:
// appsettings.Development.json
{
"DynamoDB": {
"TableName": "ecommerce-dev"
}
}
// appsettings.Production.json
{
"DynamoDB": {
"TableName": "ecommerce-prod"
}
}
builder.Services.AddSingleton<EcommerceTable>(sp =>
{
var client = sp.GetRequiredService<IAmazonDynamoDB>();
var tableName = builder.Configuration["DynamoDB:TableName"]!;
return new EcommerceTable(client, tableName);
});
Best Practices
- Use internal accessors for implementation details - Hide low-level DynamoDB operations from consumers
- Validate in public methods - Add validation and business logic before database operations
- Group related operations - Organize custom methods by feature in separate partial class files
- Document public methods - Add XML documentation to public methods
- Use transactions for consistency - Ensure related entities are created/updated atomically
- Use lambda expressions - Prefer type-safe lambda expressions over string-based queries
Next Steps
- Single-Table Design - Learn about multi-entity patterns
- Access Patterns - Explore query approaches
- Tables - Understand generated table classes