Skip to content

How It Works

DynamoMapper is an incremental source generator that produces high-performance, type-safe mapping code at compile time. This guide explains the core concepts, architecture, and design decisions that make DynamoMapper both powerful and focused.

Core Philosophy

DynamoMapper is built on three fundamental principles:

  1. Domain Stays Clean - Your domain models remain free of persistence attributes
  2. Compile-Time Safety - All mapping code is generated and validated at compile time
  3. DynamoDB-Focused - Single-purpose library for DynamoDB attribute mapping

Mapping Scope

DynamoMapper is a DynamoDB-specific mapping library, not a general-purpose object mapper.

What DynamoMapper Does

DynamoMapper supports exactly two mapping directions:

// ToItem: Domain model → DynamoDB item
Dictionary<string, AttributeValue> ToItem(T source);

// FromItem: DynamoDB item → Domain model
T FromItem(Dictionary<string, AttributeValue> item);

What DynamoMapper Does NOT Do

Unlike general-purpose mappers (Mapperly, AutoMapper, etc.), DynamoMapper does not support:

  • Object-to-object mapping (e.g., DTO → Entity)
  • Collection transformations
  • Projection mapping
  • Runtime mapping configuration
  • Multi-step mapping pipelines

Why this matters: This focused scope enables DynamoMapper to optimize specifically for DynamoDB patterns like single-table design, PK/SK composition, and attribute bags without compromising on performance or clarity.

Source Generation Overview

DynamoMapper uses .NET's IIncrementalGenerator API to analyze your code at compile time and generate mapping implementations.

The Generation Pipeline

  1. Discovery Phase
  2. Locate classes marked with [DynamoMapper]
  3. Find partial mapping methods (ToItem, FromItem)
  4. Collect configuration attributes

  5. Analysis Phase

  6. Resolve target entity types
  7. Analyze properties (public, readable, writable)
  8. Apply naming conventions
  9. Validate converters and hooks

  10. Code Generation Phase

  11. Generate ToItem implementation
  12. Generate FromItem implementation
  13. Emit diagnostics for configuration errors

  14. Compilation Phase

  15. Generated code is compiled with your project
  16. No runtime dependencies beyond AWS SDK types

Mapper Anatomy

Basic Structure

using Amazon.DynamoDBv2.Model;

