Skip to main content

Internal Architecture

This document explains how the internal components of Oproto.FluentDynamoDb work together. Understanding this architecture helps you leverage the library effectively and troubleshoot issues.

Layered Architecture

Oproto.FluentDynamoDb uses a layered architecture combining compile-time source generation with runtime expression translation:

Layer Responsibilities

LayerResponsibility
Application CodeYour business logic using the fluent API
Generated CodeType-specific mappers, table classes, and accessors created at compile time
Runtime LibraryRequest builders, expression translation, and base table operations
AWS SDKDirect communication with DynamoDB service

Source Generator Pipeline

The source generator runs at compile time to produce type-specific code:

Generator Components

The source generator consists of four main components that work together to produce the generated code:

EntityAnalyzer

The EntityAnalyzer uses Roslyn to extract metadata from your entity classes:

  • Table name from [DynamoDbTable] attribute
  • Key structure from [PartitionKey] and [SortKey] attributes
  • Property types and mappings from class properties
  • GSI configurations from [GsiPartitionKey] and [GsiSortKey] attributes

MapperGenerator

Generates the {Entity}.Mapper.g.cs file containing:

  • ToDynamoDb() - Converts entity to DynamoDB AttributeValue dictionary
  • FromDynamoDb() - Creates entity from DynamoDB item
  • GetPartitionKey() - Extracts partition key for grouping
  • MatchesEntity() - Discriminator for multi-entity tables
  • GetEntityMetadata() - Returns property mapping information

KeysGenerator

Generates the nested {Entity}.Keys class with type-safe key construction methods:

public partial class User
{
public static partial class Keys
{
// Create full composite key
public static Dictionary<string, AttributeValue> Create(
string tenantId,
string userId)
{
return new Dictionary<string, AttributeValue>(2)
{
["tenantId"] = new AttributeValue { S = tenantId },
["userId"] = new AttributeValue { S = userId }
};
}

// Create partition key only (for queries)
public static Dictionary<string, AttributeValue> PartitionKey(string tenantId)
{
return new Dictionary<string, AttributeValue>(1)
{
["tenantId"] = new AttributeValue { S = tenantId }
};
}
}
}

FieldsGenerator

Generates the nested {Entity}.Fields class with compile-time constants:

public partial class User
{
public static partial class Fields
{
public const string TenantId = "tenantId";
public const string UserId = "userId";
public const string Name = "name";
public const string Email = "email";
public const string Status = "status";

// GSI-specific field classes
public static partial class EmailIndex
{
public const string PartitionKey = "email";
public const string SortKey = "tenantId";
}
}
}

IDynamoDbEntity Interface

The IDynamoDbEntity interface is the foundation of the source generation system. Entities marked with [DynamoDbTable] implement this interface through generated code.

public interface IDynamoDbEntity : IEntityMetadataProvider
{
// Convert entity to DynamoDB AttributeValue dictionary
static abstract Dictionary<string, AttributeValue> ToDynamoDb<TSelf>(
TSelf entity,
IDynamoDbLogger? logger = null) where TSelf : IDynamoDbEntity;

// Create entity from single DynamoDB item
static abstract TSelf FromDynamoDb<TSelf>(
Dictionary<string, AttributeValue> item,
IDynamoDbLogger? logger = null) where TSelf : IDynamoDbEntity;

// Create entity from multiple DynamoDB items (multi-item entities)
static abstract TSelf FromDynamoDb<TSelf>(
IList<Dictionary<string, AttributeValue>> items,
IDynamoDbLogger? logger = null) where TSelf : IDynamoDbEntity;

// Extract partition key for grouping items
static abstract string GetPartitionKey(Dictionary<string, AttributeValue> item);

// Determine if item matches this entity type (discriminator)
static abstract bool MatchesEntity(Dictionary<string, AttributeValue> item);
}

Key Design Decisions

DecisionBenefit
Static abstract methodsEnable generic constraints while maintaining AOT compatibility
No reflectionAll mapping logic is generated at compile time
Multi-item supportEntities can span multiple DynamoDB items (e.g., orders with line items)

Request Builder Pattern

Request builders provide the fluent API for constructing DynamoDB operations. Each operation type has a dedicated builder that integrates with generated code.

Available Builders

BuilderPurpose
QueryRequestBuilder<T>Build Query operations with key conditions and filters
GetItemRequestBuilder<T>Build GetItem operations for single-item retrieval
PutItemRequestBuilder<T>Build PutItem operations for creating/replacing items
UpdateItemRequestBuilder<T>Build UpdateItem operations with SET/REMOVE/ADD expressions
DeleteItemRequestBuilder<T>Build DeleteItem operations with optional conditions
ScanRequestBuilder<T>Build Scan operations for full table scans (requires [Scannable] attribute)
BatchGetBuilderBuild BatchGetItem for multi-item retrieval
BatchWriteBuilderBuild BatchWriteItem for bulk writes
TransactionWriteBuilderBuild TransactWriteItems for ACID transactions
TransactionGetBuilderBuild TransactGetItems for consistent multi-item reads

Builder Integration

Builders integrate with generated code through:

  1. Entity metadata - Builders access property mappings via IEntityMetadataProvider
  2. Type-specific methods - Generated extension methods provide entity-aware operations
  3. Expression translation - Lambda expressions are converted to DynamoDB syntax at runtime

Example: Builder Chain vs Direct Methods

Builder chain approach (full control):

var user = await table.Users.Get()
.WithKey("tenantId", tenantId)
.WithKey("userId", userId)
.WithProjection("name", "email", "status")
.WithConsistentRead(true)
.GetItemAsync();

