Skip to main content

CRUD Operations

This guide covers the fundamental Create, Read, Update, and Delete operations in Oproto.FluentDynamoDb. The library provides source-generated entity accessors that offer type-safe, fluent APIs for all DynamoDB operations.

Entity Accessors

Generated tables provide entity-specific accessor properties that eliminate the need for generic type parameters:

// 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 user = await table.Users.GetAsync("user456");

Benefits of Entity Accessors:

  • No generic type parameters needed (table.Orders.Get() vs table.Get<Order>())
  • Better IntelliSense - IDE shows only relevant methods
  • Type-safe - compiler ensures correct entity type
  • Cleaner code - more readable and maintainable

Quick Start

Here's a quick example showing basic Put and Get operations using lambda expressions:

using Amazon.DynamoDBv2;
using Oproto.FluentDynamoDb;

var client = new AmazonDynamoDBClient();
var table = new UsersTable(client, "users");

// Create a user
var user = new User
{
UserId = "user123",
Email = "john@example.com",
Name = "John Doe"
};

// Put the user (simple)
await table.Users.PutAsync(user);

// Put with condition (prevent overwrite) - Lambda Expression (Recommended)
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();

// Get the user
var retrievedUser = await table.Users.GetAsync("user123");
if (retrievedUser != null)
{
Console.WriteLine($"Found user: {retrievedUser.Name}");
}

Three API Styles

FluentDynamoDb supports three approaches for writing expressions. Choose based on your needs:

Use C# lambda expressions for compile-time type safety and IntelliSense support:

await table.Users.Update("user123")
.Where(x => x.Status == "active")
.Set(x => new UserUpdateModel { Name = "Jane Doe" })
.UpdateAsync();

Advantages: Compile-time type checking, IntelliSense support, refactoring safety, automatic parameter generation.

Format Strings (Alternative)

Use String.Format-style syntax for concise expressions:

await table.Users.Update("user123")
.Where($"{User.Fields.Status} = {{0}}", "active")
.Set($"SET {User.Fields.Name} = {{0}}", "Jane Doe")
.UpdateAsync();

Advantages: Concise syntax, automatic parameter generation, supports all DynamoDB features.

Manual Parameters (Advanced)

Use explicit parameter binding for maximum control:

await table.Users.Update("user123")
.Where("#status = :status")
.WithAttribute("#status", "status")
.WithValue(":status", "active")
.Set("SET #name = :name")
.WithAttribute("#name", "name")
.WithValue(":name", "Jane Doe")
.UpdateAsync();

Advantages: Maximum control, explicit parameter management, good for dynamic queries.

Put Operations

Put operations create new items or completely replace existing items with the same primary key.

Simple Put

var user = new User
{
UserId = "user123",
Email = "john@example.com",
Name = "John Doe"
};

// Convenience method (recommended for simple puts)
await table.Users.PutAsync(user);

// Builder API (equivalent)
await table.Users.Put(user).PutAsync();

What Happens:

  • If no item exists with the same primary key, a new item is created
  • If an item exists with the same primary key, it is completely replaced
  • All attributes from the new item are written

Conditional Put (Prevent Overwrite)

Use a condition expression to prevent overwriting existing items:

// Lambda Expression (Recommended)
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();

// Format String (Alternative)
await table.Users.Put(user)
.Where($"attribute_not_exists({User.Fields.UserId})")
.PutAsync();

// Manual Parameters (Advanced)
await table.Users.Put(user)
.Where("attribute_not_exists(#pk)")
.WithAttribute("#pk", "pk")
.PutAsync();

Common Condition Patterns:

// Only create if doesn't exist - Lambda (Recommended)
.Where(x => x.UserId.AttributeNotExists())

// Only update if exists - Lambda (Recommended)
.Where(x => x.UserId.AttributeExists())

// Only update if version matches (optimistic locking)
.Where(x => x.Version == currentVersion)

// Only update if status is specific value
.Where(x => x.Status == "active")

Put with Return Values

