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
| Layer | Responsibility |
|---|---|
| Application Code | Your business logic using the fluent API |
| Generated Code | Type-specific mappers, table classes, and accessors created at compile time |
| Runtime Library | Request builders, expression translation, and base table operations |
| AWS SDK | Direct 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 dictionaryFromDynamoDb()- Creates entity from DynamoDB itemGetPartitionKey()- Extracts partition key for groupingMatchesEntity()- Discriminator for multi-entity tablesGetEntityMetadata()- 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
| Decision | Benefit |
|---|---|
| Static abstract methods | Enable generic constraints while maintaining AOT compatibility |
| No reflection | All mapping logic is generated at compile time |
| Multi-item support | Entities 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
| Builder | Purpose |
|---|---|
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) |
BatchGetBuilder | Build BatchGetItem for multi-item retrieval |
BatchWriteBuilder | Build BatchWriteItem for bulk writes |
TransactionWriteBuilder | Build TransactWriteItems for ACID transactions |
TransactionGetBuilder | Build TransactGetItems for consistent multi-item reads |
Builder Integration
Builders integrate with generated code through:
- Entity metadata - Builders access property mappings via
IEntityMetadataProvider - Type-specific methods - Generated extension methods provide entity-aware operations
- 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:
- Collects all index definitions from all entities
- Merges indexes with the same name if they have identical configurations
- Reports errors for conflicting index configurations
- Generates index properties on the table class for all valid indexes
Index Consolidation Rules
| Scenario | Behavior |
|---|---|
| Same index name, same configuration | Single index property generated |
| Same index name, different partition key | FDDB053 diagnostic error |
| Same index name, different sort key | FDDB054 diagnostic error |
| Same index name, different type (GSI vs LSI) | FDDB055 diagnostic error |
| Index on non-default entity only | Index 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:
| Category | File Pattern | Contents |
|---|---|---|
| Entity Mappers | {Entity}.Mapper.g.cs | ToDynamoDb(), FromDynamoDb(), GetPartitionKey(), MatchesEntity() |
| Field Constants | Nested {Entity}.Fields class | Compile-time constants for DynamoDB attribute names |
| Key Builders | Nested {Entity}.Keys class | Type-safe key construction methods |
| Table Classes | {Table}Table.g.cs | Table class extending DynamoDbTableBase with entity accessors |
| Entity Accessors | Nested {Entity}Accessor class | Entity-specific operation methods |
| Extension Methods | {Entity}Extensions.g.cs | Type-specific versions of generic extension methods |
| Security Metadata | {Entity}.SecurityMetadata.g.cs | Sensitive 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>