Skip to main content

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

  1. Use internal accessors for implementation details - Hide low-level DynamoDB operations from consumers
  2. Validate in public methods - Add validation and business logic before database operations
  3. Group related operations - Organize custom methods by feature in separate partial class files
  4. Document public methods - Add XML documentation to public methods
  5. Use transactions for consistency - Ensure related entities are created/updated atomically
  6. Use lambda expressions - Prefer type-safe lambda expressions over string-based queries

Next Steps