Skip to main content

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:

ChangeAction RequiredRisk Level
Index attribute API redesignReplace [GlobalSecondaryIndex]/[LocalSecondaryIndex] with new attributesHigh
DynamoDbTableBase removalUpdate direct type references (rare)Low
NoUpdate() for conditional skipReplace null with x.Prop.NoUpdate()Medium
Empty conditional expression handlingReview error-dependent code (rare)Low
[Queryable] attribute removedRemove 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 AttributeNew 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:

AttributeOptional 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:

CodeDescription
DYNDB120GSI sort key without corresponding partition key
DYNDB121Duplicate GSI partition keys for same index
DYNDB122Duplicate GSI sort keys for same index
DYNDB123Duplicate LSI sort keys for same index
DYNDB124-126Empty/whitespace index name
DYNDB127Same index name used as both GSI and LSI

Find-and-Replace Pattern

For most codebases, a simple find-and-replace handles the migration:

  1. Replace [GlobalSecondaryIndex(" with [GsiPartitionKey(" (then fix sort keys manually)
  2. Find remaining IsPartitionKey = true and remove them
  3. Find IsSortKey = true entries and change [GsiPartitionKey to [GsiSortKey
  4. Replace [LocalSecondaryIndex(" with [LsiSortKey("
  5. Remove any remaining , IsPartitionKey = true or , IsSortKey = true fragments

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 DynamoDbTableBase as 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

  1. Visibility Control: Generated table classes can now control the visibility of all operations via attributes like [GenerateAccessors]
  2. Cleaner Architecture: No inheritance hierarchy means simpler code and better AOT compatibility
  3. 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:

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()

MethodDynamoDB ResultUse Case
= nullSET attr = NULLSet attribute to DynamoDB NULL type
.NoUpdate()No operationSkip updating this property conditionally
.Remove()REMOVE attrDelete 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