Skip to content

Support mapper-level reusable converters for exact CLR types #110

@j-d-ha

Description

@j-d-ha

Summary

Add mapper-level converter registration so a DynamoMapper can reuse custom T <-> AttributeValue conversions anywhere an exact CLR type appears in that mapper's object graph.

This is a compile-time source-generator feature. It should complement the existing field-level ToMethod / FromMethod escape hatch, not replace it.

This issue supersedes #22. That earlier issue describes a field-level Converter = typeof(...) design, but the desired design here is mapper-level registration with directional resolution and fallback to existing code paths.

Problem

Today, reusable converter types are not supported.

The current workaround is field-specific static methods configured with ToMethod / FromMethod, which works for one property at a time but does not solve the "always use this conversion for this CLR type everywhere in this mapper" case.

That becomes painful when the same unsupported type appears in multiple places, for example:

  • top-level properties
  • nested object properties
  • nullable wrappers
  • list elements
  • dictionary values

Example:

public readonly record struct OrderStatus(string Value);

If a mapper wants to store OrderStatus as a DynamoDB string everywhere it appears, there is currently no mapper-level reusable way to register that conversion once and have it apply consistently across the mapper graph.

Current implementation context

The current implementation supports only field-level custom static methods:

  • DynamoFieldAttribute exposes ToMethod / FromMethod, but no converter-type property.
  • The generator short-circuits custom handling only when both field-level methods are present.
  • Unsupported member types currently fall back to existing diagnostics such as DM0001.

This issue should introduce a separate reusable converter mechanism rather than extending DynamoField for per-field converter types.

Proposed runtime API

Add a repeated mapper-level attribute:

[DynamoMapper]
[DynamoConverter(typeof(OrderStatusConverter))]
public static partial class OrderMapper
{
    public static partial Dictionary<string, AttributeValue> ToItem(Order order);
    public static partial Order FromItem(Dictionary<string, AttributeValue> item);
}

Add directional converter interfaces:

public interface IDynamoToConverter<T>
{
    AttributeValue ToAttributeValue(T value);
}

public interface IDynamoFromConverter<T>
{
    T FromAttributeValue(AttributeValue value);
}

public interface IDynamoConverter<T> : IDynamoToConverter<T>, IDynamoFromConverter<T>;

Example converter:

public sealed class OrderStatusConverter : IDynamoConverter<OrderStatus>
{
    public AttributeValue ToAttributeValue(OrderStatus value) => new() { S = value.Value };

    public OrderStatus FromAttributeValue(AttributeValue value) => new(value.S);
}

Resolution rules

Resolution must be directional.

For ToItem:

  1. field-level ToMethod / FromMethod override wins for that property
  2. otherwise use a registered IDynamoToConverter<T> for the exact underlying CLR type
  3. otherwise use the existing built-in To path
  4. otherwise emit a generator error for that property

For FromItem:

  1. field-level ToMethod / FromMethod override wins for that property
  2. otherwise use a registered IDynamoFromConverter<T> for the exact underlying CLR type
  3. otherwise use the existing built-in From path
  4. otherwise emit a generator error for that property

Important details:

  • matching is exact type match only
  • a registered converter wins over built-in handling for the direction it supports
  • the opposite direction may still fall back to built-in handling if no converter exists for that side
  • converters apply to the entire mapper object graph, including nested object properties

Nullability

Do not introduce new null semantics.

Converters should plug into the existing null handling paths.

That means:

  • registration is for the underlying non-null type, e.g. OrderStatus
  • OrderStatus? should flow through the existing nullable handling path
  • no separate converter registration for OrderStatus? is required

Supported composition

A registered converter should work anywhere the overall shape is already supported today, including:

  • scalar properties
  • nullable scalar properties
  • nested object properties
  • List<T>, arrays, IEnumerable<T>
  • Dictionary<string, T>

A converter must not relax existing shape rules.

