Tables
When you define an entity with the [DynamoDbTable] attribute and partial keyword, the source generator creates a corresponding table class that provides type-safe operations for interacting with DynamoDB.
How Source Generation Works
The relationship between entities and table classes is straightforward:
- You define entities with attributes like
[DynamoDbTable],[PartitionKey], and[DynamoDbAttribute] - The source generator creates a table class with the same name as your table, suffixed with
Table - The generated class provides type-safe CRUD operations, key builders, and field constants
// Your entity definition
[DynamoDbTable("users")]
public partial class User
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string UserId { get; set; } = string.Empty;
[DynamoDbAttribute("email")]
public string Email { get; set; } = string.Empty;
}
// Source generator creates: UsersTable class
// with operations like: Get(), Put(), Update(), Delete(), Query()
Table Initialization
Basic Initialization
Instantiate the generated table class with a DynamoDB client and table name:
using Amazon.DynamoDBv2;
using Oproto.FluentDynamoDb;
var client = new AmazonDynamoDBClient();
// Table name is configurable at runtime
var table = new UsersTable(client, "users");
Environment-Specific Table Names
Configure different table names for different environments:
// Development
var devTable = new UsersTable(client, "users-dev");
// Staging
var stagingTable = new UsersTable(client, "users-staging");
// Production
var prodTable = new UsersTable(client, "users-prod");
// Or use configuration
var tableName = configuration["DynamoDB:UsersTable"];
var table = new UsersTable(client, tableName);
Using FluentDynamoDbOptions
For advanced features like logging, encryption, or geospatial support, use FluentDynamoDbOptions:
using Oproto.FluentDynamoDb;
using Oproto.FluentDynamoDb.Logging.Extensions;
var client = new AmazonDynamoDBClient();
// Without options (uses defaults)
var table = new UsersTable(client, "users");
// With explicit default options
var options = new FluentDynamoDbOptions();
var table = new UsersTable(client, "users", options);
// With logging enabled
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var options = new FluentDynamoDbOptions()
.WithLogger(loggerFactory.ToDynamoDbLogger<UsersTable>());
var table = new UsersTable(client, "users", options);
// With multiple features combined
var options = new FluentDynamoDbOptions()
.WithLogger(loggerFactory.ToDynamoDbLogger<UsersTable>())
.AddGeospatial()
.WithEncryption(encryptor);
var table = new UsersTable(client, "users", options);
Basic Operations
The generated table class provides two complementary patterns for CRUD operations:
- Convenience methods: Single-call operations for simple use cases
- Builder API: Fluent chaining for complex operations with conditions, return values, or projections
Put Operations
Create new items or replace existing items:
var user = new User
{
UserId = "user123",
Email = "john@example.com",
Name = "John Doe"
};
// Convenience method (simple puts)
await table.Users.PutAsync(user);
// Builder API (equivalent)
await table.Users.Put(user).PutAsync();
// Conditional put - prevent overwrite
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();
Get Operations
Retrieve items by primary key:
// Convenience method (simple gets)
var user = await table.Users.GetAsync("user123");
if (user != null)
{
Console.WriteLine($"Found: {user.Name}");
}
// Builder API with projection (only retrieve specific attributes)
var response = await table.Users.Get("user123")
.WithProjection($"{User.Fields.Name}, {User.Fields.Email}")
.GetItemAsync();
// Strongly consistent read
var response = await table.Users.Get("user123")
.UsingConsistentRead()
.GetItemAsync();
Update Operations
Modify specific attributes without replacing the entire item:
// Convenience method with lambda
await table.Users.UpdateAsync("user123", update =>
update.Set(x => new UserUpdateModel { Status = "active" }));
// Builder API with lambda expression (preferred)
await table.Users.Update("user123")
.Set(x => new UserUpdateModel
{
Name = "Jane Doe",
UpdatedAt = DateTime.UtcNow
})
.UpdateAsync();
// Builder API with format string
await table.Users.Update("user123")
.Set($"SET {User.Fields.Name} = {{0}}", "Jane Doe")
.UpdateAsync();
// Conditional update
await table.Users.Update("user123")
.Where(x => x.Status == "active")
.Set(x => new UserUpdateModel { Name = "Jane Doe" })
.UpdateAsync();
// Atomic increment
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { LoginCount = x.LoginCount.Add(1) })
.UpdateAsync();
Delete Operations
Remove items from the table:
// Convenience method (simple deletes)
await table.Users.DeleteAsync("user123");
// Builder API (equivalent)
await table.Users.Delete("user123").DeleteAsync();
// Conditional delete
await table.Users.Delete("user123")
.Where(x => x.Status == "inactive")
.DeleteAsync();
// Delete with return values
var response = await table.Users.Delete("user123")
.ReturnAllOldValues()
.DeleteAsync();
Conditional Operations
Use conditions to control when operations succeed:
// Only create if doesn't exist
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();
// Only update if exists
await table.Users.Update("user123")
.Where(x => x.UserId.AttributeExists())
.Set(x => new UserUpdateModel { Name = "Jane" })
.UpdateAsync();
// Optimistic locking with version
await table.Users.Update("user123")
.Where(x => x.Version == currentVersion)
.Set(x => new UserUpdateModel {
Name = "Jane",
Version = currentVersion + 1
})
.UpdateAsync();
Entity Accessors
For multi-entity tables (single-table design), the generated table class provides entity-specific accessor properties that eliminate the need for generic type parameters.
What Are Entity Accessors?
When multiple entities share the same table, each entity gets its own accessor property:
// Multi-entity table with entity accessors
var table = new OrdersTable(client, "orders");
// Entity accessors provide type-safe operations without generic parameters
var order = await table.Orders.GetAsync("order123");
var orderLines = await table.OrderLines.Query()
.Where(x => x.OrderId == "order123")
.ToListAsync();
Accessor vs Generic Approach
Compare the accessor approach with the generic method approach:
// Entity accessor approach (recommended) - cleaner, no generics
var order = await table.Orders.GetAsync("order123");
await table.Orders.PutAsync(order);
var lines = await table.OrderLines.Query()
.Where($"{OrderLine.Fields.OrderId} = {{0}}", "order123")
.ToListAsync();
// Generic method approach (also available) - more verbose
var order = await table.Get<Order>()
.WithKey("pk", "order123")
.GetItemAsync();
await table.Put<Order>().WithItem(order).PutAsync();
Benefits of Entity Accessors
| Benefit | Description |
|---|---|
| No generics | table.Orders.Get() vs table.Get<Order>() |
| Better IntelliSense | IDE shows only relevant methods for each entity |
| Type safety | Compiler ensures correct entity type |
| Cleaner code | More readable and maintainable |
Available Accessor Methods
Each entity accessor provides these methods:
| Method | Description | Returns |
|---|---|---|
table.Entity.Get(key) | Start a get builder | GetItemRequestBuilder<T> |
table.Entity.GetAsync(key) | Express-route get | Task<T?> |
table.Entity.Query() | Start a query builder | QueryRequestBuilder<T> |
table.Entity.Put(entity) | Start a put builder with entity | PutItemRequestBuilder<T> |
table.Entity.Put() | Start an empty put builder | PutItemRequestBuilder<T> |
table.Entity.PutAsync(entity) | Express-route put | Task |
table.Entity.Update(key) | Start an update builder | UpdateItemRequestBuilder<T> |
table.Entity.Delete(key) | Start a delete builder | DeleteItemRequestBuilder<T> |
table.Entity.DeleteAsync(key) | Express-route delete | Task |
Pattern Comparison
// Express-route (simplest - for basic operations)
await table.Users.PutAsync(user);
// Builder with entity (for conditions, return values)
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();
// Generic method (also available)
await table.Put<User>().WithItem(user).PutAsync();
Generated Code
The source generator creates several helper classes alongside the table class to provide type-safe access to attribute names, key formatting, and index names.
Fields Static Class
The Fields class contains constants for all DynamoDB attribute names, enabling compile-time validation:
// Generated: User.Fields
public static class Fields
{
public const string UserId = "pk";
public const string Email = "email";
public const string Name = "name";
public const string Status = "status";
}
// Usage - compile-time validated attribute names
await table.Users.Update("user123")
.Set($"SET {User.Fields.Name} = {{0}}", "Jane Doe")
.UpdateAsync();
// Benefits:
// - Refactoring safe: rename property, references update
// - Compile-time validation: typos caught at build time
// - IntelliSense support: discover available fields
Keys Static Class
The Keys class provides methods for formatting keys with prefixes and separators:
// Entity definition with key prefixes
[DynamoDbTable("entities")]
public partial class User
{
[PartitionKey(Prefix = "USER")]
[DynamoDbAttribute("pk")]
public string UserId { get; set; } = string.Empty;
[SortKey(Prefix = "PROFILE")]
[DynamoDbAttribute("sk")]
public string ProfileType { get; set; } = "MAIN";
}
// Generated: User.Keys
public static class Keys
{
public static string Pk(string userId) => $"USER#{userId}";
public static string Sk(string profileType) => $"PROFILE#{profileType}";
}
// Usage - consistent key formatting
var pk = User.Keys.Pk("user123"); // Returns "USER#user123"
var sk = User.Keys.Sk("MAIN"); // Returns "PROFILE#MAIN"
await table.Get<User>()
.WithKey(User.Fields.UserId, User.Keys.Pk("user123"))
.WithKey(User.Fields.ProfileType, User.Keys.Sk("MAIN"))
.GetItemAsync();
Indexes Static Class
The Indexes class contains constants for Global Secondary Index names:
// Entity definition with GSI
[DynamoDbTable("orders")]
public partial class Order
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string OrderId { get; set; } = string.Empty;
[GlobalSecondaryIndex("StatusIndex", IsPartitionKey = true)]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
[GlobalSecondaryIndex("StatusIndex", IsSortKey = true)]
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
}
// Generated: Order.Indexes
public static class Indexes
{
public const string StatusIndex = "StatusIndex";
}
Index-Specific Field Accessors
For each GSI, the source generator also creates a nested class within Fields containing the index-specific field constants:
// Generated: OrderFields.g.cs
public static class OrderFields
{
// Main table fields
public const string OrderId = "pk";
public const string Status = "status";
public const string CreatedAt = "createdAt";
public const string CustomerId = "customerId";
public const string Total = "total";
// GSI-specific nested class
public static class StatusIndex
{
public const string Status = "status";
public const string CreatedAt = "createdAt";
}
}
// Usage - reference GSI-specific fields
await table.Query<Order>()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ToListAsync();
This pattern provides:
- Clear distinction between main table fields and GSI fields
- IntelliSense support for discovering available GSI fields
- Compile-time validation of field names
Querying Global Secondary Indexes
Use the UsingIndex() method to query a GSI. This method accepts the index name constant from the generated Indexes class.
Basic GSI Query
// Query all pending orders using the StatusIndex GSI
var pendingOrders = await table.Query<Order>()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{Order.Fields.Status} = {{0}}", "pending")
.ToListAsync();
foreach (var order in pendingOrders.Items)
{
Console.WriteLine($"Order {order.OrderId}: ${order.Total}");
}
GSI Query with Sort Key Condition
When your GSI has a sort key, you can add range conditions:
// Query pending orders created in the last 7 days
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
var recentPendingOrders = await table.Query<Order>()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}} AND {OrderFields.StatusIndex.CreatedAt} > {{1:o}}",
"pending",
sevenDaysAgo)
.ToListAsync();
GSI Query with Filter Expression
Add filter expressions for additional filtering after the query:
// Query pending orders over $100
var highValuePendingOrders = await table.Query<Order>()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.WithFilter($"{Order.Fields.Total} > {{0}}", 100.00m)
.ToListAsync();
Note: Filter expressions are applied after items are retrieved, so they don't reduce read capacity consumption.
GSI Query with Entity Accessors
For multi-entity tables, you can combine entity accessors with GSI queries:
// Using entity accessor with GSI
var pendingOrders = await table.Orders.Query()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{OrderFields.StatusIndex.Status} = {{0}}", "pending")
.ToListAsync();
GSI Query Limitations
GSIs only support eventually consistent reads:
// ❌ This will throw an exception - GSIs don't support consistent reads
var response = await table.Query<Order>()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{Order.Fields.Status} = {{0}}", "pending")
.UsingConsistentRead() // Not supported on GSIs!
.ToListAsync();
// ✅ GSI queries are always eventually consistent
var response = await table.Query<Order>()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{Order.Fields.Status} = {{0}}", "pending")
.ToListAsync();
Using Generated Code Together
Combine Fields, Keys, and Indexes for fully type-safe operations:
// Query with all generated helpers
var orders = await table.Query<Order>()
.UsingIndex(Order.Indexes.StatusIndex)
.Where($"{Order.Fields.Status} = {{0}} AND {Order.Fields.CreatedAt} > {{1:o}}",
"pending",
DateTime.UtcNow.AddDays(-7))
.ToListAsync();
// Get with formatted keys
var user = await table.Get<User>()
.WithKey(User.Fields.UserId, User.Keys.Pk("user123"))
.WithKey(User.Fields.ProfileType, User.Keys.Sk("MAIN"))
.GetItemAsync();
// Update with field constants
await table.Users.Update("user123")
.Set($"SET {User.Fields.Name} = {{0}}, {User.Fields.Status} = {{1}}",
"Jane Doe",
"active")
.UpdateAsync();
Next Steps
- Access Patterns - Learn the three approaches for writing queries
- Repositories - Extend generated tables with custom methods
- Single-Table Design - Multi-entity patterns and compound entities