diff --git a/.claude/hooks/format.py b/.claude/hooks/format.py
new file mode 100644
index 00000000..f4c278a1
--- /dev/null
+++ b/.claude/hooks/format.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.14"
+# ///
+
+import json
+import os
+import sys
+import subprocess
+
+DOTSETTINGS_FILE_NAME = "LayeredCraft.DynamoMapper.sln.DotSettings"
+
+
+def main():
+ try:
+ # Read JSON input from stdin
+ input_data = json.load(sys.stdin)
+
+ cwd = input_data["cwd"]
+ eddited_input = input_data["tool_input"]["file_path"]
+ _, ext = os.path.splitext(eddited_input)
+
+ print(f"Running code cleanup on: '{eddited_input}' in directory: '{cwd}'")
+
+ match ext.lower():
+ case ".cs" | ".csx" | ".csproj" | ".props":
+ csharp(cwd, eddited_input)
+ case ".md":
+ markdown(cwd, eddited_input)
+ case _:
+ print(f"Skipping unsupported file type: '{ext}'")
+
+ sys.exit(0)
+
+ except json.JSONDecodeError:
+ # Handle JSON decode errors gracefully
+ sys.exit(0)
+ except Exception:
+ # Exit cleanly on any other error
+ sys.exit(0)
+
+
+def csharp(cwd: str, eddited_input: str) -> None:
+ print("======================================")
+
+ print("Running C# code cleanup...")
+
+ result = subprocess.run(
+ [
+ "dotnet",
+ "tool",
+ "run",
+ "jb",
+ "cleanupcode",
+ "--profile=Built-in: Reformat Code",
+ f"--include={eddited_input}",
+ f"--settings={os.getenv('DOTSETTINGS_FILE')}",
+ ],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ )
+
+ print(result.stdout)
+
+ print("======================================")
+
+
+def markdown(cwd: str, eddited_input: str) -> None:
+ print("======================================")
+
+ print("Running Markdown code cleanup...")
+
+ result = subprocess.run(
+ [
+ "uvx",
+ "--with",
+ "mdformat-mkdocs",
+ "--with",
+ "mdformat-frontmatter",
+ "mdformat",
+ eddited_input,
+ "--exclude",
+ ".agents/**",
+ "--exclude",
+ ".claude/**",
+ "--exclude",
+ ".opencode/**"
+ ],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ )
+
+ print(result.stdout)
+
+ print("======================================")
+
+
+if __name__ == "__main__":
+ print("Running format_cs.py hook...")
+ main()
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 00000000..836a1fd6
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,19 @@
+{
+ "respectGitignore": false,
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Write|Edit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "uv run $CLAUDE_PROJECT_DIR/.claude/hooks/format.py"
+ }
+ ]
+ }
+ ]
+ },
+ "env": {
+ "DOTSETTINGS_FILE": "LayeredCraft.DynamoMapper.sln.DotSettings"
+ }
+}
diff --git a/.claude/skills/dynamo-mapper b/.claude/skills/dynamo-mapper
new file mode 120000
index 00000000..8b801859
--- /dev/null
+++ b/.claude/skills/dynamo-mapper
@@ -0,0 +1 @@
+../../skills/dynamo-mapper
\ No newline at end of file
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
new file mode 100644
index 00000000..8ec3ee6f
--- /dev/null
+++ b/.opencode/opencode.jsonc
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "instructions": [
+ "CLAUDE.local.md"
+ ],
+ "formatter": {
+ "cs-jb-formatter": {
+ "command": [
+ "dotnet",
+ "tool",
+ "run",
+ "jb",
+ "cleanupcode",
+ "--profile=Built-in: Reformat Code",
+ "--include=$FILE",
+ "--settings=LayeredCraft.DynamoMapper.sln.DotSettings"
+ ],
+ "extensions": [
+ ".cs",
+ ".props",
+ ".csproj"
+ ]
+ },
+ "mdformat": {
+ "command": [
+ "uvx",
+ "--with",
+ "mdformat-mkdocs",
+ "--with",
+ "mdformat-frontmatter",
+ "mdformat",
+ "$FILE",
+ "--exclude",
+ ".agents/**",
+ "--exclude",
+ ".claude/**",
+ "--exclude",
+ ".opencode/**"
+ ],
+ "extensions": [
+ ".md"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b5d7ce6a..57a67798 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,26 +9,29 @@
-
-
-
-
-
+
+
+
+
-
+
-
-
+
+
+
+
-
+
+
-
+
+
-
\ No newline at end of file
+
diff --git a/LayeredCraft.DynamoMapper.sln.DotSettings b/LayeredCraft.DynamoMapper.sln.DotSettings
index 6f48e3e8..ca99829e 100644
--- a/LayeredCraft.DynamoMapper.sln.DotSettings
+++ b/LayeredCraft.DynamoMapper.sln.DotSettings
@@ -1,143 +1,62 @@
-
- DO_NOT_SHOW
- <?xml version="1.0" encoding="utf-16"?><Profile name="Full Custom Cleanup"><CppReformatCode>True</CppReformatCode><FSharpReformatCode>True</FSharpReformatCode><ShaderLabReformatCode>True</ShaderLabReformatCode><XMLReformatCode>True</XMLReformatCode><VBReformatCode>True</VBReformatCode><CSReformatCode>True</CSReformatCode><CSharpReformatComments>True</CSharpReformatComments><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" ArrangeArgumentsStyle="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeCodeBodyStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CppCodeStyleCleanupDescriptor ArrangeBraces="True" ArrangeAuto="True" ArrangeFunctionDeclarations="True" ArrangeNestedNamespaces="True" ArrangeTypeAliases="True" ArrangeCVQualifiers="True" ArrangeSlashesInIncludeDirectives="True" ArrangeOverridingFunctions="True" SortDefinitions="True" SortIncludeDirectives="True" SortMemberInitializers="True" /><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><VBOptimizeImports>True</VBOptimizeImports><VBShortenReferences>True</VBShortenReferences><Xaml.RemoveRedundantNamespaceAlias>True</Xaml.RemoveRedundantNamespaceAlias><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CppAddTypenameTemplateKeywords>True</CppAddTypenameTemplateKeywords><CppCStyleToStaticCastDescriptor>True</CppCStyleToStaticCastDescriptor><CppRedundantDereferences>True</CppRedundantDereferences><CppDeleteRedundantAccessSpecifier>True</CppDeleteRedundantAccessSpecifier><CppRemoveCastDescriptor>True</CppRemoveCastDescriptor><CppRemoveElseKeyword>True</CppRemoveElseKeyword><CppShortenQualifiedName>True</CppShortenQualifiedName><CppDeleteRedundantSpecifier>True</CppDeleteRedundantSpecifier><CppRemoveStatement>True</CppRemoveStatement><CppDeleteRedundantTypenameTemplateKeywords>True</CppDeleteRedundantTypenameTemplateKeywords><CppReplaceExpressionWithBooleanConst>True</CppReplaceExpressionWithBooleanConst><CppMakeIfConstexpr>True</CppMakeIfConstexpr><CppMakePostfixOperatorPrefix>True</CppMakePostfixOperatorPrefix><CppMakeVariableConstexpr>True</CppMakeVariableConstexpr><CppChangeSmartPointerToMakeFunction>True</CppChangeSmartPointerToMakeFunction><CppReplaceThrowWithRethrowFix>True</CppReplaceThrowWithRethrowFix><CppTypeTraitAliasDescriptor>True</CppTypeTraitAliasDescriptor><CppRemoveRedundantConditionalExpressionDescriptor>True</CppRemoveRedundantConditionalExpressionDescriptor><CppSimplifyConditionalExpressionDescriptor>True</CppSimplifyConditionalExpressionDescriptor><CppReplaceExpressionWithNullptr>True</CppReplaceExpressionWithNullptr><CppReplaceTieWithStructuredBindingDescriptor>True</CppReplaceTieWithStructuredBindingDescriptor><CppUseAssociativeContainsDescriptor>True</CppUseAssociativeContainsDescriptor><CppUseEraseAlgorithmDescriptor>True</CppUseEraseAlgorithmDescriptor><CppJoinDeclarationAndAssignmentDescriptor>True</CppJoinDeclarationAndAssignmentDescriptor><CppMakeClassFinal>True</CppMakeClassFinal><CppMakeLocalVarConstDescriptor>True</CppMakeLocalVarConstDescriptor><CppMakeMethodConst>True</CppMakeMethodConst><CppMakeMethodStatic>True</CppMakeMethodStatic><CppMakePtrOrRefParameterConst>True</CppMakePtrOrRefParameterConst><CppMakeParameterConst>True</CppMakeParameterConst><CppPassValueParameterByConstReference>True</CppPassValueParameterByConstReference><CppRemoveElaboratedTypeSpecifierDescriptor>True</CppRemoveElaboratedTypeSpecifierDescriptor><CppRemoveRedundantLambdaParameterListDescriptor>True</CppRemoveRedundantLambdaParameterListDescriptor><CppRemoveRedundantMemberInitializerDescriptor>True</CppRemoveRedundantMemberInitializerDescriptor><CppRemoveRedundantParentheses>True</CppRemoveRedundantParentheses><CppRemoveTemplateArgumentsDescriptor>True</CppRemoveTemplateArgumentsDescriptor><CppRemoveUnreachableCode>True</CppRemoveUnreachableCode><CppRemoveUnusedIncludes>True</CppRemoveUnusedIncludes><CppRemoveUnusedLambdaCaptures>True</CppRemoveUnusedLambdaCaptures><CppReplaceIfWithIfConsteval>True</CppReplaceIfWithIfConsteval><RemoveCodeRedundanciesVB>True</RemoveCodeRedundanciesVB><VBMakeFieldReadonly>True</VBMakeFieldReadonly><Xaml.RedundantFreezeAttribute>True</Xaml.RedundantFreezeAttribute><Xaml.RemoveRedundantModifiersAttribute>True</Xaml.RemoveRedundantModifiersAttribute><Xaml.RemoveRedundantNameAttribute>True</Xaml.RemoveRedundantNameAttribute><Xaml.RemoveRedundantResource>True</Xaml.RemoveRedundantResource><Xaml.RemoveRedundantCollectionProperty>True</Xaml.RemoveRedundantCollectionProperty><Xaml.RemoveRedundantAttachedPropertySetter>True</Xaml.RemoveRedundantAttachedPropertySetter><Xaml.RemoveRedundantStyledValue>True</Xaml.RemoveRedundantStyledValue><Xaml.RemoveForbiddenResourceName>True</Xaml.RemoveForbiddenResourceName><Xaml.RemoveRedundantGridDefinitionsAttribute>True</Xaml.RemoveRedundantGridDefinitionsAttribute><Xaml.RemoveRedundantUpdateSourceTriggerAttribute>True</Xaml.RemoveRedundantUpdateSourceTriggerAttribute><Xaml.RemoveRedundantBindingModeAttribute>True</Xaml.RemoveRedundantBindingModeAttribute><Xaml.RemoveRedundantGridSpanAttribut>True</Xaml.RemoveRedundantGridSpanAttribut><IDEA_SETTINGS><profile version="1.0">
- <option name="myName" value="Full Custom Cleanup" />
- <inspection_tool class="ConditionalExpressionWithIdenticalBranchesJS" enabled="true" level="WARNING" enabled_by_default="true" />
- <inspection_tool class="ES6ShorthandObjectProperty" enabled="true" level="WARNING" enabled_by_default="true" />
- <inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="true" level="WARNING" enabled_by_default="true" />
- <inspection_tool class="JSRemoveUnnecessaryParentheses" enabled="true" level="WARNING" enabled_by_default="true" />
- <inspection_tool class="UnterminatedStatementJS" enabled="true" level="WARNING" enabled_by_default="true" />
-</profile></IDEA_SETTINGS><RIDER_SETTINGS><profile>
- <Language id="CSS">
- <Reformat>true</Reformat>
- <Rearrange>false</Rearrange>
- </Language>
- <Language id="EditorConfig">
- <Reformat>true</Reformat>
- </Language>
- <Language id="HCL">
- <Reformat>true</Reformat>
- </Language>
- <Language id="HTML">
- <Reformat>true</Reformat>
- <OptimizeImports>true</OptimizeImports>
- <Rearrange>false</Rearrange>
- </Language>
- <Language id="HTTP Request">
- <Reformat>true</Reformat>
- </Language>
- <Language id="Handlebars">
- <Reformat>true</Reformat>
- </Language>
- <Language id="Ini">
- <Reformat>true</Reformat>
- </Language>
- <Language id="JSON">
- <Reformat>true</Reformat>
- </Language>
- <Language id="Jade">
- <Reformat>true</Reformat>
- </Language>
- <Language id="JavaScript">
- <Reformat>true</Reformat>
- <OptimizeImports>true</OptimizeImports>
- <Rearrange>false</Rearrange>
- </Language>
- <Language id="Markdown">
- <Reformat>false</Reformat>
- </Language>
- <Language id="Mermaid">
- <Reformat>true</Reformat>
- </Language>
- <Language id="PowerShell">
- <Reformat>true</Reformat>
- </Language>
- <Language id="Properties">
- <Reformat>true</Reformat>
- </Language>
- <Language id="RELAX-NG">
- <Reformat>true</Reformat>
- </Language>
- <Language id="Razor">
- <Reformat>true</Reformat>
- </Language>
- <Language id="SQL">
- <Reformat>true</Reformat>
- </Language>
- <Language id="TOML">
- <Reformat>true</Reformat>
- </Language>
- <Language id="VueExpr">
- <Reformat>true</Reformat>
- </Language>
- <Language id="XML">
- <Reformat>true</Reformat>
- <OptimizeImports>true</OptimizeImports>
- <Rearrange>false</Rearrange>
- </Language>
- <Language id="liquid">
- <Reformat>false</Reformat>
- </Language>
- <Language id="yaml">
- <Reformat>true</Reformat>
- </Language>
-</profile></RIDER_SETTINGS><CSharpFormatDocComments>True</CSharpFormatDocComments></Profile>
- Built-in: Full Cleanup
- NotRequired
- NotRequired
- NotRequired
- NotRequired
- ExpressionBody
- ExpressionBody
- ExpressionBody
- True
- False
- False
+
+ NotRequiredForBoth
+ True
+ False
+ False
+ False
+ TOGETHER_SAME_LINE
+ True
+ True
+ True
+ True
+ INSIDE
+ 1
+ 1
+ False
+ False
False
False
False
False
+ False
+ False
False
False
- True
- 1
- ALWAYS
- ALWAYS
- ALWAYS
+ 0
+ True
+ NEVER
+ NEVER
+ ALWAYS
False
NEVER
- False
- False
- ALWAYS_IF_MULTILINE
+ STRONGLY
+ False
True
True
+ True
CHOP_IF_LONG
- False
- True
+ CHOP_IF_LONG
+ True
+ True
True
+ True
True
- True
- True
+ True
+ CHOP_IF_LONG
+ CHOP_IF_LONG
CHOP_IF_LONG
True
CHOP_IF_LONG
CHOP_IF_LONG
- 2
- False
- False
- 2
- ByFirstAttr
- True
- False
- True
- False
- False
- True
- False
- False
- True
+ CHOP_IF_LONG
+ 100
True
True
True
- True
- True
\ No newline at end of file
+ True
\ No newline at end of file
diff --git a/LayeredCraft.DynamoMapper.slnx b/LayeredCraft.DynamoMapper.slnx
index 86197600..0339df72 100644
--- a/LayeredCraft.DynamoMapper.slnx
+++ b/LayeredCraft.DynamoMapper.slnx
@@ -79,11 +79,14 @@
+
+
diff --git a/skills/dynamo-mapper/SKILL.md b/skills/dynamo-mapper/SKILL.md
index 565f26fb..5407c37b 100644
--- a/skills/dynamo-mapper/SKILL.md
+++ b/skills/dynamo-mapper/SKILL.md
@@ -10,7 +10,8 @@ Use this skill when generating or explaining DynamoMapper code.
## Core truths
- DynamoMapper is a C# incremental source generator for `T <-> Dictionary`.
-- Configure mapping on a `static partial` mapper class marked with `[DynamoMapper]`.
+- Configure mapping on a partial mapper class marked with `[DynamoMapper]`.
+- Mapper classes can be instance-based or `static`; both shapes are supported.
- The generator recognizes unimplemented partial methods whose names start with `To` or `From`
and use the expected model/dictionary signatures.
- One-way mappers are valid: `To*` only or `From*` only.
@@ -46,9 +47,8 @@ Use this skill when generating or explaining DynamoMapper code.
- Do not tell the user to decorate every POCO property; configuration belongs on the mapper class.
- Do not assume methods must be named exactly `ToItem` and `FromItem`; the `To`/`From` prefix
matters, but the generator also expects the recognized model/dictionary signatures.
-- Do not invent hook signatures; all hooks must be `static partial void` with exact parameter
- shapes.
-- `AfterFromItem` requires `ref` on the entity parameter.
+- Do not assume mappers must be `static`; non-static members are valid too.
+- Check `references/gotchas.md` before teaching hooks or custom converter signatures.
- Do not assume every unsupported converter setup becomes a DynamoMapper diagnostic; some become normal C# compile errors.
## Reference map
diff --git a/skills/dynamo-mapper/references/core-usage.md b/skills/dynamo-mapper/references/core-usage.md
index 3e7d1e75..68ab647b 100644
--- a/skills/dynamo-mapper/references/core-usage.md
+++ b/skills/dynamo-mapper/references/core-usage.md
@@ -13,16 +13,19 @@ public sealed class Order
}
[DynamoMapper]
-public static partial class OrderMapper
+public partial class OrderMapper
{
- public static partial Dictionary ToItem(Order source);
- public static partial Order FromItem(Dictionary item);
+ public partial Dictionary ToItem(Order source);
+ public partial Order FromItem(Dictionary item);
}
```
+`static` mapper classes are also supported if you prefer static access.
+
## Mapper rules
-- The mapper is a `static partial class` marked with `[DynamoMapper]`.
+- The mapper is a `partial class` marked with `[DynamoMapper]`.
+- Mapper classes and methods may be instance-based or `static`.
- `To*` methods take one model parameter and return `Dictionary`.
- `From*` methods take one `Dictionary` and return the model type.
- One-way mappers are valid.
diff --git a/skills/dynamo-mapper/references/gotchas.md b/skills/dynamo-mapper/references/gotchas.md
index 94bc7b8d..ec2b0e12 100644
--- a/skills/dynamo-mapper/references/gotchas.md
+++ b/skills/dynamo-mapper/references/gotchas.md
@@ -4,7 +4,8 @@
- Do not tell users to decorate every domain-model property.
- Do not require methods to be named exactly `ToItem` and `FromItem`.
-- Do not invent lifecycle hook signatures from memory.
+- Do not require mapper classes or mapper methods to be `static`.
+- Do not teach lifecycle hooks as currently implemented behavior.
- Do not use the old property-level converter signatures from stale docs.
- Do not assume every converter mistake becomes a DynamoMapper diagnostic.
diff --git a/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs b/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs
new file mode 100644
index 00000000..4cc65442
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs
@@ -0,0 +1,44 @@
+using Amazon.DynamoDBv2;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace LayeredCraft.DynamoMapper.Client.DependencyInjection;
+
+/// Provides dependency injection registration helpers for .
+public static class DynamoClientServiceCollectionExtensions
+{
+ ///
+ /// Registers as a singleton and applies mapper configuration
+ /// through the returned builder.
+ ///
+ /// The service collection to update.
+ /// Applies mapper registrations to the builder.
+ /// The service collection for further chaining.
+ public static IServiceCollection AddDynamoClient(
+ this IServiceCollection services,
+ Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var registrations = new List<(Type DtoType, Type MapperType)>();
+ var builder = new DynamoClientServiceBuilder(services, registrations);
+ configure(builder);
+
+ services.TryAddSingleton(serviceProvider =>
+ {
+ var dynamoDbClient =
+ builder.AmazonDynamoDb ?? serviceProvider.GetRequiredService();
+ var clientBuilder = new DynamoClientBuilder().WithAmazonDynamoDb(dynamoDbClient);
+
+ foreach (var registration in registrations)
+ {
+ var mapper = serviceProvider.GetRequiredService(registration.MapperType);
+ clientBuilder.WithMapper(registration.DtoType, mapper);
+ }
+
+ return clientBuilder.Build();
+ });
+ return services;
+ }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs
new file mode 100644
index 00000000..23245292
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs
@@ -0,0 +1,193 @@
+using System.Collections.Immutable;
+using Amazon.DynamoDBv2;
+using Amazon.DynamoDBv2.Model;
+using LayeredCraft.DynamoMapper.Client.Models;
+
+// ReSharper disable MemberCanBePrivate.Global
+
+namespace LayeredCraft.DynamoMapper.Client;
+
+///
+/// Provides typed convenience methods for reading and writing DynamoDB items through
+/// registered mappers.
+///
+public class DynamoClient
+{
+ private readonly ImmutableDictionary _mappers;
+
+ /// Gets the underlying DynamoDB client used for all requests.
+ public IAmazonDynamoDB AmazonDynamoDb { get; }
+
+ internal DynamoClient(ImmutableDictionary mappers, IAmazonDynamoDB dynamoDbClient)
+ {
+ ArgumentNullException.ThrowIfNull(dynamoDbClient);
+
+ _mappers = mappers;
+ AmazonDynamoDb = dynamoDbClient;
+ }
+
+ /// Gets the mapper registered for the specified DTO type.
+ /// The DTO type to retrieve a mapper for.
+ /// The mapper registered for .
+ ///
+ /// Thrown when no mapper has been registered for
+ /// .
+ ///
+ public IDynamoMapper GetMapper()
+ {
+ if (_mappers.TryGetValue(typeof(T), out var mapper))
+ return (IDynamoMapper)mapper;
+
+ throw new InvalidOperationException($"No mapper found for type {typeof(T)}");
+ }
+
+ /// Retrieves a single item by key and maps it to the specified DTO type.
+ /// The DTO type to map the item to.
+ /// The DynamoDB table name.
+ /// The primary key of the item to retrieve.
+ /// The cancellation token for the asynchronous operation.
+ ///
+ /// A task that returns the mapped DTO when an item is found; otherwise,
+ /// .
+ ///
+ public async Task> GetItemAsync(
+ string tableName,
+ Dictionary key,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken);
+ var mappedItem = result.Item is null || result.Item.Count == 0
+ ? default
+ : GetMapper().FromItem(result.Item);
+
+ return new GetItemResponse(result, mappedItem);
+ }
+
+ /// Saves a mapped DTO to the specified table.
+ /// The DTO type to write.
+ /// The DynamoDB table name.
+ /// The DTO instance to map and save.
+ /// The cancellation token for the asynchronous operation.
+ ///
+ /// A task that returns the DynamoDB response together with a mapped item when attributes are
+ /// returned.
+ ///
+ public async Task PutItemAsync(
+ string tableName,
+ T item,
+ CancellationToken cancellationToken = default)
+ {
+ var mappedItem = GetMapper().ToItem(item);
+ return await AmazonDynamoDb.PutItemAsync(tableName, mappedItem, cancellationToken);
+ }
+
+ /// Executes a put request and maps returned attributes to the specified DTO type.
+ /// The DTO type to map the returned attributes to.
+ /// The put request to execute.
+ /// The cancellation token for the asynchronous operation.
+ ///
+ /// A task that returns the DynamoDB response together with a mapped item when the request
+ /// returns attributes; otherwise, .
+ ///
+ public async Task> PutItemAsync(
+ PutItemRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await AmazonDynamoDb.PutItemAsync(request, cancellationToken);
+ var mappedItem =
+ result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes);
+ return new PutItemResponse(result, mappedItem);
+ }
+
+ /// Deletes a single item by key from the specified table.
+ /// The DynamoDB table name.
+ /// The primary key of the item to delete.
+ /// The cancellation token for the asynchronous operation.
+ /// A task that completes when the delete request has finished.
+ public Task DeleteItemAsync(
+ string tableName,
+ Dictionary key,
+ CancellationToken cancellationToken = default)
+ => AmazonDynamoDb.DeleteItemAsync(tableName, key, cancellationToken);
+
+ /// Executes a delete request and maps returned attributes to the specified DTO type.
+ /// The DTO type to map the returned attributes to.
+ /// The delete request to execute.
+ /// The cancellation token for the asynchronous operation.
+ ///
+ /// A task that returns the DynamoDB response together with a mapped item when the request
+ /// returns attributes; otherwise, .
+ ///
+ public async Task> DeleteItemAsync(
+ DeleteItemRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await AmazonDynamoDb.DeleteItemAsync(request, cancellationToken);
+ var mappedItem =
+ result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes);
+ return new DeleteItemResponse(result, mappedItem);
+ }
+
+ /// Executes an update request and maps returned attributes to the specified DTO type.
+ /// The DTO type to map the returned attributes to.
+ /// The update request to execute.
+ /// The cancellation token for the asynchronous operation.
+ ///
+ /// A task that returns the DynamoDB response together with a mapped item when the request
+ /// returns attributes; otherwise, .
+ ///
+ public async Task> UpdateItemAsync(
+ UpdateItemRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken);
+ var mappedItem =
+ result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes);
+ return new UpdateItemResponse(result, mappedItem);
+ }
+
+ /// Executes a query request and maps each returned item to the specified DTO type.
+ /// The DTO type to map the query results to.
+ /// The query request to execute.
+ /// The cancellation token for the asynchronous operation.
+ /// A task that returns the mapped query results.
+ public async Task> QueryAsync(
+ QueryRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await AmazonDynamoDb.QueryAsync(request, cancellationToken);
+ var mapper = GetMapper();
+ var mappedItems = result.Items.Select(mapper.FromItem).ToList();
+ return new QueryResponse(result, mappedItems);
+ }
+
+ /// Executes a scan request and maps each returned item to the specified DTO type.
+ /// The DTO type to map the scan results to.
+ /// The scan request to execute.
+ /// The cancellation token for the asynchronous operation.
+ /// A task that returns the mapped scan results.
+ public async Task> ScanAsync(
+ ScanRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await AmazonDynamoDb.ScanAsync(request, cancellationToken);
+ var mapper = GetMapper();
+ var mappedItems = result.Items.Select(mapper.FromItem).ToList();
+ return new ScanResponse(result, mappedItems);
+ }
+
+ /// Executes a PartiQL statement and maps each returned item to the specified DTO type.
+ /// The DTO type to map the returned items to.
+ /// The PartiQL request to execute.
+ /// The cancellation token for the asynchronous operation.
+ /// A task that returns the mapped statement results.
+ public async Task> ExecuteStatementAsync(
+ ExecuteStatementRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await AmazonDynamoDb.ExecuteStatementAsync(request, cancellationToken);
+ var mapper = GetMapper();
+ var mappedItems = result.Items.Select(mapper.FromItem).ToList();
+ return new ExecuteStatementResponse(result, mappedItems);
+ }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs
new file mode 100644
index 00000000..31bbfac8
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs
@@ -0,0 +1,58 @@
+using System.Collections.Immutable;
+using Amazon.DynamoDBv2;
+
+namespace LayeredCraft.DynamoMapper.Client;
+
+/// Builds a with registered mappers and a DynamoDB client.
+public class DynamoClientBuilder
+{
+ private readonly Dictionary _mappers = new();
+ private IAmazonDynamoDB? _dynamoDbClient;
+
+ /// Registers the specified mapper instance for the DTO type.
+ /// The DTO type handled by the mapper.
+ /// The mapper instance to register.
+ /// The current builder instance.
+ public DynamoClientBuilder WithMapper(IDynamoMapper mapper)
+ {
+ ArgumentNullException.ThrowIfNull(mapper);
+
+ _mappers[typeof(TDto)] = mapper;
+ return this;
+ }
+
+ /// Registers a mapper for the specified DTO type.
+ /// The DTO type handled by the mapper.
+ /// The mapper type to instantiate and register.
+ /// The current builder instance.
+ public DynamoClientBuilder WithMapper()
+ where TMapper : class, IDynamoMapper, new()
+ => WithMapper(new TMapper());
+
+ /// Uses the specified DynamoDB client when building the .
+ /// The DynamoDB client instance to use.
+ /// The current builder instance.
+ public DynamoClientBuilder WithAmazonDynamoDb(IAmazonDynamoDB dynamoDbClient)
+ {
+ _dynamoDbClient = dynamoDbClient;
+ return this;
+ }
+
+ /// Builds a from the configured mappers and DynamoDB client.
+ /// A configured instance.
+ public DynamoClient Build()
+ {
+ _dynamoDbClient ??= new AmazonDynamoDBClient();
+
+ return new DynamoClient(_mappers.ToImmutableDictionary(), _dynamoDbClient);
+ }
+
+ internal DynamoClientBuilder WithMapper(Type dtoType, object mapper)
+ {
+ ArgumentNullException.ThrowIfNull(dtoType);
+ ArgumentNullException.ThrowIfNull(mapper);
+
+ _mappers[dtoType] = mapper;
+ return this;
+ }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs
new file mode 100644
index 00000000..cdf61525
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs
@@ -0,0 +1,54 @@
+using System.Diagnostics.CodeAnalysis;
+using Amazon.DynamoDBv2;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace LayeredCraft.DynamoMapper.Client;
+
+///
+/// Provides a fluent registration surface for configuring in a
+/// dependency injection container.
+///
+public sealed class DynamoClientServiceBuilder
+{
+ private readonly IList<(Type DtoType, Type MapperType)> _registrations;
+
+ internal DynamoClientServiceBuilder(
+ IServiceCollection services,
+ IList<(Type DtoType, Type MapperType)> registrations)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ Services = services;
+ _registrations = registrations;
+ }
+
+ /// Gets the service collection being configured.
+ public IServiceCollection Services { get; }
+
+ internal IAmazonDynamoDB? AmazonDynamoDb { get; private set; }
+
+ /// Uses the specified DynamoDB client when building the .
+ /// The DynamoDB client instance to use.
+ /// The current registration builder.
+ public DynamoClientServiceBuilder WithAmazonDynamoDb(IAmazonDynamoDB dynamoDbClient)
+ {
+ ArgumentNullException.ThrowIfNull(dynamoDbClient);
+
+ AmazonDynamoDb = dynamoDbClient;
+ return this;
+ }
+
+ /// Registers a mapper that should be included in the built .
+ /// The DTO type handled by the mapper.
+ /// The mapper implementation type.
+ /// The current registration builder.
+ public DynamoClientServiceBuilder AddMapper()
+ where TMapper : class, IDynamoMapper
+ {
+ Services.TryAddSingleton();
+ _registrations.Add((typeof(TDto), typeof(TMapper)));
+ return this;
+ }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs
new file mode 100644
index 00000000..db046b71
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs
@@ -0,0 +1,18 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client;
+
+/// Maps a DTO to and from a DynamoDB item representation.
+/// The DTO type handled by the mapper.
+public interface IDynamoMapper
+{
+ /// Converts a DTO instance into a DynamoDB item.
+ /// The DTO instance to convert.
+ /// A DynamoDB item keyed by attribute name.
+ Dictionary ToItem(TDto source);
+
+ /// Creates a DTO instance from a DynamoDB item.
+ /// The DynamoDB item to convert.
+ /// The DTO created from the item.
+ TDto FromItem(Dictionary item);
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj
new file mode 100644
index 00000000..b37a5504
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net10.0
+ 14
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs
new file mode 100644
index 00000000..7d841707
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs
@@ -0,0 +1,27 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Models;
+
+///
+/// Represents the result of a DynamoDB DeleteItem operation together with optional
+/// mapped returned attributes.
+///
+/// The DTO type produced from returned DynamoDB attributes.
+///
+/// DynamoDB only returns attributes for DeleteItem when
+/// requests old values. In all other cases
+/// is .
+///
+public class DeleteItemResponse : DeleteItemResponse
+{
+ internal DeleteItemResponse(DeleteItemResponse response, T? mappedItem)
+ {
+ MappedItem = mappedItem;
+ Attributes = response.Attributes;
+ ConsumedCapacity = response.ConsumedCapacity;
+ ItemCollectionMetrics = response.ItemCollectionMetrics;
+ }
+
+ /// Gets the returned attributes mapped to when present.
+ public T? MappedItem { get; }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs
new file mode 100644
index 00000000..00a59fb9
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs
@@ -0,0 +1,34 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Models;
+
+///
+/// Represents the result of a DynamoDB PartiQL ExecuteStatement operation together
+/// with mapped DTO items.
+///
+/// The DTO type produced from the returned DynamoDB items.
+///
+/// This type provides the same general response context as
+/// , including the raw statement
+/// results, pagination state, and consumed capacity details, while also exposing
+/// for typed access through a registered mapper.
+///
+public class ExecuteStatementResponse : ExecuteStatementResponse
+{
+ internal ExecuteStatementResponse(ExecuteStatementResponse response, List mappedItems)
+ {
+ MappedItems = mappedItems;
+ Items = response.Items;
+ LastEvaluatedKey = response.LastEvaluatedKey;
+ NextToken = response.NextToken;
+ ConsumedCapacity = response.ConsumedCapacity;
+ }
+
+ /// Gets the items returned by DynamoDB mapped to .
+ ///
+ /// This list corresponds to the raw items exposed by
+ /// , but each entry has been
+ /// projected into the typed DTO using the registered mapper.
+ ///
+ public List MappedItems { get; }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs
new file mode 100644
index 00000000..72fae05c
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs
@@ -0,0 +1,32 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Models;
+
+///
+/// Represents the result of a DynamoDB GetItem operation together with an optional
+/// mapped DTO instance.
+///
+/// The DTO type produced from the returned DynamoDB item.
+///
+/// This type provides the same general response context as
+/// , including consumed capacity details and
+/// the raw DynamoDB item, while also exposing for typed access through a
+/// registered mapper.
+///
+public class GetItemResponse : GetItemResponse
+{
+ internal GetItemResponse(GetItemResponse response, T? mappedItem)
+ {
+ MappedItem = mappedItem;
+ Item = response.Item;
+ ConsumedCapacity = response.ConsumedCapacity;
+ IsItemSet = response.IsItemSet;
+ }
+
+ /// Gets the item returned by DynamoDB mapped to .
+ ///
+ /// This value is when the response does not contain an item or when
+ /// the mapped type is nullable and the mapper produces a null value.
+ ///
+ public T? MappedItem { get; }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs
new file mode 100644
index 00000000..d74c43e3
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs
@@ -0,0 +1,27 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Models;
+
+///
+/// Represents the result of a DynamoDB PutItem operation together with optional mapped
+/// returned attributes.
+///
+/// The DTO type produced from returned DynamoDB attributes.
+///
+/// DynamoDB only returns attributes for PutItem when
+/// requests ALL_OLD. In all other cases
+/// is .
+///
+public class PutItemResponse : PutItemResponse
+{
+ internal PutItemResponse(PutItemResponse response, T? mappedItem)
+ {
+ MappedItem = mappedItem;
+ Attributes = response.Attributes;
+ ConsumedCapacity = response.ConsumedCapacity;
+ ItemCollectionMetrics = response.ItemCollectionMetrics;
+ }
+
+ /// Gets the returned attributes mapped to when present.
+ public T? MappedItem { get; }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs
new file mode 100644
index 00000000..af2c336d
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs
@@ -0,0 +1,32 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Models;
+
+/// Represents the result of a DynamoDB Query operation together with mapped DTO items.
+/// The DTO type produced from the returned DynamoDB items.
+///
+/// This type provides the same general response context as
+/// , including the raw query items, result
+/// counts, pagination state, and consumed capacity details, while also exposing
+/// for typed access through a registered mapper.
+///
+public class QueryResponse : QueryResponse
+{
+ internal QueryResponse(QueryResponse response, List mappedItems)
+ {
+ MappedItems = mappedItems;
+ Items = response.Items;
+ Count = response.Count;
+ LastEvaluatedKey = response.LastEvaluatedKey;
+ ScannedCount = response.ScannedCount;
+ ConsumedCapacity = response.ConsumedCapacity;
+ }
+
+ /// Gets the items returned by DynamoDB mapped to .
+ ///
+ /// This list corresponds to the raw items exposed by
+ /// , but each entry has been projected
+ /// into the typed DTO using the registered mapper.
+ ///
+ public List MappedItems { get; }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs
new file mode 100644
index 00000000..ef2b9721
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs
@@ -0,0 +1,32 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Models;
+
+/// Represents the result of a DynamoDB Scan operation together with mapped DTO items.
+/// The DTO type produced from the returned DynamoDB items.
+///
+/// This type provides the same general response context as
+/// , including the raw scan items, result
+/// counts, pagination state, and consumed capacity details, while also exposing
+/// for typed access through a registered mapper.
+///
+public class ScanResponse : ScanResponse
+{
+ internal ScanResponse(ScanResponse response, List mappedItems)
+ {
+ MappedItems = mappedItems;
+ Items = response.Items;
+ Count = response.Count;
+ LastEvaluatedKey = response.LastEvaluatedKey;
+ ScannedCount = response.ScannedCount;
+ ConsumedCapacity = response.ConsumedCapacity;
+ }
+
+ /// Gets the items returned by DynamoDB mapped to .
+ ///
+ /// This list corresponds to the raw items exposed by
+ /// , but each entry has been projected
+ /// into the typed DTO using the registered mapper.
+ ///
+ public List MappedItems { get; }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs
new file mode 100644
index 00000000..ef127030
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs
@@ -0,0 +1,22 @@
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Models;
+
+///
+/// Represents the result of a DynamoDB UpdateItem operation together with optional
+/// mapped returned attributes.
+///
+/// The DTO type produced from returned DynamoDB attributes.
+public class UpdateItemResponse : UpdateItemResponse
+{
+ internal UpdateItemResponse(UpdateItemResponse response, T? mappedItem)
+ {
+ MappedItem = mappedItem;
+ Attributes = response.Attributes;
+ ConsumedCapacity = response.ConsumedCapacity;
+ ItemCollectionMetrics = response.ItemCollectionMetrics;
+ }
+
+ /// Gets the returned attributes mapped to when present.
+ public T? MappedItem { get; }
+}
diff --git a/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs
new file mode 100644
index 00000000..24681a7c
--- /dev/null
+++ b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs
@@ -0,0 +1,229 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using Amazon.DynamoDBv2.Model;
+
+namespace System;
+
+///
+/// Convenience extensions for converting common scalar values to
+/// .
+///
+public static class AttributeValueConverterExtensions
+{
+ extension(string? str)
+ {
+ /// Converts the string to a DynamoDB string attribute or NULL attribute.
+ public AttributeValue ToAttributeValue()
+ => str is null ? new AttributeValue { NULL = true } : new AttributeValue { S = str };
+ }
+
+ extension(bool value)
+ {
+ /// Converts the boolean to a DynamoDB BOOL attribute.
+ public AttributeValue ToAttributeValue() => new() { BOOL = value };
+ }
+
+ extension(bool? value)
+ {
+ /// Converts the nullable boolean to a DynamoDB BOOL attribute or NULL attribute.
+ public AttributeValue ToAttributeValue()
+ => value is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue { BOOL = value.Value };
+ }
+
+ extension(int num)
+ {
+ /// Converts the integer to a DynamoDB number attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => new() { N = num.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(int? num)
+ {
+ /// Converts the nullable integer to a DynamoDB number attribute or NULL attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => num is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ N = num.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+
+ extension(long num)
+ {
+ /// Converts the long integer to a DynamoDB number attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => new() { N = num.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(long? num)
+ {
+ /// Converts the nullable long integer to a DynamoDB number attribute or NULL attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => num is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ N = num.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+
+ extension(float num)
+ {
+ /// Converts the single-precision number to a DynamoDB number attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => new() { N = num.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(float? num)
+ {
+ ///
+ /// Converts the nullable single-precision number to a DynamoDB number attribute or NULL
+ /// attribute.
+ ///
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => num is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ N = num.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+
+ extension(double num)
+ {
+ /// Converts the double-precision number to a DynamoDB number attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => new() { N = num.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(double? num)
+ {
+ ///
+ /// Converts the nullable double-precision number to a DynamoDB number attribute or NULL
+ /// attribute.
+ ///
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => num is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ N = num.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+
+ extension(decimal num)
+ {
+ /// Converts the decimal number to a DynamoDB number attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => new() { N = num.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(decimal? num)
+ {
+ /// Converts the nullable decimal number to a DynamoDB number attribute or NULL attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax("NumericFormat")] string? format = null)
+ => num is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ N = num.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+
+ extension(Guid value)
+ {
+ /// Converts the GUID to a DynamoDB string attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.GuidFormat)] string format = "D")
+ => new() { S = value.ToString(format) };
+ }
+
+ extension(Guid? value)
+ {
+ /// Converts the nullable GUID to a DynamoDB string attribute or NULL attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.GuidFormat)] string format = "D")
+ => value is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue { S = value.Value.ToString(format) };
+ }
+
+ extension(DateTime value)
+ {
+ /// Converts the date and time to a DynamoDB string attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o")
+ => new() { S = value.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(DateTime? value)
+ {
+ /// Converts the nullable date and time to a DynamoDB string attribute or NULL attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o")
+ => value is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ S = value.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+
+ extension(DateTimeOffset value)
+ {
+ /// Converts the date and time offset to a DynamoDB string attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o")
+ => new() { S = value.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(DateTimeOffset? value)
+ {
+ ///
+ /// Converts the nullable date and time offset to a DynamoDB string attribute or NULL
+ /// attribute.
+ ///
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o")
+ => value is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ S = value.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+
+ extension(TimeSpan value)
+ {
+ /// Converts the time span to a DynamoDB string attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.TimeSpanFormat)] string format = "c")
+ => new() { S = value.ToString(format, CultureInfo.InvariantCulture) };
+ }
+
+ extension(TimeSpan? value)
+ {
+ /// Converts the nullable time span to a DynamoDB string attribute or NULL attribute.
+ public AttributeValue ToAttributeValue(
+ [StringSyntax(StringSyntaxAttribute.TimeSpanFormat)] string format = "c")
+ => value is null
+ ? new AttributeValue { NULL = true }
+ : new AttributeValue
+ {
+ S = value.Value.ToString(format, CultureInfo.InvariantCulture),
+ };
+ }
+}
diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs
new file mode 100644
index 00000000..013b5b96
--- /dev/null
+++ b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs
@@ -0,0 +1,290 @@
+using System.Globalization;
+using Amazon.DynamoDBv2.Model;
+
+namespace LayeredCraft.DynamoMapper.Client.Tests;
+
+public sealed class AttributeValueConverterExtensionsTests
+{
+ [Fact]
+ public void String_ToAttributeValue_ReturnsStringAttribute()
+ {
+ var attribute = "hello".ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(StringAttribute("hello"));
+ }
+
+ [Fact]
+ public void NullableString_ToAttributeValue_ReturnsNullAttribute_WhenValueIsNull()
+ {
+ string? value = null;
+
+ var attribute = value.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void Bool_ToAttributeValue_ReturnsBoolAttribute()
+ {
+ var attribute = true.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(BoolAttribute(true));
+ }
+
+ [Fact]
+ public void NullableBool_ToAttributeValue_ReturnsBoolOrNullAttribute()
+ {
+ bool? value = false;
+ bool? nullValue = null;
+
+ var attribute = value.ToAttributeValue();
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(BoolAttribute(false));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void Int_ToAttributeValue_UsesInvariantCultureAndFormatString()
+ {
+ using var _ = new CultureScope("de-DE");
+
+ var attribute = 12345.ToAttributeValue("N0");
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("12,345"));
+ }
+
+ [Fact]
+ public void NullableInt_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ int? value = 255;
+ int? nullValue = null;
+
+ var attribute = value.ToAttributeValue("X");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("FF"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void Long_ToAttributeValue_UsesInvariantCultureAndFormatString()
+ {
+ using var _ = new CultureScope("de-DE");
+
+ var attribute = 123456789L.ToAttributeValue("N0");
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("123,456,789"));
+ }
+
+ [Fact]
+ public void NullableLong_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ long? value = 4095;
+ long? nullValue = null;
+
+ var attribute = value.ToAttributeValue("X");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("FFF"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void Float_ToAttributeValue_UsesInvariantCulture()
+ {
+ using var _ = new CultureScope("de-DE");
+
+ var attribute = 12.5f.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("12.5"));
+ }
+
+ [Fact]
+ public void NullableFloat_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ float? value = 12.5f;
+ float? nullValue = null;
+
+ var attribute = value.ToAttributeValue("0.00");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("12.50"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void Double_ToAttributeValue_UsesInvariantCulture()
+ {
+ using var _ = new CultureScope("de-DE");
+
+ var attribute = 1234.5d.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("1234.5"));
+ }
+
+ [Fact]
+ public void NullableDouble_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ double? value = 1234.5d;
+ double? nullValue = null;
+
+ var attribute = value.ToAttributeValue("0.000");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("1234.500"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void Decimal_ToAttributeValue_UsesInvariantCulture()
+ {
+ using var _ = new CultureScope("de-DE");
+
+ var attribute = 1234.5m.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("1234.5"));
+ }
+
+ [Fact]
+ public void NullableDecimal_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ decimal? value = 1234.5m;
+ decimal? nullValue = null;
+
+ var attribute = value.ToAttributeValue("0.000");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(NumberAttribute("1234.500"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void Guid_ToAttributeValue_UsesDefaultDFormat()
+ {
+ var value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff");
+
+ var attribute = value.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(StringAttribute("00112233-4455-6677-8899-aabbccddeeff"));
+ }
+
+ [Fact]
+ public void NullableGuid_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ Guid? value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff");
+ Guid? nullValue = null;
+
+ var attribute = value.ToAttributeValue("N");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(StringAttribute("00112233445566778899aabbccddeeff"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void DateTime_ToAttributeValue_UsesDefaultRoundTripFormat()
+ {
+ var value = new DateTime(2025, 4, 7, 12, 34, 56, DateTimeKind.Utc);
+
+ var attribute = value.ToAttributeValue();
+
+ attribute
+ .Should()
+ .BeEquivalentTo(StringAttribute(value.ToString("o", CultureInfo.InvariantCulture)));
+ }
+
+ [Fact]
+ public void NullableDateTime_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ DateTime? value = new DateTime(2025, 4, 7, 12, 34, 56, DateTimeKind.Utc);
+ DateTime? nullValue = null;
+
+ var attribute = value.ToAttributeValue("yyyyMMdd");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(StringAttribute("20250407"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void DateTimeOffset_ToAttributeValue_UsesDefaultRoundTripFormat()
+ {
+ var value = new DateTimeOffset(2025, 4, 7, 12, 34, 56, TimeSpan.FromHours(2));
+
+ var attribute = value.ToAttributeValue();
+
+ attribute
+ .Should()
+ .BeEquivalentTo(StringAttribute(value.ToString("o", CultureInfo.InvariantCulture)));
+ }
+
+ [Fact]
+ public void NullableDateTimeOffset_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ DateTimeOffset? value = new DateTimeOffset(2025, 4, 7, 12, 34, 56, TimeSpan.Zero);
+ DateTimeOffset? nullValue = null;
+
+ var attribute = value.ToAttributeValue("yyyy-MM-dd zzz");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute.Should().BeEquivalentTo(StringAttribute("2025-04-07 +00:00"));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ [Fact]
+ public void TimeSpan_ToAttributeValue_UsesDefaultConstantFormat()
+ {
+ var value = TimeSpan.FromHours(1) + TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(3);
+
+ var attribute = value.ToAttributeValue();
+
+ attribute
+ .Should()
+ .BeEquivalentTo(StringAttribute(value.ToString("c", CultureInfo.InvariantCulture)));
+ }
+
+ [Fact]
+ public void NullableTimeSpan_ToAttributeValue_UsesFormatStringOrNullAttribute()
+ {
+ TimeSpan? value =
+ TimeSpan.FromHours(26) + TimeSpan.FromMinutes(3) + TimeSpan.FromSeconds(4);
+ TimeSpan? nullValue = null;
+
+ var attribute = value.ToAttributeValue("g");
+ var nullAttribute = nullValue.ToAttributeValue();
+
+ attribute
+ .Should()
+ .BeEquivalentTo(
+ StringAttribute(value.Value.ToString("g", CultureInfo.InvariantCulture)));
+ nullAttribute.Should().BeEquivalentTo(NullAttribute());
+ }
+
+ private static AttributeValue StringAttribute(string value) => new() { S = value };
+
+ private static AttributeValue NumberAttribute(string value) => new() { N = value };
+
+ private static AttributeValue BoolAttribute(bool value) => new() { BOOL = value };
+
+ private static AttributeValue NullAttribute() => new() { NULL = true };
+
+ private sealed class CultureScope : IDisposable
+ {
+ private readonly CultureInfo _originalCulture = CultureInfo.CurrentCulture;
+ private readonly CultureInfo _originalUiCulture = CultureInfo.CurrentUICulture;
+
+ public CultureScope(string name)
+ {
+ var culture = CultureInfo.GetCultureInfo(name);
+ CultureInfo.CurrentCulture = culture;
+ CultureInfo.CurrentUICulture = culture;
+ }
+
+ public void Dispose()
+ {
+ CultureInfo.CurrentCulture = _originalCulture;
+ CultureInfo.CurrentUICulture = _originalUiCulture;
+ }
+ }
+}
diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs
new file mode 100644
index 00000000..439e0735
--- /dev/null
+++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs
@@ -0,0 +1,368 @@
+using Amazon.DynamoDBv2.Model;
+using LayeredCraft.DynamoMapper.Client.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LayeredCraft.DynamoMapper.Client.Tests;
+
+public sealed class DynamoClientTests(DynamoDbFixture fixture) : IClassFixture
+{
+ private readonly DynamoClient _client = new DynamoClientBuilder()
+ .WithAmazonDynamoDb(fixture.Client)
+ .WithMapper()
+ .WithMapper()
+ .WithMapper()
+ .Build();
+
+ [Fact]
+ public async Task GetItemAsync_UserProfile_ReturnsSeededItem()
+ {
+ var expected = TestDataSamples.UserProfiles[0];
+
+ var item = await _client.GetItemAsync(
+ DynamoDbFixture.TableName,
+ CreateKey(expected.Pk, expected.Sk),
+ TestContext.Current.CancellationToken);
+
+ item.MappedItem.Should().BeEquivalentTo(expected);
+ }
+
+ [Fact]
+ public async Task QueryAsync_ProjectRecord_ReturnsSeededProjectsForOwner()
+ {
+ var expected = TestDataSamples.ProjectRecords[0];
+
+ var response = await _client.QueryAsync(
+ new QueryRequest
+ {
+ TableName = DynamoDbFixture.TableName,
+ KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)",
+ ExpressionAttributeValues = new Dictionary
+ {
+ [":pk"] = expected.Pk.ToAttributeValue(),
+ [":skPrefix"] = "PROJECT#".ToAttributeValue(),
+ },
+ },
+ TestContext.Current.CancellationToken);
+
+ response.MappedItems.Should().ContainEquivalentOf(expected);
+ }
+
+ [Fact]
+ public async Task ScanAsync_UserProfile_ReturnsSeededProfiles()
+ {
+ var response = await _client.ScanAsync(
+ new ScanRequest
+ {
+ TableName = DynamoDbFixture.TableName,
+ FilterExpression = "entityType = :entityType",
+ ExpressionAttributeValues =
+ new Dictionary
+ {
+ [":entityType"] = "UserProfile".ToAttributeValue(),
+ },
+ },
+ TestContext.Current.CancellationToken);
+
+ response.MappedItems.Should().BeEquivalentTo(TestDataSamples.UserProfiles);
+ }
+
+ [Fact]
+ public async Task ExecuteStatementAsync_UserProfile_ReturnsSeededProfiles()
+ {
+ var response = await _client.ExecuteStatementAsync(
+ new ExecuteStatementRequest
+ {
+ Statement = $"""
+ SELECT * FROM "{DynamoDbFixture.TableName}"
+ WHERE entityType = ?
+ """,
+ Parameters = ["UserProfile".ToAttributeValue()],
+ },
+ TestContext.Current.CancellationToken);
+
+ response.MappedItems.Should().BeEquivalentTo(TestDataSamples.UserProfiles);
+ }
+
+ [Fact]
+ public async Task ExecuteStatementAsync_UserProfile_WithKeyFilter_ReturnsMappedItem()
+ {
+ var expected = TestDataSamples.UserProfiles[0];
+
+ var response = await _client.ExecuteStatementAsync(
+ new ExecuteStatementRequest
+ {
+ Statement = $"""
+ SELECT * FROM "{DynamoDbFixture.TableName}"
+ WHERE pk = ? AND sk = ?
+ """,
+ Parameters = [expected.Pk.ToAttributeValue(), expected.Sk.ToAttributeValue()],
+ },
+ TestContext.Current.CancellationToken);
+
+ response.MappedItems.Should().BeEquivalentTo([expected]);
+ }
+
+ [Fact]
+ public async Task PutItemAsync_ThenDeleteItemAsync_PersistsAndRemovesItem()
+ {
+ var item = new TaskRecord
+ {
+ Pk = "PROJECT#p-9999",
+ Sk = "TASK#t-9999",
+ EntityType = "TaskRecord",
+ TaSkId = "t-9999",
+ ProjectId = "p-9999",
+ AssignedUserId = "u-1001",
+ Title = "Verify client put",
+ Notes = "Inserted by integration test.",
+ EstimateHours = 2.5m,
+ Completed = false,
+ Order = 99,
+ CreatedAt = "2025-04-06T10:00:00Z",
+ DueAt = "2025-04-07T10:00:00Z",
+ Checklist =
+ [
+ new TaSkChecklistItem { Text = "Write item", Done = true },
+ new TaSkChecklistItem { Text = "Read item", Done = false },
+ ],
+ Metadata = new TaSkMetadata { Color = "blue", BlockedBy = null },
+ };
+
+ await _client.PutItemAsync(
+ DynamoDbFixture.TableName,
+ item,
+ TestContext.Current.CancellationToken);
+
+ var persisted = await _client.GetItemAsync(
+ DynamoDbFixture.TableName,
+ CreateKey(item.Pk, item.Sk),
+ TestContext.Current.CancellationToken);
+
+ persisted.MappedItem.Should().BeEquivalentTo(item);
+
+ await _client.DeleteItemAsync(
+ DynamoDbFixture.TableName,
+ CreateKey(item.Pk, item.Sk),
+ TestContext.Current.CancellationToken);
+
+ var deleted = await _client.GetItemAsync(
+ DynamoDbFixture.TableName,
+ CreateKey(item.Pk, item.Sk),
+ TestContext.Current.CancellationToken);
+
+ deleted.MappedItem.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task PutItemAsync_UserProfile_WithAllOld_ReturnsMappedOldItem()
+ {
+ var original = new UserProfile
+ {
+ Pk = "USER#u-9998",
+ Sk = "PROFILE#u-9998",
+ EntityType = "UserProfile",
+ UserId = "u-9998",
+ Email = "original@example.com",
+ DisplayName = "Original User",
+ Age = 41,
+ IsActive = true,
+ AccountBalance = 10.25m,
+ CreatedAt = "2025-04-01T00:00:00Z",
+ LastLoginEpoch = 1743465600,
+ Tags = ["temp", "original"],
+ Preferences =
+ new UserPreferences
+ {
+ Theme = "dark", NotificationsEnabled = true, Language = "en-US",
+ },
+ LoginHistory =
+ [
+ new LoginHistoryEntry
+ {
+ At = "2025-04-01T00:00:00Z", IpAddress = "203.0.113.99",
+ },
+ ],
+ ProfilePhoto = [9, 9, 9],
+ };
+ var replacement = new UserProfile
+ {
+ Pk = original.Pk,
+ Sk = original.Sk,
+ EntityType = original.EntityType,
+ UserId = original.UserId,
+ Email = "updated@example.com",
+ DisplayName = "Updated User",
+ Age = original.Age,
+ IsActive = original.IsActive,
+ AccountBalance = original.AccountBalance,
+ CreatedAt = original.CreatedAt,
+ LastLoginEpoch = original.LastLoginEpoch,
+ Tags = original.Tags,
+ Preferences = original.Preferences,
+ LoginHistory = original.LoginHistory,
+ ProfilePhoto = original.ProfilePhoto,
+ };
+
+ await _client.PutItemAsync(
+ DynamoDbFixture.TableName,
+ original,
+ TestContext.Current.CancellationToken);
+
+ var response = await _client.PutItemAsync(
+ new PutItemRequest
+ {
+ TableName = DynamoDbFixture.TableName,
+ Item = _client.GetMapper().ToItem(replacement),
+ ReturnValues = "ALL_OLD",
+ },
+ TestContext.Current.CancellationToken);
+
+ response.MappedItem.Should().BeEquivalentTo(original);
+
+ await _client.DeleteItemAsync(
+ DynamoDbFixture.TableName,
+ CreateKey(original.Pk, original.Sk),
+ TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task UpdateItemAsync_TaskRecord_ReturnsMappedUpdatedItem()
+ {
+ var existing = TestDataSamples.TaskRecords[0];
+ var expected = new TaskRecord
+ {
+ Pk = existing.Pk,
+ Sk = existing.Sk,
+ EntityType = existing.EntityType,
+ TaSkId = existing.TaSkId,
+ ProjectId = existing.ProjectId,
+ AssignedUserId = existing.AssignedUserId,
+ Title = existing.Title,
+ Notes = "Updated by integration test.",
+ EstimateHours = existing.EstimateHours,
+ Completed = false,
+ Order = existing.Order,
+ CreatedAt = existing.CreatedAt,
+ DueAt = existing.DueAt,
+ Checklist = existing.Checklist,
+ Metadata = existing.Metadata,
+ };
+
+ var updated = await _client.UpdateItemAsync(
+ new UpdateItemRequest
+ {
+ TableName = DynamoDbFixture.TableName,
+ Key = CreateKey(existing.Pk, existing.Sk),
+ UpdateExpression = "SET notes = :notes, completed = :completed",
+ ExpressionAttributeValues = new Dictionary
+ {
+ [":notes"] = "Updated by integration test.".ToAttributeValue(),
+ [":completed"] = false.ToAttributeValue(),
+ },
+ ReturnValues = "ALL_NEW",
+ },
+ TestContext.Current.CancellationToken);
+
+ updated.MappedItem.Should().BeEquivalentTo(expected);
+ }
+
+ [Fact]
+ public async Task DeleteItemAsync_TaskRecord_WithAllOld_ReturnsMappedDeletedItem()
+ {
+ var existing = new TaskRecord
+ {
+ Pk = "PROJECT#p-9998",
+ Sk = "TASK#t-9998",
+ EntityType = "TaskRecord",
+ TaSkId = "t-9998",
+ ProjectId = "p-9998",
+ AssignedUserId = "u-1001",
+ Title = "Temporary task",
+ Notes = "Delete me",
+ EstimateHours = 1.5m,
+ Completed = false,
+ Order = 1,
+ CreatedAt = "2025-04-01T00:00:00Z",
+ DueAt = "2025-04-02T00:00:00Z",
+ Checklist = [new TaSkChecklistItem { Text = "One", Done = false }],
+ Metadata = new TaSkMetadata { Color = "green", BlockedBy = null },
+ };
+
+ await _client.PutItemAsync(
+ DynamoDbFixture.TableName,
+ existing,
+ TestContext.Current.CancellationToken);
+
+ var deleted = await _client.DeleteItemAsync(
+ new DeleteItemRequest
+ {
+ TableName = DynamoDbFixture.TableName,
+ Key = CreateKey(existing.Pk, existing.Sk),
+ ReturnValues = "ALL_OLD",
+ },
+ TestContext.Current.CancellationToken);
+
+ deleted.MappedItem.Should().BeEquivalentTo(existing);
+ }
+
+ [Fact]
+ public async Task AddDynamoClient_ResolvesWorkingClient()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(fixture.Client);
+
+ services.AddDynamoClient(builder =>
+ {
+ builder.AddMapper();
+ builder.AddMapper();
+ builder.AddMapper();
+ });
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var client = serviceProvider.GetRequiredService();
+ var expected = TestDataSamples.UserProfiles[1];
+
+ var item = await client.GetItemAsync(
+ DynamoDbFixture.TableName,
+ CreateKey(expected.Pk, expected.Sk),
+ TestContext.Current.CancellationToken);
+
+ item.MappedItem.Should().BeEquivalentTo(expected);
+ }
+
+ [Fact]
+ public async Task AddDynamoClient_WithAmazonDynamoDbOverride_ResolvesWorkingClient()
+ {
+ var services = new ServiceCollection();
+
+ services.AddDynamoClient(builder =>
+ {
+ builder.WithAmazonDynamoDb(fixture.Client);
+ builder.AddMapper();
+ builder.AddMapper();
+ builder.AddMapper();
+ });
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var client = serviceProvider.GetRequiredService();
+ var expected = TestDataSamples.ProjectRecords[1];
+
+ var response = await client.QueryAsync(
+ new QueryRequest
+ {
+ TableName = DynamoDbFixture.TableName,
+ KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)",
+ ExpressionAttributeValues = new Dictionary
+ {
+ [":pk"] = expected.Pk.ToAttributeValue(),
+ [":skPrefix"] = "PROJECT#".ToAttributeValue(),
+ },
+ },
+ TestContext.Current.CancellationToken);
+
+ response.MappedItems.Should().ContainEquivalentOf(expected);
+ }
+
+ private static Dictionary CreateKey(string pk, string sk)
+ => new() { ["pk"] = pk.ToAttributeValue(), ["sk"] = sk.ToAttributeValue() };
+}
diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs
new file mode 100644
index 00000000..a23145ad
--- /dev/null
+++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs
@@ -0,0 +1,89 @@
+using Amazon.DynamoDBv2;
+using Amazon.DynamoDBv2.Model;
+using Testcontainers.DynamoDb;
+
+namespace LayeredCraft.DynamoMapper.Client.Tests;
+
+public sealed class DynamoDbFixture : IAsyncLifetime
+{
+ private static readonly UserProfileMapper UserProfiles = new();
+ private static readonly ProjectRecordMapper ProjectRecords = new();
+ private static readonly TaskRecordMapper TaskRecords = new();
+
+ public const string TableName = "test-data";
+
+ public readonly DynamoDbContainer Container =
+ new DynamoDbBuilder("amazon/dynamodb-local:latest").Build();
+
+ public static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
+
+ public IAmazonDynamoDB Client
+ {
+ get
+ {
+ field ??= new AmazonDynamoDBClient(
+ new AmazonDynamoDBConfig { ServiceURL = Container.GetConnectionString() });
+ return field;
+ }
+ }
+
+ public async ValueTask DisposeAsync() => await Container.StopAsync(CancellationToken);
+
+ public async ValueTask InitializeAsync()
+ {
+ await Container.StartAsync(CancellationToken);
+
+ await Client.CreateTableAsync(
+ new CreateTableRequest
+ {
+ TableName = TableName,
+ BillingMode = BillingMode.PAY_PER_REQUEST,
+ AttributeDefinitions =
+ [
+ new AttributeDefinition("pk", ScalarAttributeType.S),
+ new AttributeDefinition("sk", ScalarAttributeType.S),
+ ],
+ KeySchema =
+ [
+ new KeySchemaElement("pk", KeyType.HASH),
+ new KeySchemaElement("sk", KeyType.RANGE),
+ ],
+ },
+ CancellationToken);
+
+ var writeRequests =
+ TestDataSamples
+ .UserProfiles
+ .Select(UserProfiles.ToItem)
+ .Concat(TestDataSamples.ProjectRecords.Select(ProjectRecords.ToItem))
+ .Concat(TestDataSamples.TaskRecords.Select(TaskRecords.ToItem))
+ .Select(item => new WriteRequest { PutRequest = new PutRequest { Item = item } })
+ .ToArray();
+
+ foreach (var batch in writeRequests.Chunk(25))
+ await WriteBatchUntilCompleteAsync(batch);
+ }
+
+ private async Task WriteBatchUntilCompleteAsync(IReadOnlyCollection batch)
+ {
+ var pending = batch.ToArray();
+
+ while (pending.Length > 0)
+ {
+ var response = await Client.BatchWriteItemAsync(
+ new BatchWriteItemRequest
+ {
+ RequestItems =
+ new Dictionary>
+ {
+ [TableName] = pending.ToList(),
+ },
+ },
+ CancellationToken);
+
+ pending = response.UnprocessedItems.TryGetValue(TableName, out var unprocessed)
+ ? unprocessed.ToArray()
+ : [];
+ }
+ }
+}
diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj
new file mode 100644
index 00000000..dfecfd25
--- /dev/null
+++ b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net10.0
+ enable
+ Exe
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs
new file mode 100644
index 00000000..90da8a79
--- /dev/null
+++ b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs
@@ -0,0 +1,28 @@
+using Amazon.DynamoDBv2.Model;
+using LayeredCraft.DynamoMapper.Runtime;
+
+namespace LayeredCraft.DynamoMapper.Client.Tests;
+
+[DynamoMapper]
+public partial class UserProfileMapper : IDynamoMapper
+{
+ public partial Dictionary ToItem(UserProfile source);
+
+ public partial UserProfile FromItem(Dictionary item);
+}
+
+[DynamoMapper]
+public partial class ProjectRecordMapper : IDynamoMapper
+{
+ public partial Dictionary ToItem(ProjectRecord source);
+
+ public partial ProjectRecord FromItem(Dictionary item);
+}
+
+[DynamoMapper]
+public partial class TaskRecordMapper : IDynamoMapper
+{
+ public partial Dictionary ToItem(TaskRecord source);
+
+ public partial TaskRecord FromItem(Dictionary item);
+}
diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs
new file mode 100644
index 00000000..0c2193e8
--- /dev/null
+++ b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs
@@ -0,0 +1,308 @@
+namespace LayeredCraft.DynamoMapper.Client.Tests;
+
+public sealed class UserProfile
+{
+ public required string Pk { get; init; }
+
+ public required string Sk { get; init; }
+
+ public required string EntityType { get; init; }
+
+ public required string UserId { get; init; }
+
+ public required string Email { get; init; }
+
+ public required string DisplayName { get; init; }
+
+ public int Age { get; init; }
+
+ public bool IsActive { get; init; }
+
+ public decimal AccountBalance { get; init; }
+
+ public required string CreatedAt { get; init; }
+
+ public long LastLoginEpoch { get; init; }
+
+ public required List Tags { get; init; }
+
+ public required UserPreferences Preferences { get; init; }
+
+ public required List LoginHistory { get; init; }
+
+ public required byte[] ProfilePhoto { get; init; }
+}
+
+public sealed class ProjectRecord
+{
+ public required string Pk { get; init; }
+
+ public required string Sk { get; init; }
+
+ public required string EntityType { get; init; }
+
+ public required string ProjectId { get; init; }
+
+ public required string OwnerUserId { get; init; }
+
+ public required string Name { get; init; }
+
+ public required string Description { get; init; }
+
+ public decimal Budget { get; init; }
+
+ public bool IsArchived { get; init; }
+
+ public int Priority { get; init; }
+
+ public required string StartDate { get; init; }
+
+ public required string DueDate { get; init; }
+
+ public required List Labels { get; init; }
+
+ public required ProjectSettings Settings { get; init; }
+
+ public required ProjectMetrics Metrics { get; init; }
+}
+
+public sealed class TaskRecord
+{
+ public required string Pk { get; init; }
+
+ public required string Sk { get; init; }
+
+ public required string EntityType { get; init; }
+
+ public required string TaSkId { get; init; }
+
+ public required string ProjectId { get; init; }
+
+ public required string AssignedUserId { get; init; }
+
+ public required string Title { get; init; }
+
+ public required string Notes { get; init; }
+
+ public decimal EstimateHours { get; init; }
+
+ public bool Completed { get; init; }
+
+ public int Order { get; init; }
+
+ public required string CreatedAt { get; init; }
+
+ public required string DueAt { get; init; }
+
+ public required List Checklist { get; init; }
+
+ public required TaSkMetadata Metadata { get; init; }
+}
+
+public sealed class UserPreferences
+{
+ public required string Theme { get; init; }
+
+ public bool NotificationsEnabled { get; init; }
+
+ public required string Language { get; init; }
+}
+
+public sealed class LoginHistoryEntry
+{
+ public required string At { get; init; }
+
+ public required string IpAddress { get; init; }
+}
+
+public sealed class ProjectSettings
+{
+ public required string Visibility { get; init; }
+
+ public bool AllowGuestComments { get; init; }
+}
+
+public sealed class ProjectMetrics
+{
+ public int TaSkCount { get; init; }
+
+ public int CompletedTaSkCount { get; init; }
+}
+
+public sealed class TaSkChecklistItem
+{
+ public required string Text { get; init; }
+
+ public bool Done { get; init; }
+}
+
+public sealed class TaSkMetadata
+{
+ public required string Color { get; init; }
+
+ public string? BlockedBy { get; init; }
+}
+
+public static class TestDataSamples
+{
+ public static IReadOnlyList UserProfiles { get; } =
+ [
+ new()
+ {
+ Pk = "USER#u-1001",
+ Sk = "PROFILE#u-1001",
+ EntityType = "UserProfile",
+ UserId = "u-1001",
+ Email = "alex.carter@example.com",
+ DisplayName = "Alex Carter",
+ Age = 34,
+ IsActive = true,
+ AccountBalance = 1520.75m,
+ CreatedAt = "2025-01-10T09:15:00Z",
+ LastLoginEpoch = 1739529600,
+ Tags = ["admin", "beta", "us-east-1"],
+ Preferences =
+ new UserPreferences
+ {
+ Theme = "dark", NotificationsEnabled = true, Language = "en-US",
+ },
+ LoginHistory =
+ [
+ new LoginHistoryEntry
+ {
+ At = "2025-02-11T08:00:00Z", IpAddress = "203.0.113.10",
+ },
+ new LoginHistoryEntry
+ {
+ At = "2025-02-12T18:45:00Z", IpAddress = "203.0.113.11",
+ },
+ ],
+ ProfilePhoto = [1, 2, 3, 4, 5],
+ },
+ new()
+ {
+ Pk = "USER#u-1002",
+ Sk = "PROFILE#u-1002",
+ EntityType = "UserProfile",
+ UserId = "u-1002",
+ Email = "maya.chen@example.com",
+ DisplayName = "Maya Chen",
+ Age = 29,
+ IsActive = false,
+ AccountBalance = 87.40m,
+ CreatedAt = "2024-11-03T14:20:00Z",
+ LastLoginEpoch = 1738771200,
+ Tags = ["designer", "trial"],
+ Preferences =
+ new UserPreferences
+ {
+ Theme = "light", NotificationsEnabled = false, Language = "en-GB",
+ },
+ LoginHistory =
+ [
+ new LoginHistoryEntry
+ {
+ At = "2025-01-28T12:15:00Z", IpAddress = "198.51.100.25",
+ },
+ new LoginHistoryEntry
+ {
+ At = "2025-02-05T07:32:00Z", IpAddress = "198.51.100.44",
+ },
+ ],
+ ProfilePhoto = [10, 20, 30, 40],
+ },
+ ];
+
+ public static IReadOnlyList ProjectRecords { get; } =
+ [
+ new()
+ {
+ Pk = "USER#u-1001",
+ Sk = "PROJECT#p-2001",
+ EntityType = "ProjectRecord",
+ ProjectId = "p-2001",
+ OwnerUserId = "u-1001",
+ Name = "Apollo Migration",
+ Description = "Move customer workflows to the new platform.",
+ Budget = 125000.00m,
+ IsArchived = false,
+ Priority = 1,
+ StartDate = "2025-02-01",
+ DueDate = "2025-06-30",
+ Labels = ["migration", "high-priority", "enterprise"],
+ Settings =
+ new ProjectSettings { Visibility = "private", AllowGuestComments = false },
+ Metrics = new ProjectMetrics { TaSkCount = 18, CompletedTaSkCount = 7 },
+ },
+ new()
+ {
+ Pk = "USER#u-1002",
+ Sk = "PROJECT#p-2002",
+ EntityType = "ProjectRecord",
+ ProjectId = "p-2002",
+ OwnerUserId = "u-1002",
+ Name = "Website Refresh",
+ Description = "Update marketing pages and design tokens.",
+ Budget = 18000.50m,
+ IsArchived = false,
+ Priority = 2,
+ StartDate = "2025-03-15",
+ DueDate = "2025-05-01",
+ Labels = ["design", "marketing"],
+ Settings =
+ new ProjectSettings { Visibility = "team", AllowGuestComments = true },
+ Metrics = new ProjectMetrics { TaSkCount = 9, CompletedTaSkCount = 3 },
+ },
+ ];
+
+ public static IReadOnlyList TaskRecords { get; } =
+ [
+ new()
+ {
+ Pk = "PROJECT#p-2001",
+ Sk = "TASK#t-3001",
+ EntityType = "TaskRecord",
+ TaSkId = "t-3001",
+ ProjectId = "p-2001",
+ AssignedUserId = "u-1001",
+ Title = "Audit existing integrations",
+ Notes = "Document external dependencies and rate limits.",
+ EstimateHours = 6.5m,
+ Completed = true,
+ Order = 1,
+ CreatedAt = "2025-02-02T10:00:00Z",
+ DueAt = "2025-02-05T17:00:00Z",
+ Checklist =
+ [
+ new TaSkChecklistItem { Text = "List current providers", Done = true },
+ new TaSkChecklistItem { Text = "Capture auth mechanisms", Done = true },
+ ],
+ Metadata = new TaSkMetadata { Color = "green", BlockedBy = null },
+ },
+ new()
+ {
+ Pk = "PROJECT#p-2002",
+ Sk = "TASK#t-3002",
+ EntityType = "TaskRecord",
+ TaSkId = "t-3002",
+ ProjectId = "p-2002",
+ AssignedUserId = "u-1002",
+ Title = "Create homepage mockups",
+ Notes = "Deliver desktop and mobile variants for review.",
+ EstimateHours = 12.0m,
+ Completed = false,
+ Order = 2,
+ CreatedAt = "2025-03-16T09:30:00Z",
+ DueAt = "2025-03-20T16:00:00Z",
+ Checklist =
+ [
+ new TaSkChecklistItem { Text = "Collect brand assets", Done = true },
+ new TaSkChecklistItem { Text = "Draft hero section", Done = false },
+ ],
+ Metadata = new TaSkMetadata
+ {
+ Color = "orange", BlockedBy = "Awaiting stakeholder feedback",
+ },
+ },
+ ];
+}
diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json b/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json
new file mode 100644
index 00000000..c2f84268
--- /dev/null
+++ b/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}