Examples:

  • List<StrongId> should work if StrongId has a converter
  • Dictionary<string, StrongId> should work if StrongId has a converter
  • List<List<StrongId>> remains unsupported if nested lists are unsupported today
  • currently invalid nested/container shapes remain invalid
  • HashSet<T> / ISet<T> should not gain converter-based support in v1

Converter validation rules

Registered converter types should be validated at generator time.

A valid converter type must be:

  • concrete
  • non-abstract
  • accessible from generated mapper code
  • constructible via an accessible parameterless constructor
  • implementing at least one valid converter interface

Interface rules:

  • fixed method names are required:
    • ToAttributeValue(T value)
    • FromAttributeValue(AttributeValue value)
  • a converter class may target exactly one underlying CLR type
  • all implemented converter interfaces on the class must refer to that same CLR type

These should be generator errors:

  • type implements no valid converter interface
  • type mixes converter interfaces for different CLR types
  • type is inaccessible
  • type is abstract
  • type has no accessible parameterless constructor

Multiple registrations

Multiple registrations for the same CLR type should be allowed only when their directions do not overlap.

Valid:

[DynamoConverter(typeof(OrderStatusToConverter))]
[DynamoConverter(typeof(OrderStatusFromConverter))]

when one implements only IDynamoToConverter<OrderStatus> and the other implements only IDynamoFromConverter<OrderStatus>.

Invalid:

  • two registrations both implementing IDynamoToConverter<OrderStatus>
  • two registrations both implementing IDynamoFromConverter<OrderStatus>

Direction overlap for the same CLR type should be a generator error.

Valid but unused registrations should be ignored silently.

Code generation expectations

Generated code should instantiate converters with new MyConverter() and cache them in static fields on the generated mapper.

This implies v1 supports parameterless, stateless converter types only.

Diagnostics

This feature should distinguish between:

Registration diagnostics

Bad converter registration should fail on the registration/type, for example:

  • invalid converter interface shape
  • multiple CLR types in one converter class
  • direction overlap for the same CLR type
  • inaccessible converter type
  • missing accessible parameterless constructor

Usage diagnostics

If a converter registration is valid, but a specific property still cannot be mapped in one direction because:

  • no converter exists for that direction, and
  • built-in mapping also cannot handle it

then the diagnostic should be reported on the property usage, not the registration.

Why this is mapper-level only

Field-level converter registration is not needed for this feature because DynamoMapper already has field-specific customization via ToMethod / FromMethod.

This proposal covers the separate use case of:

register one reusable conversion for a CLR type and use it everywhere that type appears in this mapper.

Out of scope for v1

  • field-level converter registration
  • DI-based activation
  • runtime registration
  • assembly scanning
  • inheritance/interface lookup
  • open generic converters
  • stateful converters
  • changing existing container/set shape rules
  • converter support for DynamoDB set handling (HashSet<T>, ISet<T>)

Acceptance criteria

  • A mapper can register one or more converter types with [DynamoConverter(typeof(...))]
  • A registered converter applies everywhere the exact underlying CLR type appears in that mapper graph
  • Field-level ToMethod / FromMethod still overrides mapper-level converter registration
  • One-way mappers can use one-way converters
  • Two-way mappers can mix converter-based handling in one direction with built-in handling in the other direction
  • Nullable wrappers use existing null handling and do not require nullable-specific registrations
  • Supported container shapes can compose with converters for element/value types
  • Unsupported shapes remain unsupported
  • Direction overlap for the same CLR type produces a generator error
  • Invalid converter registrations produce generator errors
  • Valid but unused registrations are ignored silently
  • The issue body contains enough design detail to implement the feature without re-deciding the core behavior

Suggested implementation areas

Likely implementation work spans:

  • runtime API additions for DynamoConverterAttribute and converter interfaces
  • generator parsing/binding of mapper-level converter registrations
  • converter validation and diagnostics
  • directional type-strategy resolution updates
  • code rendering for cached converter usage
  • verify tests for happy paths, precedence, direction overlap, fallback, and diagnostics
  • documentation updates for the new mapper-level converter model

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions