Skip to main content

The Evolution of FluentDynamoDB

· 5 min read
Dan Guisinger
Founder, Oproto Inc.

FluentDynamoDB has come a long way from its origins as a small internal helper library. What began as a verbose, hand-crafted collection of table definitions, mapping code, and string-based expression builders has evolved—under real production needs and the pressure of the Kiroween 2025 competition—into a fully source-generated, strongly-typed, expression-driven DynamoDB framework. This post walks through that transformation step by step, highlighting the major design pivots, the problems each phase solved, and how FluentDynamoDB grew into the modern developer experience it offers today.

🚧 Project Status Update – Temporary Code Freeze FluentDynamoDB is currently in a temporary code freeze due to our Kiroween 2025 hackathon submission. During this period, the public repository will not receive updates, and new features will be released after judging concludes in early 2026. We're still actively engaged with the community, and we're preparing a February release that will include important fixes, improvements, and new capabilities.

⚠️ Documentation in Progress FluentDynamoDB is currently in pre-release, and we’re racing to get the documentation caught up. The library has evolved so rapidly—especially through the Kiroween 2025 development cycle—that the docs require their own major project. If something looks under-documented today, it probably is. We’re working on it.

Origins: An Internal Helper Library

FluentDynamoDB didn’t start as an ambitious, expression-translating, source-generated DynamoDB access layer. It began as a thin internal helper library designed to wrap the AWS SDK’s low-level DynamoDB requests.

Manual Table Definitions

You created a table class like this:

public class OrdersTable : DynamoDbTableBase
{
public DynamoDbIndex Gsi1 { get; } = new DynamoDbIndex("GSI1");
}

Then you would call:

await table.Query()
.Where("pk = :pk AND begins_with(sk, :prefix)")
.WithAttribute("#pk", "pk")
.WithValue(":pk", orderId)
.WithValue(":prefix", "ORDER#")
.WithFilter("#status = :status")
.WithAttribute("#status", "status")
.WithValue(":status", "pending")
.ExecuteAsync();

It worked—but it was verbose, and it didn’t handle hydration. You still needed:

  • Manual mapping code (entity → attribute map → entity)
  • Repository layers
  • Repetitive query expressions with brittle string formatting

The helper library just built request objects and executed them. Nothing more.

The Turning Point: Kiroween 2025

When Kiroween 2025 was announced, we looked at our internal code and said:

“This is still WAY too verbose.”

Our fluent builders were often longer than the raw DynamoDB SDK calls. We still had to create table classes and mapping layers. The boilerplate was enormous.

So for the competition, we decided to double down and build something magical.

Phase 1: Automatic Table Generation Through Source Generators

The first major breakthrough was the introduction of:

  • Attributes
  • Interfaces
  • A .NET Roslyn compile-time source generator

With these, we could generate:

  • The entire table class and GSI metadata
  • Key constants and field-name constants
  • Request builders for each entity
  • Hydration methods (ToDynamoDb, FromDynamoDb)

Goodbye boilerplate

Before:

public class OrderMapper
{
public Dictionary<string, AttributeValue> ToDynamo(Order o) { ... }
public Order FromDynamo(Dictionary<string, AttributeValue> map) { ... }
}

After:

// Generated automatically
order.ToDynamoDb();
Order.FromDynamoDb(map);

This removed thousands of lines of code from our internal projects.

But expression building was still stuck in the old pattern:

.Where("sk > :value").WithValue(":value", limit)

We needed something better.

Phase 2: Parameterized String Expressions

To reduce verbosity, we introduced:

C#-style string formatting for expressions

.Where("id = {0} AND sk < {1}", id, skLimit)

Need ISO-formatted dates?

.Where("timestamp > {0:o}", someDate)

This eliminated most .WithAttribute() and .WithValue() boilerplate.

Developers could finally write expressive, compact DynamoDB queries with the .NET string formatting they already know and love.

But we didn’t stop there.

Phase 3: Lambda Expression Translation (The Big One)

This was the largest engineering effort in the entire project.

We introduced:

.Where(x => x.Id == id && x.Sk < skLimit)

Lambda → DynamoDB Expression Tree → Condition/Filter/Key Expression

This also extended to Update expressions:

await table.Orders
.Update(orderId)
.Set(x => new OrderUpdateModel
{
Status = "shipped",
Version = x.Version + 1
})
.UpdateAsync();

Under the hood, this required:

  • AoT-safe expression visitors
  • Method recognition (e.g., AttributeExists(), Add())
  • Generated metadata that identifies the entity’s DynamoDB field names
  • Handling nested models, enums, nullable types, lists, sets, value types
  • Translating C# operators into DynamoDB equivalents

This is where FluentDynamoDB crossed the line from “helper” to framework.

Phase 4: Hard-Typed Convenience Methods & Entity Accessors

Once tables and metadata were generated, we added strongly-typed shortcuts.

Instead of:

await table.Query<Order>("id = {0}", id)
.ToListAsync();

You can now simply write:

await table.Orders.Query(x => x.Id == id).ToListAsync();

Or:

await table.Orders.DeleteAsync(orderId);

Or:

await table.Orders.PutAsync(order);

And you still retain the option to drop down into:

  • Raw expressions
  • Parameterized strings
  • Full fluent builders

Underlying Query() / Scan() / Put() / Update() / Delete() variants

Variant Explosion (By Design)

For Query, you have:

  • .Query()

  • .Query("expr")

  • .Query("expr", params...)

  • .Query<T>("expr")

  • .Query(x => ...)

  • Entity-scoped: .Orders.Query(...)

  • Index-scoped: .Orders.GSI1.Query(...)

All of these return builders—no work is performed until you call:

  • .ToListAsync()
  • .ToCompoundEntityAsync()

For writes, the variants include:

  • .Put()
  • .Put<T>().WithItem(item)
  • .Put(item)
  • .PutAsync(item)

You choose the level of abstraction you want.

Transactions & Batch Support

For batch and transactional operations, non-async builder forms are crucial:

await DynamoDbTransactions.Write
.Add(table.Orders.Put(item1))
.Add(table.Orders.Put(item2))
.ExecuteAsync();

Here, a standard Put builder is:

  • Interpreted
  • Transformed into a TransactionalWriteItem
  • Embedded in the final request

This applies to:

  • Put
  • Update
  • Delete
  • Condition
  • Get

We wanted transactional operations to be as natural as everything else.

Conclusion: FluentDynamoDB Today

The library has gone through massive transformation:

  • From manual tables → source-generated tables
  • From verbose expressions → parameterized formats
  • From string builders → lambda translators
  • From raw operations → entity accessors and convenience methods
  • From one-off helpers → full transactional support
  • From internal utility → open-source library

And we’re still evolving—and writing documentation as fast as possible.

If our early users say “this feels like magic,” then we’ve done our job.