Skip to main content

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:

  1. You define entities with attributes like [DynamoDbTable], [PartitionKey], and [DynamoDbAttribute]
  2. The source generator creates a table class with the same name as your table, suffixed with Table
  3. 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

BenefitDescription
No genericstable.Orders.Get() vs table.Get<Order>()
Better IntelliSenseIDE shows only relevant methods for each entity
Type safetyCompiler ensures correct entity type
Cleaner codeMore readable and maintainable

Available Accessor Methods

Each entity accessor provides these methods:

MethodDescriptionReturns
table.Entity.Get(key)Start a get builderGetItemRequestBuilder<T>
table.Entity.GetAsync(key)Express-route getTask<T?>
table.Entity.Query()Start a query builderQueryRequestBuilder<T>
table.Entity.Put(entity)Start a put builder with entityPutItemRequestBuilder<T>
table.Entity.Put()Start an empty put builderPutItemRequestBuilder<T>
table.Entity.PutAsync(entity)Express-route putTask
table.Entity.Update(key)Start an update builderUpdateItemRequestBuilder<T>
table.Entity.Delete(key)Start a delete builderDeleteItemRequestBuilder<T>
table.Entity.DeleteAsync(key)Express-route deleteTask

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