Get the old item values after a put operation. There are two approaches:

Option 1: ToDynamoDbResponseAsync - Direct Response Access

// Use ToDynamoDbResponseAsync to get the raw AWS SDK response
var response = await table.Users.Put(user)
.ReturnAllOldValues()
.ToDynamoDbResponseAsync();

// Check if an item was replaced
if (response.Attributes != null && response.Attributes.Count > 0)
{
var oldUser = UserMapper.FromAttributeMap(response.Attributes);
Console.WriteLine($"Replaced user: {oldUser.Name}");
}

Option 2: Context-Based Access

// PutAsync() returns Task (void) and populates DynamoDbOperationContext automatically
await table.Users.Put(user)
.ReturnAllOldValues()
.PutAsync();

// Access old values via context
var context = DynamoDbOperationContext.Current;
if (context?.PreOperationValues != null && context.PreOperationValues.Count > 0)
{
// Use the built-in deserialization helper
var oldUser = context.DeserializePreOperationValue<User>();
if (oldUser != null)
{
Console.WriteLine($"Replaced user: {oldUser.Name}");
}
}

Note: PutAsync() returns Task (void) and populates DynamoDbOperationContext.Current with operation metadata. Use ToDynamoDbResponseAsync() when you need direct access to the raw AWS SDK response.

Warning: DynamoDbOperationContext uses AsyncLocal<T> which may not be suitable for unit testing scenarios where async context doesn't flow as expected. For testable code, prefer ToDynamoDbResponseAsync() or inject the context access pattern.

Return Value Options:

  • ReturnAllOldValues() - Returns all attributes of the old item
  • ReturnNone() - Returns nothing (default, most efficient)

Conditional Put with Error Handling

using Amazon.DynamoDBv2.Model;

try
{
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();

Console.WriteLine("User created successfully");
}
catch (ConditionalCheckFailedException)
{
Console.WriteLine("User already exists");
}

Get Operations

Get operations retrieve items by their primary key.

Simple Get (Partition Key Only)

// Convenience method (recommended for simple gets)
var user = await table.Users.GetAsync("user123");

if (user != null)
{
Console.WriteLine($"Found user: {user.Name}");
}
else
{
Console.WriteLine("User not found");
}

// Builder API (equivalent)
var response = await table.Users.Get("user123").GetItemAsync();
if (response.Item != null)
{
Console.WriteLine($"Found user: {response.Item.Name}");
}

Get with Composite Key

For tables with both partition key and sort key:

// Convenience method with composite key
var order = await table.Orders.GetAsync("customer123", "order456");

// Builder API (equivalent)
var response = await table.Orders.Get("customer123", "order456").GetItemAsync();

Get with Projection Expression

Retrieve only specific attributes to reduce data transfer and improve performance:

// Builder API required for projections
var response = await table.Users.Get("user123")
.WithProjection($"{User.Fields.Name}, {User.Fields.Email}")
.GetItemAsync();

// Note: Other properties will have default values
if (response.Item != null)
{
Console.WriteLine($"Name: {response.Item.Name}");
Console.WriteLine($"Email: {response.Item.Email}");
}

Projection Benefits:

  • Reduces network bandwidth
  • Lowers read capacity consumption
  • Improves response time for large items

Consistent Read

Use consistent reads when you need the most up-to-date data:

// Eventually consistent read (default, faster, cheaper)
var user = await table.Users.GetAsync("user123");

// Strongly consistent read - builder API required
var response = await table.Users.Get("user123")
.UsingConsistentRead()
.GetItemAsync();

When to Use Consistent Reads:

  • Immediately after a write operation
  • When data accuracy is critical (financial transactions)
  • When reading your own writes

Trade-offs:

  • Consistent reads consume 2x the read capacity
  • Consistent reads have higher latency
  • Not available for Global Secondary Indexes

Update Operations

Update operations modify specific attributes of existing items without replacing the entire item.

Basic Update

