The .NET DynamoDB Landscape
AWS provides three ways to interact with DynamoDB from .NET:
- Low-level SDK (
IAmazonDynamoDB). RawPutItem,GetItem,Querycalls withDictionary<string, AttributeValue>. Maximum control, maximum boilerplate. - Document Model (
Table,Document). Slightly higher-level, still untyped. - Object Persistence Model (
DynamoDBContext). Attribute-decorated POCOs, automatic serialization. Easy to start, breaks under AoT, and fights single-table design.
For modern .NET on Lambda with AoT, options 1-2 work but are verbose. Option 3 uses reflection and won't compile with AoT unless you jump through source-generator hoops. This is exactly why I built FluentDynamoDB: type-safe repositories with source generation, no reflection, designed for single-table patterns.
Basic SDK Usage
Writing an item
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
var client = new AmazonDynamoDBClient();
await client.PutItemAsync(new PutItemRequest
{
TableName = "my-app-table",
Item = new Dictionary<string, AttributeValue>
{
["PK"] = new AttributeValue { S = $"USER#{userId}" },
["SK"] = new AttributeValue { S = "PROFILE" },
["Name"] = new AttributeValue { S = user.Name },
["Email"] = new AttributeValue { S = user.Email },
["CreatedAt"] = new AttributeValue { S = DateTime.UtcNow.ToString("O") },
},
ConditionExpression = "attribute_not_exists(PK)",
});
Querying items
var response = await client.QueryAsync(new QueryRequest
{
TableName = "my-app-table",
KeyConditionExpression = "PK = :pk AND begins_with(SK, :prefix)",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
[":pk"] = new AttributeValue { S = $"USER#{userId}" },
[":prefix"] = new AttributeValue { S = "ORDER#" },
},
});
foreach (var item in response.Items)
{
var orderId = item["SK"].S.Replace("ORDER#", "");
var amount = decimal.Parse(item["Amount"].N);
}
This works but it's stringly-typed and error-prone. Typo in a key name? Runtime error. Wrong attribute type? Runtime error.
AoT-Compatible Patterns
The DynamoDBContext (Object Persistence Model) uses reflection for serialization. Under Native AoT, reflection is either unavailable or requires extensive trimming configuration. The cleaner approach is source-generated serialization:
// Define your model with System.Text.Json source generation
[JsonSerializable(typeof(UserProfile))]
internal partial class AppJsonContext : JsonSerializerContext { }
public record UserProfile(string UserId, string Name, string Email, DateTime CreatedAt);
// Serialize/deserialize manually with the low-level SDK
public static Dictionary<string, AttributeValue> ToItem(UserProfile profile) => new()
{
["PK"] = new AttributeValue { S = $"USER#{profile.UserId}" },
["SK"] = new AttributeValue { S = "PROFILE" },
["Name"] = new AttributeValue { S = profile.Name },
["Email"] = new AttributeValue { S = profile.Email },
["CreatedAt"] = new AttributeValue { S = profile.CreatedAt.ToString("O") },
};
public static UserProfile FromItem(Dictionary<string, AttributeValue> item) => new(
UserId: item["PK"].S.Replace("USER#", ""),
Name: item["Name"].S,
Email: item["Email"].S,
CreatedAt: DateTime.Parse(item["CreatedAt"].S)
);
This is verbose but it compiles under AoT without issues. For a production app with dozens of entity types, you want a library that generates this mapping code for you.
FluentDynamoDB Approach
FluentDynamoDB uses source generators to create type-safe repositories at compile time:
[DynamoTable("my-app-table")]
public partial class UserRepository : DynamoRepository<UserProfile>
{
[PartitionKey]
public string PK => $"USER#{Entity.UserId}";
[SortKey]
public string SK => "PROFILE";
}
// Usage
var repo = new UserRepository(dynamoClient);
await repo.PutAsync(new UserProfile("12345", "Dan", "dan@example.com", DateTime.UtcNow));
var user = await repo.GetAsync("USER#12345", "PROFILE");
No reflection. The source generator writes the ToItem/FromItem mapping at build time.
CDK Setup (C#)
using Amazon.CDK;
using Amazon.CDK.AWS.DynamoDB;
var table = new Table(this, "AppTable", new TableProps
{
TableName = "my-app-table",
PartitionKey = new Attribute { Name = "PK", Type = AttributeType.STRING },
SortKey = new Attribute { Name = "SK", Type = AttributeType.STRING },
BillingMode = BillingMode.PAY_PER_REQUEST,
PointInTimeRecovery = true,
DeletionProtection = true,
});
table.AddGlobalSecondaryIndex(new GlobalSecondaryIndexProps
{
IndexName = "GSI1",
PartitionKey = new Attribute { Name = "GSI1PK", Type = AttributeType.STRING },
SortKey = new Attribute { Name = "GSI1SK", Type = AttributeType.STRING },
});
// Grant Lambda access
appFunction.AddEnvironment("TABLE_NAME", table.TableName);
table.GrantReadWriteData(appFunction);
Tips for .NET Developers
- Don't try to use EF Core patterns. DynamoDB isn't relational. No joins, no transactions across partitions, no ad-hoc queries. Design your keys around access patterns.
- Use
ServiceURLfor local development. Point the SDK at DynamoDB Local or LocalStack for integration tests. - Batch operations have limits.
BatchWriteItemhandles 25 items max. For bulk loads, you need to chunk and handleUnprocessedItemsin the response. - Watch for hot partitions. If all your traffic hits one partition key, you'll get throttled regardless of your capacity settings. Spread writes across partitions.
Further Reading
- AWS SDK for .NET: DynamoDB documentation
- DynamoDB Developer Guide
- DynamoDB pricing
- FluentDynamoDB: type-safe DynamoDB repositories for .NET
- AWS DynamoDB overview: general DynamoDB concepts, when to use it, key design patterns
Related Blog Posts
Looking for hands-on help? View my .NET on AWS services β