Breaking Changes in v1.0
This guide covers breaking changes introduced in FluentDynamoDb v1.0.0 and provides migration guidance.
Overview
Version 1.0.0 introduces architectural improvements with the following breaking changes:
| Change | Action Required | Risk Level |
|---|---|---|
| Index attribute API redesign | Replace [GlobalSecondaryIndex]/[LocalSecondaryIndex] with new attributes | High |
DynamoDbTableBase removal | Update direct type references (rare) | Low |
NoUpdate() for conditional skip | Replace null with x.Prop.NoUpdate() | Medium |
| Empty conditional expression handling | Review error-dependent code (rare) | Low |
[Queryable] attribute removed | Remove from entities (no replacement needed) | Low |
Index Attribute API Redesign
What Changed
The [GlobalSecondaryIndex] and [LocalSecondaryIndex] attributes have been removed entirely and replaced with three self-describing attributes:
| Old Attribute | New Attribute |
|---|---|
[GlobalSecondaryIndex("name", IsPartitionKey = true)] | [GsiPartitionKey("name")] |
[GlobalSecondaryIndex("name", IsSortKey = true)] | [GsiSortKey("name")] |
[LocalSecondaryIndex("name")] | [LsiSortKey("name")] |
Why This Change Was Made
The old API used boolean flags (IsPartitionKey = true, IsSortKey = true) which were error-prone:
- Easy to forget to set the flag
- No compile-time enforcement that exactly one partition key exists per index
- Confusing when reading code — the attribute name didn't indicate the key role
The new attributes encode the key role directly in the name, making misconfiguration impossible.
Migration Guide
GSI with partition key only:
// Before
[GlobalSecondaryIndex("status-index", IsPartitionKey = true)]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
// After
[GsiPartitionKey("status-index")]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
GSI with partition key and sort key:
// Before
[GlobalSecondaryIndex("status-index", IsPartitionKey = true)]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
[GlobalSecondaryIndex("status-index", IsSortKey = true)]
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
// After
[GsiPartitionKey("status-index")]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
[GsiSortKey("status-index")]
[DynamoDbAttribute("createdAt")]
public DateTime CreatedAt { get; set; }
LSI sort key:
// Before
[LocalSecondaryIndex("lsi1")]
[DynamoDbAttribute("updatedAt")]
public DateTime UpdatedAt { get; set; }
// After
[LsiSortKey("lsi1")]
[DynamoDbAttribute("updatedAt")]
public DateTime UpdatedAt { get; set; }
GSI with custom Name property:
// Before
[GlobalSecondaryIndex("status-index", IsPartitionKey = true, Name = "StatusIndex")]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
// After
[GsiPartitionKey("status-index", Name = "StatusIndex")]
[DynamoDbAttribute("status")]
public string Status { get; set; } = string.Empty;
Optional Properties
All three new attributes support these optional properties:
| Attribute | Optional Properties |
|---|---|
[GsiPartitionKey] | Name, ProjectionType, DiscriminatorProperty, DiscriminatorValue, DiscriminatorPattern |
[GsiSortKey] | Name, ProjectionType |
[LsiSortKey] | Name, ProjectionType |
New Diagnostic Codes
The source generator now validates index attribute configurations with these diagnostics:
| Code | Description |
|---|---|
| DYNDB120 | GSI sort key without corresponding partition key |
| DYNDB121 | Duplicate GSI partition keys for same index |
| DYNDB122 | Duplicate GSI sort keys for same index |
| DYNDB123 | Duplicate LSI sort keys for same index |
| DYNDB124-126 | Empty/whitespace index name |
| DYNDB127 | Same index name used as both GSI and LSI |
Find-and-Replace Pattern
For most codebases, a simple find-and-replace handles the migration:
- Replace
[GlobalSecondaryIndex("with[GsiPartitionKey("(then fix sort keys manually) - Find remaining
IsPartitionKey = trueand remove them - Find
IsSortKey = trueentries and change[GsiPartitionKeyto[GsiSortKey - Replace
[LocalSecondaryIndex("with[LsiSortKey(" - Remove any remaining
, IsPartitionKey = trueor, IsSortKey = truefragments
Queryable Attribute Removed
What Changed
The [Queryable] attribute has been removed. Query capabilities are now exclusively derived from [PartitionKey] and [SortKey] attributes.
Migration
Simply remove the attribute — no replacement is needed:
// Before
[PartitionKey]
[DynamoDbAttribute("pk")]
[Queryable(SupportedOperations = new[] { DynamoDbOperation.Equals })]
public string UserId { get; set; } = string.Empty;
// After
[PartitionKey]
[DynamoDbAttribute("pk")]
public string UserId { get; set; } = string.Empty;
DynamoDbTableBase Removal
What Changed
The DynamoDbTableBase abstract class has been removed. Generated table classes are now fully self-contained without inheritance.
Impact
- Low Impact for Most Users: Code using generated table classes (entity accessors, index accessors, Query/Get/Put/Update/Delete methods) continues to work unchanged.
- Breaking for Direct References: Code that directly references
DynamoDbTableBaseas a type will no longer compile.
What Still Works (No Changes Required)
All of the following patterns continue to work exactly as before:
// Entity accessors - unchanged
var user = await table.Users.Get(userId).GetItemAsync();
await table.Users.Put(user).PutAsync();
// Index accessors - unchanged
var results = await table.EmailIndex.Query()
.Where(x => x.Email == email)
.ToListAsync();
// Generic methods - unchanged
var items = await table.Query<Order>()
.Where(x => x.Pk == tenantId)
.ToListAsync();
// Properties - unchanged
var client = table.DynamoDbClient;
var tableName = table.Name;
var options = table.Options;
What Requires Migration
Only code that directly references DynamoDbTableBase as a type needs to change.
Method Parameters Accepting DynamoDbTableBase
// Before (no longer compiles)
public void ProcessTable(DynamoDbTableBase table)
{
var client = table.DynamoDbClient;
// ...
}
// After: Use the concrete generated table type
public void ProcessTable(UsersTable table)
{
var client = table.DynamoDbClient;
// ...
}
// Or: Use generics if you need to support multiple table types
public void ProcessTable<TTable>(TTable table) where TTable : class
{
// Use duck typing or reflection if needed
}
Variables Typed as DynamoDbTableBase
// Before (no longer compiles)
DynamoDbTableBase table = new UsersTable(client, "users");
// After: Use the concrete type or var
UsersTable table = new UsersTable(client, "users");
// Or
var table = new UsersTable(client, "users");
Collections of Tables
// Before (no longer compiles)
List<DynamoDbTableBase> tables = new();
tables.Add(new UsersTable(client, "users"));
tables.Add(new OrdersTable(client, "orders"));
// After: Use object or a custom interface
List<object> tables = new();
tables.Add(new UsersTable(client, "users"));
tables.Add(new OrdersTable(client, "orders"));
Why This Change Was Made
- Visibility Control: Generated table classes can now control the visibility of all operations via attributes like
[GenerateAccessors] - Cleaner Architecture: No inheritance hierarchy means simpler code and better AOT compatibility
- Flexibility: Each generated table class can be customized independently
Generated Table Class Structure
Generated table classes now include all functionality directly:
// Generated code (simplified)
public partial class UsersTable
{
public IAmazonDynamoDB DynamoDbClient { get; }
public string Name { get; }
public FluentDynamoDbOptions Options { get; }
public UsersTable(IAmazonDynamoDB client, string tableName, FluentDynamoDbOptions? options = null)
{
DynamoDbClient = client;
Name = tableName;
Options = options ?? new FluentDynamoDbOptions();
}
// Entity accessor
public UserEntityAccessor Users { get; }
// Index accessor
public DynamoDbIndex EmailIndex { get; }
// Generic methods
public QueryRequestBuilder<TEntity> Query<TEntity>() where TEntity : class, IDynamoDbEntity
=> new QueryRequestBuilder<TEntity>(DynamoDbClient, Options).ForTable(Name);
// ... other methods
}
New Features in v1.0.0
While not breaking changes, v1.0.0 also introduces several new features:
- DynamicTable - Schema-less access to any DynamoDB table without defining entity classes
- PartiQL Support - SQL-like query capability with entity hydration
- Direct SDK Request Passing - Accept native AWS SDK request objects with response hydration
NoUpdate() for Conditional Skip
What Changed
In conditional update expressions, using null in the false branch now sets the attribute to DynamoDB NULL instead of skipping the update. Use the new NoUpdate() method to skip updates conditionally.
Previous Behavior (Pre-1.0)
// OLD: null in false branch would skip the update
await table.Users.Update(userId)
.Set(x => new UserUpdateModel
{
Name = shouldUpdate ? newName : null // Skipped update if !shouldUpdate
})
.UpdateAsync();
New Behavior (1.0+)
// NEW: null sets the attribute to DynamoDB NULL type
await table.Users.Update(userId)
.Set(x => new UserUpdateModel
{
Name = null // Sets attribute to NULL (not skip)
})
.UpdateAsync();
// NEW: Use NoUpdate() to skip the update
await table.Users.Update(userId)
.Set(x => new UserUpdateModel
{
Name = shouldUpdate ? newName : x.Name.NoUpdate() // Skips if !shouldUpdate
})
.UpdateAsync();
Migration Guide
Replace null in conditional false branches with x.Property.NoUpdate():
// Before (1.0 breaking)
Name = condition ? value : null
// After
Name = condition ? value : x.Name.NoUpdate()
Null vs NoUpdate() vs Remove()
| Method | DynamoDB Result | Use Case |
|---|---|---|
= null | SET attr = NULL | Set attribute to DynamoDB NULL type |
.NoUpdate() | No operation | Skip updating this property conditionally |
.Remove() | REMOVE attr | Delete the attribute entirely |
Empty Conditional Expression Handling
What Changed
When all conditional clauses in a filter or condition expression evaluate to skip (e.g., all local conditions are true in OR patterns), the expression is now gracefully omitted instead of throwing a DynamoDB error.
Previous Behavior (Pre-1.0)
var status = ""; // Empty string
var category = ""; // Empty string
// OLD: Would throw DynamoDB error "Invalid FilterExpression: The expression can not be empty"
var orders = await table.Orders.Query(x => x.CustomerId == customerId)
.WithFilter(x =>
(string.IsNullOrWhiteSpace(status) || x.Status == status) &&
(string.IsNullOrWhiteSpace(category) || x.Category == category))
.ToListAsync();
New Behavior (1.0+)
var status = ""; // Empty string
var category = ""; // Empty string
// NEW: Query executes without filter (no error)
var orders = await table.Orders.Query(x => x.CustomerId == customerId)
.WithFilter(x =>
(string.IsNullOrWhiteSpace(status) || x.Status == status) &&
(string.IsNullOrWhiteSpace(category) || x.Category == category))
.ToListAsync();
// Returns all orders for customer (filter omitted)
Impact
- Low Impact for Most Users: This is typically a quality-of-life improvement
- Breaking for Error-Dependent Code: Code that relied on catching the DynamoDB error will no longer receive it
Migration Guide
If you relied on the error being thrown for validation:
// Before: Catching the error
try
{
var results = await table.Query(...)
.WithFilter(x => skipAll || x.Status == status)
.ToListAsync();
}
catch (AmazonDynamoDBException ex) when (ex.Message.Contains("empty"))
{
// Handle empty filter case
}
// After: Check conditions before querying
if (skipAll)
{
// Handle the "no filter" case explicitly
}
else
{
var results = await table.Query(...)
.WithFilter(x => x.Status == status)
.ToListAsync();
}
See Also
- DynamicTable Guide - Schema-less table access
- PartiQL Guide - SQL-like query support
- Direct SDK Requests - SDK migration patterns