// Lambda Expression (Recommended)
await table.Users.Update("user123")
.Set(x => new UserUpdateModel
{
Name = "Jane Doe",
Email = "jane@example.com",
UpdatedAt = DateTime.UtcNow
})
.UpdateAsync();

// Format String (Alternative)
await table.Users.Update("user123")
.Set($"SET {User.Fields.Name} = {{0}}, {User.Fields.Email} = {{1}}",
"Jane Doe",
"jane@example.com")
.UpdateAsync();

// Manual Parameters (Advanced)
await table.Users.Update("user123")
.Set("SET #name = :name, #email = :email")
.WithAttribute("#name", "name")
.WithAttribute("#email", "email")
.WithValue(":name", "Jane Doe")
.WithValue(":email", "jane@example.com")
.UpdateAsync();

SET Operations

Set attribute values:

// Lambda Expression (Recommended)
await table.Users.Update("user123")
.Set(x => new UserUpdateModel
{
Name = "Jane Doe",
UpdatedAt = DateTime.UtcNow
})
.UpdateAsync();

// Convenience method with configuration action
await table.Users.UpdateAsync("user123", update =>
update.Set(x => new UserUpdateModel { Status = "active" }));

ADD Operations

Increment numeric values or add elements to sets:

// Lambda Expression (Recommended) - Increment counter
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { LoginCount = x.LoginCount.Add(1) })
.UpdateAsync();

// Decrement (use negative number)
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Credits = x.Credits.Add(-10) })
.UpdateAsync();

// Add elements to a set
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Tags = x.Tags.Add("premium", "verified") })
.UpdateAsync();

// Format String (Alternative)
await table.Users.Update("user123")
.Set($"ADD {User.Fields.LoginCount} {{0}}", 1)
.UpdateAsync();

ADD Behavior:

  • If attribute doesn't exist, it's created with the value
  • For numbers: adds the value (can be negative for subtraction)
  • For sets: adds elements to the set (duplicates ignored)

REMOVE Operations

Remove attributes from an item:

// Lambda Expression (Recommended)
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { TempData = x.TempData.Remove() })
.UpdateAsync();

// Remove multiple attributes
await table.Users.Update("user123")
.Set(x => new UserUpdateModel
{
TempData = x.TempData.Remove(),
CachedValue = x.CachedValue.Remove()
})
.UpdateAsync();

// Format String (Alternative)
await table.Users.Update("user123")
.Set($"REMOVE {User.Fields.TempData}")
.UpdateAsync();

DELETE Operations

Remove elements from sets:

// Lambda Expression (Recommended)
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Tags = x.Tags.Delete("old-tag", "deprecated") })
.UpdateAsync();

// Format String (Alternative)
await table.Users.Update("user123")
.Set($"DELETE {User.Fields.Tags} {{0}}", new HashSet<string> { "old-tag" })
.UpdateAsync();

DELETE vs REMOVE:

  • DELETE - Removes elements from a set attribute
  • REMOVE - Removes entire attributes from the item

Lambda Update Operations Reference

OperationDescriptionExample
x.Prop.Add(value)Atomic increment/add to setx.LoginCount.Add(1)
x.Prop.Remove()Remove attribute entirelyx.TempData.Remove()
x.Prop.Delete(elements)Remove elements from setx.Tags.Delete("old")
x.Prop.IfNotExists(default)Set only if attribute missingx.Count.IfNotExists(0)
x.Prop.ListAppend(elements)Append to end of listx.History.ListAppend("event")
x.Prop.ListPrepend(elements)Prepend to start of listx.Recent.ListPrepend("event")

Conditional Updates

Only update if a condition is met:

// Lambda Expression (Recommended)
await table.Users.Update("user123")
.Where(x => x.Status == "active")
.Set(x => new UserUpdateModel { Name = "Jane Doe" })
.UpdateAsync();

// Format String (Alternative)
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Name = "Jane Doe" })
.Where($"{User.Fields.Status} = {{0}}", "active")
.UpdateAsync();

// Optimistic Locking Example
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Name = "Jane Doe", Version = currentVersion + 1 })
.Where(x => x.Version == currentVersion)
.UpdateAsync();

