Dynamic Fields
Capture and work with unmapped DynamoDB attributes for multi-tenant applications and flexible schemas.
Overview
Dynamic fields allow entities to capture DynamoDB attributes that are not explicitly defined as properties on the entity class. This is essential for multi-tenant applications where different tenants may need different custom fields without modifying the entity schema.
With dynamic fields enabled, unmapped attributes are automatically captured into a DynamicFieldCollection property, allowing you to:
- Read custom attributes stored by other systems or tenants
- Write custom attributes without modifying the entity class
- Query and filter by custom attribute values
- Update specific custom attributes with change tracking
Use Case: Multi-Tenant Custom Attributes
Consider a multi-tenant e-commerce platform where different tenants sell different types of products:
| Tenant Type | Custom Fields |
|---|---|
| Clothing Store | size, color, material |
| Electronics Store | warranty_months, voltage, weight_kg |
| Food Store | expiry_date, calories, allergens |
With dynamic fields, all these custom attributes can be stored and retrieved using a single Product entity class.
Enabling Dynamic Fields
Add the [EnableDynamicFields] attribute to your entity class:
using Oproto.FluentDynamoDb.Attributes;
using Oproto.FluentDynamoDb.Entities;
[DynamoDbTable("products")]
[EnableDynamicFields]
public partial class Product
{
[PartitionKey(Prefix = "PRODUCT")]
[DynamoDbAttribute("pk")]
public string Pk { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string Sk { get; set; } = string.Empty;
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
[DynamoDbAttribute("price")]
public decimal Price { get; set; }
// The source generator automatically adds:
// public DynamicFieldCollection DynamicFields { get; set; } = new();
}
Requirements:
- The entity class must be declared as
partial - The entity must have
[DynamoDbTable]attribute - Only one
[EnableDynamicFields]attribute per entity
Reading Dynamic Fields
After retrieving an entity, access dynamic fields using typed getters.
Type Detection
Use GetFieldType() to discover the DynamoDB type of a field:
var product = await table.Products.GetAsync(productId);
var fieldType = product.DynamicFields.GetFieldType("color");
switch (fieldType)
{
case DynamicFieldType.String:
var color = product.DynamicFields.GetString("color");
break;
case DynamicFieldType.Number:
var weight = product.DynamicFields.GetInt("weight_grams");
break;
case DynamicFieldType.DateTime:
var expiry = product.DynamicFields.GetDateTime("expiry_date");
break;
case DynamicFieldType.NotFound:
// Field doesn't exist
break;
}
Typed Getters
Use typed getters for type-safe access. These return null if the field doesn't exist:
// String values
var color = product.DynamicFields.GetString("color");
// Numeric values
var weight = product.DynamicFields.GetInt("weight_grams");
var price = product.DynamicFields.GetDecimal("sale_price");
var rating = product.DynamicFields.GetDouble("avg_rating");
var views = product.DynamicFields.GetLong("view_count");
// Boolean values
var isOrganic = product.DynamicFields.GetBool("organic");
// Date/Time values (stored as ISO 8601 strings)
var expiry = product.DynamicFields.GetDateTime("expiry_date");
var lastUpdated = product.DynamicFields.GetDateTimeOffset("last_updated");
// Binary values
var thumbnail = product.DynamicFields.GetBytes("thumbnail");
// Collection values
var tags = product.DynamicFields.GetStringList("tags");
var sizes = product.DynamicFields.GetIntList("available_sizes");
var categories = product.DynamicFields.GetStringSet("categories");
TryGet Pattern
Use TryGet methods for safe access without exceptions:
if (product.DynamicFields.TryGetDecimal("sale_price", out var salePrice))
{
Console.WriteLine($"On sale for: {salePrice:C}");
}
else
{
Console.WriteLine($"Regular price: {product.Price:C}");
}
if (product.DynamicFields.TryGetDateTime("expiry_date", out var expiry))
{
if (expiry < DateTime.UtcNow)
{
Console.WriteLine("Product has expired!");
}
}
Collection Operations
Check for field existence and enumerate fields:
// Check if a field exists
if (product.DynamicFields.ContainsKey("warranty_months"))
{
var warranty = product.DynamicFields.GetInt("warranty_months");
}
// Get count of dynamic fields
Console.WriteLine($"Custom fields: {product.DynamicFields.Count}");
// Enumerate all field names
foreach (var fieldName in product.DynamicFields.FieldNames)
{
var fieldType = product.DynamicFields.GetFieldType(fieldName);
Console.WriteLine($" {fieldName}: {fieldType}");
}
Writing Dynamic Fields
Set dynamic fields before saving an entity:
Typed Setters
var product = new Product
{
Pk = Product.Keys.Pk(productId),
Sk = "META",
Name = "T-Shirt",
Price = 29.99m
};
// String values
product.DynamicFields.SetString("color", "Blue");
product.DynamicFields.SetString("material", "Cotton");
// Numeric values
product.DynamicFields.SetInt("size_us", 10);
product.DynamicFields.SetDecimal("weight_kg", 0.25m);
product.DynamicFields.SetLong("sku", 1234567890L);
// Boolean values
product.DynamicFields.SetBool("in_stock", true);
// Date/Time values (stored as ISO 8601 strings)
product.DynamicFields.SetDateTime("last_restocked", DateTime.UtcNow);
product.DynamicFields.SetDateTimeOffset("created_at", DateTimeOffset.UtcNow);
// Collection values
product.DynamicFields.SetStringList("tags", new List<string> { "sale", "featured" });
product.DynamicFields.SetIntList("available_sizes", new List<int> { 8, 10, 12 });
product.DynamicFields.SetStringSet("categories", new HashSet<string> { "clothing", "mens" });
await table.Products.PutAsync(product);
Removing Fields
// Remove a dynamic field
product.DynamicFields.Remove("temporary_note");
// Clear all dynamic fields
product.DynamicFields.Clear();
Updating Dynamic Fields
There are two approaches to updating dynamic fields, listed in order of preference.
Lambda Expressions with DynamicFieldCollection (Preferred)
Use the DynamicFields property on the update model with a DynamicFieldCollection:
// Load entity and modify dynamic fields
var product = await table.Products.GetAsync(pk, sk);
product.DynamicFields.SetDecimal("sale_price", 24.99m);
product.DynamicFields.SetDateTime("sale_ends", DateTime.UtcNow.AddDays(7));
product.DynamicFields.Remove("temporary_note");
// Update with only the changed fields
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel
{
DynamicFields = product.DynamicFields.ChangesOnly()
})
.UpdateAsync();
// Combine with regular property updates
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel
{
Price = 34.99m,
DynamicFields = product.DynamicFields.ChangesOnly()
})
.UpdateAsync();
You can also create a DynamicFieldCollection directly without loading an entity:
// Create changes without loading entity
var changes = new DynamicFieldCollection();
changes.SetDecimal("sale_price", 24.99m);
changes.SetDateTime("sale_ends", DateTime.UtcNow.AddDays(7));
changes.Remove("temporary_note");
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel
{
DynamicFields = changes
})
.UpdateAsync();
Manual Expression Strings (Explicit Control)
For complex scenarios requiring explicit control over the update expression:
await table.Products.Update(pk, sk)
.Set("#salePrice = :salePrice, #saleEnds = :saleEnds")
.WithAttribute("#salePrice", "sale_price")
.WithAttribute("#saleEnds", "sale_ends")
.WithValue(":salePrice", new AttributeValue { N = "24.99" })
.WithValue(":saleEnds", new AttributeValue { S = DateTime.UtcNow.AddDays(7).ToString("O") })
.UpdateAsync();
// Remove with manual expression
await table.Products.Update(pk, sk)
.Remove("#tempNote")
.WithAttribute("#tempNote", "temporary_note")
.UpdateAsync();
Change Tracking
When an entity is loaded from DynamoDB, the DynamicFieldCollection automatically tracks changes. This enables efficient updates where only modified fields are sent to DynamoDB.
How Change Tracking Works
- After loading: The collection starts tracking all modifications
- Set operations: Adding or modifying a field marks it as changed
- Remove operations: Removing a field marks it for deletion
- ChangesOnly(): Returns a new collection with only the changes
Using ChangesOnly() for Efficient Updates
The ChangesOnly() method returns a new collection containing only the fields that have been added, modified, or removed since the entity was loaded:
// Load an entity
var product = await table.Products.GetAsync(pk, sk);
// Modify some dynamic fields
product.DynamicFields.SetString("color", "Red"); // Changed
product.DynamicFields.SetInt("stock_count", 50); // Added
product.DynamicFields.Remove("temporary_note"); // Removed
// Update with only the changes
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel
{
Price = product.Price,
DynamicFields = product.DynamicFields.ChangesOnly()
})
.UpdateAsync();
// Only "color", "stock_count" are SET, "temporary_note" is REMOVEd
Checking for Changes
Use HasChanges to check if any modifications have been made:
var product = await table.Products.GetAsync(pk, sk);
// Make some changes
product.DynamicFields.SetString("color", "Blue");
if (product.DynamicFields.HasChanges)
{
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel
{
DynamicFields = product.DynamicFields.ChangesOnly()
})
.UpdateAsync();
}
Accessing Removed Fields
The RemovedFields property provides access to fields marked for removal:
var product = await table.Products.GetAsync(pk, sk);
product.DynamicFields.Remove("old_field");
product.DynamicFields.Remove("deprecated_field");
// Check what will be removed
foreach (var fieldName in product.DynamicFields.RemovedFields)
{
Console.WriteLine($"Will remove: {fieldName}");
}
Retry Scenarios
By default, ChangesOnly() resets change tracking on the source collection. For retry scenarios where you need to preserve tracking, pass resetTracking: false:
var product = await table.Products.GetAsync(pk, sk);
product.DynamicFields.SetString("color", "Green");
try
{
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel
{
DynamicFields = product.DynamicFields.ChangesOnly(resetTracking: false)
})
.UpdateAsync();
// Success - manually reset tracking
product.DynamicFields.ResetChangeTracking();
}
catch (Exception)
{
// Retry will include the same changes because tracking was preserved
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel
{
DynamicFields = product.DynamicFields.ChangesOnly()
})
.UpdateAsync();
}
Filtering by Dynamic Fields
Use dynamic fields in filter expressions with natural typed syntax.
Equality Comparisons
// Filter by string value
var blueProducts = await table.Products.Scan()
.WithFilter(x => x.DynamicFields["color"] == "Blue")
.ToListAsync();
// Filter by boolean value
var organicProducts = await table.Products.Scan()
.WithFilter(x => x.DynamicFields["organic"] == true)
.ToListAsync();
Numeric Comparisons
// Greater than
var heavyProducts = await table.Products.Scan()
.WithFilter(x => x.DynamicFields["weight_grams"] > 500)
.ToListAsync();
// Less than or equal
var affordableProducts = await table.Products.Scan()
.WithFilter(x => x.DynamicFields["sale_price"] <= 50.00m)
.ToListAsync();
// Range comparison
var mediumSizes = await table.Products.Scan()
.WithFilter(x => x.DynamicFields["size_us"] >= 8 && x.DynamicFields["size_us"] <= 12)
.ToListAsync();
Existence Checks
// Check if field exists
var productsWithWarranty = await table.Products.Scan()
.WithFilter(x => x.DynamicFields.Exists("warranty_months"))
.ToListAsync();
// Check if field does not exist
var productsWithoutWarranty = await table.Products.Scan()
.WithFilter(x => x.DynamicFields.NotExists("warranty_months"))
.ToListAsync();
Combining with Regular Filters
var results = await table.Products.Query()
.Where(x => x.Pk == tenantPk)
.WithFilter(x => x.Price < 100 && x.DynamicFields["color"] == "Blue")
.ToListAsync();
Condition Expressions
Use dynamic fields in condition expressions for conditional writes:
// Only update if field exists
var changes = new DynamicFieldCollection();
changes.SetDecimal("sale_price", 19.99m);
await table.Products.Update(pk, sk)
.Set(x => new ProductUpdateModel { DynamicFields = changes })
.Where(x => x.DynamicFields.Exists("original_price"))
.UpdateAsync();
// Only put if field has specific value
await table.Products.Put(product)
.Where(x => x.DynamicFields["status"] == "draft")
.PutAsync();
Supported Dynamic Field Types
| DynamicFieldType | DynamoDB Type | Getter Methods | Setter Methods |
|---|---|---|---|
String | S | GetString, TryGetString | SetString |
DateTime | S (ISO 8601) | GetDateTime, GetDateTimeOffset | SetDateTime, SetDateTimeOffset |
Number | N | GetInt, GetLong, GetDouble, GetDecimal | SetInt, SetLong, SetDouble, SetDecimal |
Boolean | BOOL | GetBool, TryGetBool | SetBool |
Binary | B | GetBytes, TryGetBytes | SetBytes |
List | L | GetStringList, GetIntList | SetStringList, SetIntList |
StringSet | SS | GetStringSet | SetStringSet |
NumberSet | NS | GetNumberSet | SetNumberSet |
Map | M | GetRaw, GetMap<T> | SetRaw, SetMap<T> |
BinarySet | BS | GetRaw | SetRaw |
Null | NULL | Returns null from typed getters | Set value to null |
NotFound | - | Field doesn't exist | - |
Prefix-Based Operations
For sparse attribute patterns using naming conventions (e.g., c_{id} for children, t_{id} for transactions), FluentDynamoDb provides prefix-based operations.
Discover Field Names by Prefix
var node = await table.Nodes.GetAsync(pk, sk);
// Get all field names matching a prefix
var childFieldNames = node.DynamicFields.GetFieldNamesByPrefix("c_");
// Returns: ["c_ABC", "c_DEF", ...]
Get Fields by Prefix
// Get all fields matching prefix as raw AttributeValues
var childFields = node.DynamicFields.GetByPrefix("c_");
// Keys: "c_ABC", "c_DEF"
// Get with prefix stripped from keys
var childFieldsStripped = node.DynamicFields.GetByPrefixWithStrippedKeys("c_");
// Keys: "ABC", "DEF"
Remove Fields by Prefix
// Remove all fields matching prefix
int removedCount = node.DynamicFields.RemoveByPrefix("c_");
// Returns count of removed fields
Typed Map Operations
Store and retrieve nested [DynamoDbEntity] types as Map attributes.
Define Nested Entity Type
[DynamoDbEntity]
public partial class ChildRef
{
[DynamoDbAttribute("amt")]
public decimal Amount { get; set; }
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
}
Get Typed Entity from Map Field
// Get typed entity from Map field (returns null if missing)
var child = node.DynamicFields.GetMap<ChildRef>("c_ABC123");
// TryGet pattern for safe access
if (node.DynamicFields.TryGetMap<ChildRef>("c_ABC123", out var childRef))
{
Console.WriteLine(childRef.Amount);
}
Set Typed Entity as Map Field
// Set typed entity as Map field
node.DynamicFields.SetMap("c_ABC123", new ChildRef { Amount = 100m, Status = "active" });
// Set to null removes the field
node.DynamicFields.SetMap<ChildRef>("c_ABC123", null);
Get All Maps by Prefix
// Get all Map fields matching prefix as typed entities
var children = node.DynamicFields.GetMapsByPrefix<ChildRef>("c_");
// Keys: "c_ABC", "c_DEF"
// With prefix stripped from keys
var childrenStripped = node.DynamicFields.GetMapsByPrefixWithStrippedKeys<ChildRef>("c_");
// Keys: "ABC", "DEF"
Bulk Operations
Efficiently add or remove multiple fields at once.
Set Multiple Fields
// Set multiple raw AttributeValues
node.DynamicFields.SetMany(new Dictionary<string, AttributeValue>
{
["field1"] = new AttributeValue { S = "value1" },
["field2"] = new AttributeValue { N = "42" }
});
// Set multiple fields with prefix prepended to keys
node.DynamicFields.SetManyWithPrefix("t_", new Dictionary<string, AttributeValue>
{
["TXN001"] = new AttributeValue { S = "pending" }, // Stored as "t_TXN001"
["TXN002"] = new AttributeValue { S = "complete" } // Stored as "t_TXN002"
});
// Set multiple typed entities with prefix
node.DynamicFields.SetMapsWithPrefix("c_", new Dictionary<string, ChildRef>
{
["ABC"] = new ChildRef { Amount = 100m }, // Stored as "c_ABC"
["DEF"] = new ChildRef { Amount = 200m } // Stored as "c_DEF"
});
Remove Multiple Fields
// Remove multiple fields by name
int removed = node.DynamicFields.RemoveMany(new[] { "c_ABC", "c_DEF", "t_TXN001" });
// Returns count of removed fields
Sparse Attribute Pattern Example
Complete example for tree nodes with dynamic children:
[DynamoDbTable("BalanceTree")]
[EnableDynamicFields]
public partial class TreeNode
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string Pk { get; set; } = string.Empty;
[SortKey]
[DynamoDbAttribute("sk")]
public string Sk { get; set; } = string.Empty;
[DynamoDbAttribute("v")]
public int Version { get; set; }
}
[DynamoDbEntity]
public partial class ChildReference
{
[DynamoDbAttribute("subtotal")]
public decimal Subtotal { get; set; }
}
Usage
// Load and modify
var node = await table.TreeNodes.GetAsync(pk, sk);
// Read all children
var children = node.DynamicFields.GetMapsByPrefixWithStrippedKeys<ChildReference>("c_");
foreach (var (childId, child) in children)
{
Console.WriteLine($"Child {childId}: {child.Subtotal}");
}
// Add new children
node.DynamicFields.SetMapsWithPrefix("c_", new Dictionary<string, ChildReference>
{
["newChild1"] = new ChildReference { Subtotal = 500m },
["newChild2"] = new ChildReference { Subtotal = 300m }
});
// Remove old children
node.DynamicFields.RemoveByPrefix("old_");
// Save with optimistic locking
await table.TreeNodes.Update(pk, sk)
.Set(x => new TreeNodeUpdateModel
{
Version = x.Version + 1,
DynamicFields = node.DynamicFields.ChangesOnly()
})
.Where(x => x.Version == node.Version)
.UpdateAsync();
Method Reference
| Method | Returns | Description |
|---|---|---|
GetFieldNamesByPrefix(prefix) | IEnumerable<string> | Field names matching prefix |
GetByPrefix(prefix) | Dictionary<string, AttributeValue> | Fields with full keys |
GetByPrefixWithStrippedKeys(prefix) | Dictionary<string, AttributeValue> | Fields with prefix stripped |
RemoveByPrefix(prefix) | int | Remove all matching, return count |
GetMap<T>(fieldName) | T? | Get Map as typed entity |
TryGetMap<T>(fieldName, out T?) | bool | Try get Map as typed entity |
SetMap<T>(fieldName, entity) | void | Set typed entity as Map |
GetMapsByPrefix<T>(prefix) | Dictionary<string, T> | Get all Maps as typed entities |
GetMapsByPrefixWithStrippedKeys<T>(prefix) | Dictionary<string, T> | Same with stripped keys |
SetMany(fields) | void | Set multiple AttributeValues |
SetManyWithPrefix(prefix, fields) | void | Set multiple with prefix |
SetMapsWithPrefix<T>(prefix, entities) | void | Set multiple typed entities |
RemoveMany(fieldNames) | int | Remove multiple, return count |
Security Considerations
Logging Redaction
By default, dynamic field values are redacted in logs to protect potentially sensitive data. Only field names are logged:
[Debug] Dynamic fields captured: color, size_us, material (values redacted)
To include values in logs (for debugging purposes only):
[EnableDynamicFields(SensitiveLogging = false)]
public partial class Product { }
Only disable sensitive logging in development environments. Never log dynamic field values in production if they may contain PII or sensitive data.
Error Handling
Type Mismatch
When accessing a dynamic field with an incompatible type:
try
{
// Field "color" contains a string, not an integer
var value = product.DynamicFields.GetInt("color");
}
catch (DynamicFieldTypeException ex)
{
Console.WriteLine($"Field: {ex.FieldName}");
Console.WriteLine($"Requested: {ex.RequestedType}");
Console.WriteLine($"Actual: {ex.ActualDynamoDbType}");
}
Missing Fields
Typed getters return null for missing fields (no exception):
var value = product.DynamicFields.GetString("nonexistent");
// value is null, no exception thrown
Use TryGet methods for explicit handling:
if (!product.DynamicFields.TryGetString("nonexistent", out var value))
{
// Field doesn't exist
}
Performance Considerations
Memory Overhead
DynamicFieldCollectionuses aDictionary<string, AttributeValue>internally- Each dynamic field adds minimal memory overhead
- For entities with many dynamic fields, consider the memory impact when loading large result sets
Query Performance
- Filtering by dynamic fields uses DynamoDB filter expressions
- Filter expressions are applied after the query/scan, not during
- For frequently queried dynamic fields, consider promoting them to mapped properties with GSIs
Best Practices
- Use typed accessors - They provide type safety and better performance than generic methods
- Check field existence - Use
ContainsKey()orTryGetmethods before accessing fields - Limit dynamic field count - Keep the number of dynamic fields reasonable per item
- Consider GSIs for frequent queries - If you frequently filter by a dynamic field, consider making it a mapped property with a GSI
Limitations
- No compile-time validation - Dynamic field names are strings, so typos won't be caught at compile time
- No IntelliSense - Unlike mapped properties, dynamic fields don't have IntelliSense support
- Type mismatches at runtime - Accessing a field with the wrong type throws
DynamicFieldTypeException - No projection support - Dynamic fields cannot be used in projection expressions (they're always included if present)
- Reserved word handling - Field names that are DynamoDB reserved words are automatically escaped in expressions
Complete Example
using Oproto.FluentDynamoDb.Attributes;
using Oproto.FluentDynamoDb.Entities;
[DynamoDbTable("products")]
[EnableDynamicFields]
public partial class Product
{
[PartitionKey(Prefix = "TENANT")]
[DynamoDbAttribute("pk")]
public string TenantId { get; set; } = string.Empty;
[SortKey(Prefix = "PRODUCT")]
[DynamoDbAttribute("sk")]
public string ProductId { get; set; } = string.Empty;
[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
[DynamoDbAttribute("price")]
public decimal Price { get; set; }
}
// Usage
public class ProductService
{
private readonly ProductTable _table;
public async Task CreateProductAsync(
string tenantId,
string productId,
string name,
decimal price,
Dictionary<string, object> customFields)
{
var product = new Product
{
TenantId = tenantId,
ProductId = productId,
Name = name,
Price = price
};
// Add tenant-specific custom fields
foreach (var field in customFields)
{
switch (field.Value)
{
case string s:
product.DynamicFields.SetString(field.Key, s);
break;
case int i:
product.DynamicFields.SetInt(field.Key, i);
break;
case decimal d:
product.DynamicFields.SetDecimal(field.Key, d);
break;
case bool b:
product.DynamicFields.SetBool(field.Key, b);
break;
case DateTime dt:
product.DynamicFields.SetDateTime(field.Key, dt);
break;
}
}
await _table.Products.PutAsync(product);
}
public async Task<IEnumerable<Product>> SearchByCustomFieldAsync(
string tenantId,
string fieldName,
string fieldValue)
{
return await _table.Products.Query()
.Where(x => x.TenantId == tenantId)
.WithFilter(x => x.DynamicFields[fieldName] == fieldValue)
.ToListAsync();
}
}
See Also
- Entity Definition - Learn about entity attributes and key patterns
- CRUD Operations - Basic operations with entities
- Lambda Expressions - Type-safe query expressions
- Logging with Redaction - Sensitive data handling in logs