[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
public static partial class ProductMapper
{
    // Partial method declarations (you provide)
    public static partial Dictionary<string, AttributeValue> ToItem(Product source);
    public static partial Product FromItem(Dictionary<string, AttributeValue> item);

    // Generated implementations (DynamoMapper provides)
    // - ToItem implementation
    // - FromItem implementation
}

Why Static Partial Classes?

  • Static - No instance state, no object allocation overhead
  • Partial - You declare, generator implements
  • Type-safe - Compiler validates everything

Generated Code Example

Given this domain model:

public class Product
{
    public Guid ProductId { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

[DynamoMapper(Convention = DynamoNamingConvention.CamelCase)]
public static partial class ProductMapper
{
    public static partial Dictionary<string, AttributeValue> ToItem(Product source);
    public static partial Product FromItem(Dictionary<string, AttributeValue> item);
}

DynamoMapper generates:

//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

#nullable enable

using DynamoMapper.Runtime;
using System.Collections.Generic;
using Amazon.DynamoDBv2.Model;

namespace MyApp;

public static partial class ProductMapper
{
    [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")]
    public static partial global::System.Collections.Generic.Dictionary<string, global::Amazon.DynamoDBv2.Model.AttributeValue> ToItem(global::MyApp.Product source) =>
        new Dictionary<string, AttributeValue>(3)
            .SetGuid("productId", source.ProductId, false, true)
            .SetString("name", source.Name, false, true)
            .SetDecimal("price", source.Price, false, true);

    [global::System.CodeDom.Compiler.GeneratedCode("DynamoMapper", "REPLACED")]
    public static partial global::MyApp.Product FromItem(global::System.Collections.Generic.Dictionary<string, global::Amazon.DynamoDBv2.Model.AttributeValue> item)
    {
        var product = new global::MyApp.Product
        {
            ProductId = item.GetGuid("productId", Requiredness.InferFromNullability),
            Name = item.GetString("name", Requiredness.InferFromNullability),
            Price = item.GetDecimal("price", Requiredness.InferFromNullability),
        };
        return product;
    }
}

Object Construction (FromItem)

When generating FromItem, DynamoMapper chooses between:

  • Property-based construction: new T { Prop = ..., ... }
  • Constructor-based construction: new T(arg1, arg2, ...) (optionally with an object initializer)

Constructor-based construction is used for records/record structs with primary constructors and for classes where read-only properties must be populated through a constructor. You can also explicitly choose a constructor using [DynamoMapperConstructor].

See Basic Mapping for the full selection rules.

Key Characteristics

  • Direct property access - No reflection
  • Culture-invariant parsing - Consistent number handling
  • Capacity hints - Reduces dictionary reallocations
  • Clear, readable code - Easy to debug

Configuration Model

DynamoMapper uses a layered configuration model:

1. Mapper-Level Defaults

[DynamoMapper(
    Convention = DynamoNamingConvention.CamelCase,
    OmitNullStrings = true,
    DateTimeFormat = "O")]

2. Property-Level Overrides

[DynamoMapper]
[DynamoField(nameof(Product.Name), Required = true)]
[DynamoField(nameof(Product.Description), OmitIfNull = true, OmitIfEmptyString = true)]
public static partial class ProductMapper
{
    public static partial Dictionary<string, AttributeValue> ToItem(Product source);
    public static partial Product FromItem(Dictionary<string, AttributeValue> item);
}

3. Converters

Custom conversions are currently supported via static methods on the mapper class:

[DynamoMapper]
[DynamoField(nameof(Product.Status),
    ToMethod = nameof(ToStatus),
    FromMethod = nameof(FromStatus))]
public static partial class ProductMapper
{
    public static partial Dictionary<string, AttributeValue> ToItem(Product source);
    public static partial Product FromItem(Dictionary<string, AttributeValue> item);

    static AttributeValue ToStatus(ProductStatus status) => new() { S = status.ToString() };
    static ProductStatus FromStatus(AttributeValue value) => Enum.Parse<ProductStatus>(value.S);
}

4. Customization Hooks

static partial void AfterToItem(Product source, Dictionary<string, AttributeValue> item)
{
    item["pk"] = new AttributeValue { S = $"PRODUCT#{source.ProductId}" };
    item["sk"] = new AttributeValue { S = "METADATA" };
}

Type System

Supported Types (Phase 1)

DynamoMapper natively supports:

.NET Type DynamoDB Type Notes
string S (String)
int, long, decimal, double N (Number) Culture-invariant
bool BOOL
Guid S ToString/Parse
DateTime, DateTimeOffset, TimeSpan S ISO-8601 / constant format
enum S String name
Nullable variants S/N/BOOL Null checks generated
Collections L/M/SS/NS/BS Lists, maps, and sets of supported element types

Custom Types

Use static conversion methods for custom types:

public class OrderStatus
{
    public string Name { get; }
    // ... enumeration pattern
}

[DynamoMapper]
[DynamoField(nameof(Order.Status), ToMethod = nameof(ToStatus), FromMethod = nameof(FromStatus))]
public static partial class OrderMapper
{
    public static partial Dictionary<string, AttributeValue> ToItem(Order source);
    public static partial Order FromItem(Dictionary<string, AttributeValue> item);

    static AttributeValue ToStatus(OrderStatus value) => new() { S = value.Name };
    static OrderStatus FromStatus(AttributeValue value) => OrderStatus.FromName(value.S);
}

Naming Conventions

DynamoMapper applies naming conventions to property names:

// .NET Property    → DynamoDB Attribute Name

// Exact
ProductId          ProductId

// CamelCase (recommended)
ProductId          productId
CustomerId         customerId

// SnakeCase
ProductId          product_id
CustomerId         customer_id

Override per-property:

[DynamoField(nameof(Product.ProductId), AttributeName = "id")]

Extensibility Model

Hooks: First-Class Extension Points

Hooks enable DynamoDB-specific patterns without compromising the focused mapping scope:

// Before property mapping
static partial void BeforeToItem(Product source, Dictionary<string, AttributeValue> item);

// After property mapping - most common
static partial void AfterToItem(Product source, Dictionary<string, AttributeValue> item);

// Before deserialization
static partial void BeforeFromItem(Dictionary<string, AttributeValue> item);

// After object construction
static partial void AfterFromItem(Dictionary<string, AttributeValue> item, ref Product entity);

Common use cases: - PK/SK composition for single-table design - Record type discrimination - TTL attributes - Unmapped attribute bags - Post-hydration normalization

Why Hooks Instead of Pipeline Stages?

DynamoMapper uses hooks instead of a general mapping pipeline because:

  1. Focused scope - Only two mapping directions, not arbitrary transformations
  2. Zero overhead - Unimplemented hooks compile away completely
  3. Type safety - Statically bound, no reflection
  4. DynamoDB patterns - Designed specifically for single-table design

Diagnostics

DynamoMapper provides comprehensive compile-time diagnostics:

Error Classes

  • DM000x - Mapping/usage errors (unsupported types, invalid overrides)
  • DM010x - Mapper discovery issues (e.g., missing mapper methods)

Example Diagnostic

error DM0201: Static conversion method 'ToStatus' not found on mapper 'OrderMapper'
  Location: OrderMapper.cs(12,5)
  Fix: Add static method: static AttributeValue ToStatus(OrderStatus value)

Performance Characteristics

Compile-Time

  • Incremental generation - Only affected mappers regenerate
  • Deterministic output - Same input always produces same code
  • Fast compilation - No expensive analysis

Runtime

  • Zero reflection - All types resolved at compile time
  • Minimal allocations - Dictionary capacity hints, no LINQ
  • Inlined conversions - Simple type conversions inlined by JIT
  • Culture-invariant parsing - Consistent, fast number handling

Benchmarks (Typical)

Operation Time Allocations
ToItem (5 properties) ~50ns 1 (dictionary)
FromItem (5 properties) ~100ns 1 (entity)
With hooks +5-10ns 0 additional

Design Constraints

Understanding what DynamoMapper intentionally does NOT support:

No Runtime Configuration

// Not supported - all configuration is compile-time
mapper.Configure(x => x.Property("Name").Ignore());

Why: Compile-time configuration enables: - Zero reflection - Faster runtime performance - Compile-time validation

No Dynamic Mapping

// Not supported - all types known at compile time
var mapper = MapperFactory.Create(type1, type2);

Why: Type-safe, analyzable, debuggable generated code.

No Nested Object Mapping (Phase 1)

// Not supported in Phase 1
public class Order
{
    public Customer Customer { get; set; }  // Nested object
}

Why: Phase 1 focuses on scalar properties. Use static conversion methods for complex types.

Phase 2: DSL Configuration

Phase 2 is planned and will add an optional fluent DSL while maintaining all Phase 1 capabilities:

[DynamoMapper]
public static partial class ProductMapper
{
    public static partial Dictionary<string, AttributeValue> ToItem(Product source);
    public static partial Product FromItem(Dictionary<string, AttributeValue> item);

    static partial void Configure(DynamoMapBuilder<Product> map)
    {
        map.Naming(DynamoNamingConvention.CamelCase);

        map.Property(x => x.Status)
           .Using(nameof(ToStatus), nameof(FromStatus));

        map.Ignore(x => x.ComputedProperty);
    }
}

Key points: - DSL is optional - attributes remain fully supported - DSL is compile-time only - no runtime evaluation - DSL and attributes can coexist - DSL takes precedence

Best Practices

  1. Use default conventions - Override only when necessary
  2. Keep domain clean - No DynamoDB concerns in domain models
  3. Use hooks for DynamoDB patterns - PK/SK, TTL, record types
  4. Use static methods for conversions - Simple, co-located, explicit

See Also