Update with Return Values

Get attribute values before or after the update. There are two approaches:

Option 1: ToDynamoDbResponseAsync - Direct Response Access

// Use ToDynamoDbResponseAsync to get the raw AWS SDK response
var response = await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Name = "Jane Doe" })
.ReturnAllNewValues()
.ToDynamoDbResponseAsync();

var updatedUser = UserMapper.FromAttributeMap(response.Attributes);
Console.WriteLine($"Updated user: {updatedUser.Name}");

Option 2: Context-Based Access

// UpdateAsync() returns Task (void) and populates DynamoDbOperationContext automatically
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Name = "Jane Doe" })
.ReturnAllNewValues()
.UpdateAsync();

// Access new values via context
var context = DynamoDbOperationContext.Current;
if (context?.PostOperationValues != null)
{
// Use the built-in deserialization helper
var updatedUser = context.DeserializePostOperationValue<User>();
if (updatedUser != null)
{
Console.WriteLine($"Updated user: {updatedUser.Name}");
}
}

Note: UpdateAsync() returns Task (void) and populates DynamoDbOperationContext.Current with operation metadata. Use ToDynamoDbResponseAsync() when you need direct access to the raw AWS SDK response.

Warning: DynamoDbOperationContext uses AsyncLocal<T> which may not be suitable for unit testing scenarios where async context doesn't flow as expected. For testable code, prefer ToDynamoDbResponseAsync() or inject the context access pattern.

Return Value Options:

  • ReturnAllNewValues() - All attributes after update
  • ReturnAllOldValues() - All attributes before update
  • ReturnUpdatedNewValues() - Only updated attributes (new values)
  • ReturnUpdatedOldValues() - Only updated attributes (old values)
  • ReturnNone() - No attributes (default, most efficient)

Delete Operations

Delete operations remove items from the table.

Simple Delete

// Convenience method (recommended for simple deletes)
await table.Users.DeleteAsync("user123");

// Builder API (equivalent)
await table.Users.Delete("user123").DeleteAsync();

// Delete by composite key - convenience method
await table.Orders.DeleteAsync("customer123", "order456");

// Delete by composite key - builder API
await table.Orders.Delete("customer123", "order456").DeleteAsync();

Conditional Delete

Only delete if a condition is met:

// Lambda Expression (Recommended)
await table.Users.Delete("user123")
.Where(x => x.Status == "inactive")
.DeleteAsync();

// Format String (Alternative)
await table.Users.Delete("user123")
.Where($"{User.Fields.Status} = {{0}}", "inactive")
.DeleteAsync();

// Manual Parameters (Advanced)
await table.Users.Delete("user123")
.Where("#status = :status")
.WithAttribute("#status", "status")
.WithValue(":status", "inactive")
.DeleteAsync();

Common Conditional Delete Patterns:

// Only delete if item exists - Lambda (Recommended)
.Where(x => x.UserId.AttributeExists())

// Only delete if version matches (optimistic locking)
.Where(x => x.Version == currentVersion)

// Only delete if status is specific value
.Where(x => x.Status == "inactive")

Delete with Return Values

Get the deleted item's attributes. There are two approaches:

Option 1: ToDynamoDbResponseAsync - Direct Response Access

// Use ToDynamoDbResponseAsync to get the raw AWS SDK response
var response = await table.Users.Delete("user123")
.ReturnAllOldValues()
.ToDynamoDbResponseAsync();

if (response.Attributes != null && response.Attributes.Count > 0)
{
var deletedUser = UserMapper.FromAttributeMap(response.Attributes);
Console.WriteLine($"Deleted user: {deletedUser.Name}");

// Could save to audit log, implement undo, etc.
}

Option 2: Context-Based Access

// DeleteAsync() returns Task (void) and populates DynamoDbOperationContext automatically
await table.Users.Delete("user123")
.ReturnAllOldValues()
.DeleteAsync();

