Skip to main content

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 TypeCustom Fields
Clothing Storesize, color, material
Electronics Storewarranty_months, voltage, weight_kg
Food Storeexpiry_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​

  1. After loading: The collection starts tracking all modifications
  2. Set operations: Adding or modifying a field marks it as changed
  3. Remove operations: Removing a field marks it for deletion
  4. 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​

DynamicFieldTypeDynamoDB TypeGetter MethodsSetter Methods
StringSGetString, TryGetStringSetString
DateTimeS (ISO 8601)GetDateTime, GetDateTimeOffsetSetDateTime, SetDateTimeOffset
NumberNGetInt, GetLong, GetDouble, GetDecimalSetInt, SetLong, SetDouble, SetDecimal
BooleanBOOLGetBool, TryGetBoolSetBool
BinaryBGetBytes, TryGetBytesSetBytes
ListLGetStringList, GetIntListSetStringList, SetIntList
StringSetSSGetStringSetSetStringSet
NumberSetNSGetNumberSetSetNumberSet
MapMGetRaw, GetMap<T>SetRaw, SetMap<T>
BinarySetBSGetRawSetRaw
NullNULLReturns null from typed gettersSet 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​

MethodReturnsDescription
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)intRemove all matching, return count
GetMap<T>(fieldName)T?Get Map as typed entity
TryGetMap<T>(fieldName, out T?)boolTry get Map as typed entity
SetMap<T>(fieldName, entity)voidSet 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)voidSet multiple AttributeValues
SetManyWithPrefix(prefix, fields)voidSet multiple with prefix
SetMapsWithPrefix<T>(prefix, entities)voidSet multiple typed entities
RemoveMany(fieldNames)intRemove 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 { }
warning

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​

  • DynamicFieldCollection uses a Dictionary<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​

  1. Use typed accessors - They provide type safety and better performance than generic methods
  2. Check field existence - Use ContainsKey() or TryGet methods before accessing fields
  3. Limit dynamic field count - Keep the number of dynamic fields reasonable per item
  4. Consider GSIs for frequent queries - If you frequently filter by a dynamic field, consider making it a mapped property with a GSI

Limitations​

  1. No compile-time validation - Dynamic field names are strings, so typos won't be caught at compile time
  2. No IntelliSense - Unlike mapped properties, dynamic fields don't have IntelliSense support
  3. Type mismatches at runtime - Accessing a field with the wrong type throws DynamicFieldTypeException
  4. No projection support - Dynamic fields cannot be used in projection expressions (they're always included if present)
  5. 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​