Skip to main content

DynamoDB Repository Pattern in C#

Extend FluentDynamoDB's generated table classes with custom business logic using C#'s partial class feature. The repository pattern lets you create domain-specific methods, enforce validation rules, and hide low-level DynamoDB operations behind a clean API.

Why use the repository pattern?

  • Expose domain-specific methods like GetActiveOrdersAsync() instead of raw queries
  • Enforce validation and business rules at the data access layer
  • Hide generated operations to prevent bypassing business logic
  • Create self-documenting APIs that clearly express available operations

Extending Tables with Custom Methods

Add custom methods to your generated table class using a partial class:

// Entity definition
[DynamoDbTable("ecommerce")]
public partial class Order
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;

[SortKey]
[DynamoDbAttribute("sk")]
[Computed("ORDER#{OrderId}")]
public string Sk { get; set; } = string.Empty;

public string OrderId { get; set; } = string.Empty;

[DynamoDbAttribute("total")]
public decimal Total { get; set; }

[DynamoDbAttribute("status")]
public string Status { get; set; } = "pending";
}
// Custom repository methods in a partial class
public partial class EcommerceTable
{
/// <summary>
/// Gets all orders for a customer.
/// </summary>
public async Task<List<Order>> GetCustomerOrdersAsync(string customerId)
{
return await Orders.Query()
.Where(x => x.CustomerId == customerId)
.ToListAsync();
}

/// <summary>
/// Gets orders by status.
/// </summary>
public async Task<List<Order>> GetOrdersByStatusAsync(string customerId, string status)
{
return await Orders.Query()
.Where(x => x.CustomerId == customerId)
.WithFilter(x => x.Status == status)
.ToListAsync();
}

/// <summary>
/// Creates an order with validation.
/// </summary>
public async Task CreateOrderAsync(Order order)
{
if (order.Total < 0)
throw new ArgumentException("Order total cannot be negative");

if (string.IsNullOrWhiteSpace(order.CustomerId))
throw new ArgumentException("Customer ID is required");

order.Status = "pending";
await Orders.PutAsync(order);
}

/// <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 domain methods
var orders = await table.GetCustomerOrdersAsync("customer123");
var pendingOrders = await table.GetOrdersByStatusAsync("customer123", "pending");

await table.CreateOrderAsync(new Order
{
CustomerId = "customer123",
OrderId = "order456",
Total = 199.99m
});

await table.UpdateOrderStatusAsync("customer123", "order456", "shipped");

Hiding Generated Accessors

Use [GenerateAccessors] to make generated operations private, then expose only your custom public methods:

// Entity with hidden accessors
[DynamoDbTable("ecommerce")]
[GenerateAccessors(Operations = TableOperation.All, Modifier = AccessModifier.Private)]
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 Sk { get; set; } = string.Empty;

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; }
}
// Custom methods that call hidden accessors
public partial class EcommerceTable
{
/// <summary>
/// Gets all lines for an order.
/// </summary>
public async Task<List<OrderLine>> GetOrderLinesAsync(string customerId, string orderId)
{
// Calls the private OrderLines accessor
return await OrderLines.Query()
.Where(x => x.CustomerId == customerId && x.Sk.StartsWith($"ORDER#{orderId}#LINE#"))
.ToListAsync();
}

/// <summary>
/// Adds an order line with validation.
/// </summary>
public async Task AddOrderLineAsync(OrderLine line)
{
if (line.Quantity <= 0)
throw new ArgumentException("Quantity must be positive");

if (line.Price < 0)
throw new ArgumentException("Price cannot be negative");

if (string.IsNullOrEmpty(line.ProductId))
throw new ArgumentException("ProductId is required");

// Calls the private OrderLines accessor
await OrderLines.PutAsync(line);
}

/// <summary>
/// Updates line quantity with validation.
/// </summary>
public async Task UpdateLineQuantityAsync(
string customerId,
string orderId,
int lineNumber,
int newQuantity)
{
if (newQuantity <= 0)
throw new ArgumentException("Quantity must be positive");

// Calls the private OrderLines accessor
await OrderLines.Update(customerId, $"ORDER#{orderId}#LINE#{lineNumber:D3}")
.Set(x => new { Quantity = newQuantity })
.UpdateAsync();
}

/// <summary>
/// Cancels an order and removes all lines atomically.
/// </summary>
public async Task CancelOrderAsync(string customerId, string orderId)
{
var lines = await GetOrderLinesAsync(customerId, orderId);

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, line.Sk));
}

await transaction.ExecuteAsync();
}
}

Usage:

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
});

// ❌ Compile error - private accessor not accessible
// await table.OrderLines.PutAsync(line);

Visibility Control Options

Control accessor visibility with these attributes:

AttributeEffect
[GenerateAccessors(Modifier = AccessModifier.Private)]Hide all operations
[GenerateAccessors(Modifier = AccessModifier.Internal)]Visible within assembly only
[GenerateAccessors(Operations = TableOperation.Delete, Generate = false)]Disable specific operations
[GenerateEntityProperty(Generate = false)]Don't generate accessor property

Selective visibility example:

// Make all operations internal except Query which stays public
[DynamoDbTable("ecommerce")]
[GenerateAccessors(Operations = TableOperation.All, Modifier = AccessModifier.Internal)]
[GenerateAccessors(Operations = TableOperation.Query, Modifier = AccessModifier.Public)]
public partial class Product
{
// ...
}

Other API Patterns

The examples above use the Lambda/Fluent pattern for type-safe, IntelliSense-friendly code. FluentDynamoDB also supports String Formatted and Manual Builder patterns in your custom repository methods. See the Basic Operations QuickStart for examples of all three patterns.

Learn More