// Access old values via context
var context = DynamoDbOperationContext.Current;
if (context?.PreOperationValues != null && context.PreOperationValues.Count > 0)
{
// Use the built-in deserialization helper
var deletedUser = context.DeserializePreOperationValue<User>();
if (deletedUser != null)
{
Console.WriteLine($"Deleted user: {deletedUser.Name}");

// Could save to audit log, implement undo, etc.
}
}

Note: DeleteAsync() returns Task (void) and populates DynamoDbOperationContext.Current with operation metadata. Use ToDynamoDbResponseAsync() when you need direct access to the raw AWS SDK response.

Warning: DynamoDbOperationContext uses AsyncLocal<T> which may not be suitable for unit testing scenarios where async context doesn't flow as expected. For testable code, prefer ToDynamoDbResponseAsync() or inject the context access pattern.

Delete with Error Handling

using Amazon.DynamoDBv2.Model;

try
{
await table.Users.Delete("user123")
.Where(x => x.Status == "inactive")
.DeleteAsync();

Console.WriteLine("User deleted successfully");
}
catch (ConditionalCheckFailedException)
{
Console.WriteLine("User is not inactive, cannot delete");
}
catch (ResourceNotFoundException)
{
Console.WriteLine("Table does not exist");
}

Best Practices

Use Lambda Expressions for Type Safety

Lambda expressions provide compile-time validation and IntelliSense support:

// ✅ Recommended - type-safe, refactoring-friendly
.Where(x => x.Status == "active")

// ⚠️ Alternative - works but no compile-time validation
.Where($"{User.Fields.Status} = {{0}}", "active")

Use Projection Expressions for Large Items

Reduce data transfer by requesting only needed attributes:

// ✅ Good - only retrieve needed attributes
await table.Users.Get("user123")
.WithProjection($"{User.Fields.Name}, {User.Fields.Email}")
.GetItemAsync();

// ❌ Avoid for large items - retrieves all attributes
await table.Users.GetAsync("user123");

Use Conditional Expressions to Prevent Race Conditions

Prevent overwriting data with optimistic locking:

// ✅ Good - prevents accidental overwrites
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();

// ✅ Good - optimistic locking with version
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Version = currentVersion + 1 })
.Where(x => x.Version == currentVersion)
.UpdateAsync();

Use Eventually Consistent Reads When Possible

Save costs and improve latency when strong consistency isn't required:

// ✅ Good - faster and cheaper for most use cases
var user = await table.Users.GetAsync("user123");

// ⚠️ Use sparingly - 2x cost
var response = await table.Users.Get("user123")
.UsingConsistentRead()
.GetItemAsync();

Handle Conditional Check Failures

Always handle ConditionalCheckFailedException when using conditions:

try
{
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.PutAsync();
}
catch (ConditionalCheckFailedException)
{
// Handle the case where condition wasn't met
}

API Pattern Quick Reference

OperationConvenience MethodBuilder Pattern
Simple Getawait table.Users.GetAsync("id")await table.Users.Get("id").GetItemAsync()
Get with Projection❌ Not supported.WithProjection(...).GetItemAsync()
Consistent Read❌ Not supported.UsingConsistentRead().GetItemAsync()
Simple Putawait table.Users.PutAsync(user)await table.Users.Put(user).PutAsync()
Conditional Put❌ Not supported.Where(...).PutAsync()
Put with Return Values❌ Not supported.ReturnAllOldValues().ToDynamoDbResponseAsync()
Simple Deleteawait table.Users.DeleteAsync("id")await table.Users.Delete("id").DeleteAsync()
Conditional Delete❌ Not supported.Where(...).DeleteAsync()
Delete with Return Values❌ Not supported.ReturnAllOldValues().ToDynamoDbResponseAsync()
Update with Return Values❌ Not supported.ReturnAllNewValues().ToDynamoDbResponseAsync()

Note: For return values, you can also use PutAsync(), UpdateAsync(), or DeleteAsync() and access values via DynamoDbOperationContext.Current. See the "Return Values" sections above for details.

See Also