Direct async method (simple cases):

var user = await table.Users.GetAsync(tenantId, userId);

DynamoDbTableBase

DynamoDbTableBase is the abstract base class for all generated table classes:

public abstract class DynamoDbTableBase : IDynamoDbTable
{
public IAmazonDynamoDB DynamoDbClient { get; }
public string Name { get; }
protected FluentDynamoDbOptions Options { get; }
protected IDynamoDbLogger Logger { get; }
protected IFieldEncryptor? FieldEncryptor { get; }

// Base operation methods
public QueryRequestBuilder<TEntity> Query<TEntity>() where TEntity : class;
public GetItemRequestBuilder<TEntity> Get<TEntity>() where TEntity : class;
public PutItemRequestBuilder<TEntity> Put<TEntity>() where TEntity : class;
public UpdateItemRequestBuilder<TEntity> Update<TEntity>() where TEntity : class;
public DeleteItemRequestBuilder<TEntity> Delete<TEntity>() where TEntity : class;
// Note: Scan requires [Scannable] attribute on the entity
public ScanRequestBuilder<TEntity> Scan<TEntity>() where TEntity : class;
}

Generated table classes extend this base and add:

  • Entity-specific accessor properties (table.Users, table.Orders)
  • Type-specific operation overloads
  • Index properties for GSI access

Multi-Entity Index Consolidation

In multi-entity tables, indexes defined on any entity are consolidated onto the generated table class. This enables you to define indexes on different entities and have them all available on the table.

How Index Consolidation Works

When multiple entities share a table, the source generator:

  1. Collects all index definitions from all entities
  2. Merges indexes with the same name if they have identical configurations
  3. Reports errors for conflicting index configurations
  4. Generates index properties on the table class for all valid indexes

Index Consolidation Rules

ScenarioBehavior
Same index name, same configurationSingle index property generated
Same index name, different partition keyFDDB053 diagnostic error
Same index name, different sort keyFDDB054 diagnostic error
Same index name, different type (GSI vs LSI)FDDB055 diagnostic error
Index on non-default entity onlyIndex property generated normally

Shared Indexes Across Entities

When multiple entities use the same index with identical configuration, a single index property is generated:

// Both entities use the same GSI with identical configuration
[DynamoDbTable("ecommerce", IsDefault = true)]
public partial class Order
{
[GsiPartitionKey("gsi1")]
[DynamoDbAttribute("gsi1pk")]
public string Gsi1Pk { get; set; } = string.Empty;
}

[DynamoDbTable("ecommerce")]
public partial class Customer
{
[GsiPartitionKey("gsi1")]
[DynamoDbAttribute("gsi1pk")]
public string Gsi1Pk { get; set; } = string.Empty;
}

// Single index property generated, can query either entity type
var orders = await table.Gsi1.Query<Order>(x => x.Gsi1Pk == "STATUS#pending").ToListAsync();
var customers = await table.Gsi1.Query<Customer>(x => x.Gsi1Pk == "REGION#us-west").ToListAsync();

Resolving Index Conflicts

If you see FDDB053, FDDB054, or FDDB055 errors, ensure all entities using the same index name have:

  • The same partition key attribute
  • The same sort key attribute (if any)
  • The same index type (GSI or LSI)
// ❌ Error FDDB053: Conflicting partition keys
[DynamoDbTable("ecommerce", IsDefault = true)]
public partial class Order
{
[GsiPartitionKey("gsi1")]
[DynamoDbAttribute("status")] // Different attribute
public string Status { get; set; }
}

[DynamoDbTable("ecommerce")]
public partial class Customer
{
[GsiPartitionKey("gsi1")]
[DynamoDbAttribute("email")] // Different attribute - conflict!
public string Email { get; set; }
}

// ✅ Fix: Use different index names
[DynamoDbTable("ecommerce", IsDefault = true)]
public partial class Order
{
[GsiPartitionKey("status-index")]
[DynamoDbAttribute("status")]
public string Status { get; set; }
}

[DynamoDbTable("ecommerce")]
public partial class Customer
{
[GsiPartitionKey("email-index")]
[DynamoDbAttribute("email")]
public string Email { get; set; }
}

// Both indexes are now available on the generated table class
var ordersByStatus = await table.StatusIndex.Query<Order>(x => x.Status == "pending").ToListAsync();
var customersByEmail = await table.EmailIndex.Query<Customer>(x => x.Email == "user@example.com").ToListAsync();

Generated Code Categories

The source generator produces several categories of code for each entity:

CategoryFile PatternContents
Entity Mappers{Entity}.Mapper.g.csToDynamoDb(), FromDynamoDb(), GetPartitionKey(), MatchesEntity()
Field ConstantsNested {Entity}.Fields classCompile-time constants for DynamoDB attribute names
Key BuildersNested {Entity}.Keys classType-safe key construction methods
Table Classes{Table}Table.g.csTable class extending DynamoDbTableBase with entity accessors
Entity AccessorsNested {Entity}Accessor classEntity-specific operation methods
Extension Methods{Entity}Extensions.g.csType-specific versions of generic extension methods
Security Metadata{Entity}.SecurityMetadata.g.csSensitive field detection for logging redaction

Discovering Generated Code

Generated files are located at:

{ProjectRoot}/obj/Debug/net8.0/generated/
└── Oproto.FluentDynamoDb.SourceGenerator/
└── Oproto.FluentDynamoDb.SourceGenerator.DynamoDbSourceGenerator/
├── User.Mapper.g.cs
├── User.Fields.g.cs
├── User.Keys.g.cs
├── UsersTable.g.cs
└── ...

To emit generated files to disk for inspection:

<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>