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