Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/examples/order-data-mapper-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Order Data Mapper Pattern

This example maps an `OrderAggregate` domain model to an `OrderRow` persistence record and back again, then stores the row through the Repository pattern.

## What It Demonstrates

- fluent `DataMapper<OrderAggregate,OrderRow>` construction
- generated mapper factory with `[GenerateDataMapper]`
- validation errors for invalid domain/data inputs
- repository integration after mapping
- `IServiceCollection` import through `AddOrderDataMapperDemo()`

## Import

```csharp
var services = new ServiceCollection();
services.AddOrderDataMapperDemo();

using var provider = services.BuildServiceProvider(validateScopes: true);
var workflow = provider.GetRequiredService<OrderDataMapperWorkflow>();
var summary = await workflow.RunAsync();
```

The workflow uses `IDataMapper<OrderAggregate,OrderRow>` so applications can replace the mapper with the generated factory, an audited fluent mapper, or a test double.

## Source

- `src/PatternKit.Examples/DataMapperDemo/OrderDataMapperDemo.cs`
- `test/PatternKit.Examples.Tests/DataMapperDemo/OrderDataMapperDemoTests.cs`
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
- name: Checkout Unit of Work Pattern
href: checkout-unit-of-work-pattern.md

- name: Order Data Mapper Pattern
href: order-data-mapper-pattern.md

- name: Generated Mailbox
href: generated-mailbox.md

Expand Down
40 changes: 40 additions & 0 deletions docs/generators/data-mapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Data Mapper Generator

`DataMapperGenerator` emits a fluent `DataMapper<TDomain,TData>` factory from two attributed projection methods.

```csharp
[GenerateDataMapper(typeof(OrderAggregate), typeof(OrderRow), FactoryName = "CreateMapper")]
public static partial class GeneratedOrderDataMapper
{
[DataMapperToData]
private static OrderRow ToData(OrderAggregate order)
=> new(order.OrderId, order.CustomerId, order.Total, "PAID");

[DataMapperToDomain]
private static OrderAggregate ToDomain(OrderRow row)
=> new(row.OrderId, row.BuyerId, row.TotalAmount, OrderState.Paid);
}
```

The generated method returns a normal runtime mapper:

```csharp
DataMapper<OrderAggregate, OrderRow> mapper = GeneratedOrderDataMapper.CreateMapper();
```

## Diagnostics

| ID | Severity | Message |
| --- | --- | --- |
| `PKMAP001` | Error | The host type must be partial. |
| `PKMAP002` | Error | Exactly one `[DataMapperToData]` and one `[DataMapperToDomain]` method are required. |
| `PKMAP003` | Error | Projection methods must be static, non-generic, return the target type, and accept one source parameter. |

## DI Usage

```csharp
services.AddSingleton<IDataMapper<OrderAggregate, OrderRow>>(_ =>
GeneratedOrderDataMapper.CreateMapper());
```

See [Order Data Mapper Pattern](../examples/order-data-mapper-pattern.md) for the full importable example.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Repository**](repository.md) | In-memory repository factories from key selectors | `[GenerateRepository]` |
| [**Anti-Corruption Layer**](anti-corruption-layer.md) | External-to-domain translation boundaries with validation | `[GenerateAntiCorruptionLayer]` |
| [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` |
| [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` |
| [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` |
| [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` |

Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
- name: Decorator
href: decorator.md

- name: Data Mapper
href: data-mapper.md

- name: Dispatcher
href: dispatcher.md

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |
| Application Architecture | Repository | `IRepository<TEntity,TKey>` and `InMemoryRepository<TEntity,TKey>` | Repository generator |
| Application Architecture | Unit of Work | `UnitOfWork` | Unit of Work generator |
| Application Architecture | Data Mapper | `DataMapper<TDomain,TData>` | Data Mapper generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
25 changes: 25 additions & 0 deletions docs/patterns/application/data-mapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Data Mapper

Data Mapper keeps domain objects independent from persistence or transport records. Use it when a database row, API DTO, or integration shape should not leak into your domain model.

## Fluent Path

```csharp
var mapper = DataMapper<OrderAggregate, OrderRow>.Create()
.MapToData(order => new OrderRow(order.OrderId, order.CustomerId, order.Total, "PAID"))
.MapToDomain(row => new OrderAggregate(row.OrderId, row.BuyerId, row.TotalAmount, OrderState.Paid))
.ValidateDomain(order => string.IsNullOrWhiteSpace(order.OrderId)
? new DataMapperError("order-id-required", "Order id is required.")
: null)
.Build();
```

The mapper returns `DataMapperResult<T>` so callers can distinguish successful mappings from validation failures without throwing for business validation.

## Integration Notes

- Register `IDataMapper<TDomain,TData>` in `IServiceCollection` at the scope that matches the persistence boundary.
- Keep mapping rules deterministic and side-effect free; use repositories or gateways after mapping completes.
- Add validation hooks for invariants that must be true before crossing the domain/data boundary.

See [Order Data Mapper Pattern](../../examples/order-data-mapper-pattern.md) for a repository-backed example.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@
href: application/repository.md
- name: Unit of Work
href: application/unit-of-work.md
- name: Data Mapper
href: application/data-mapper.md
- name: Specification
href: application/specification.md
- name: Type-Dispatcher
Expand Down
192 changes: 192 additions & 0 deletions src/PatternKit.Core/Application/DataMapping/DataMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
namespace PatternKit.Application.DataMapping;

/// <summary>
/// Maps between an isolated domain model and a persistence or transport data model.
/// </summary>
public interface IDataMapper<TDomain, TData>
{
/// <summary>Maps a domain model to its data representation.</summary>
ValueTask<DataMapperResult<TData>> ToDataAsync(TDomain domain, CancellationToken cancellationToken = default);

/// <summary>Maps a data representation to its domain model.</summary>
ValueTask<DataMapperResult<TDomain>> ToDomainAsync(TData data, CancellationToken cancellationToken = default);
}

/// <summary>
/// Fluent Data Mapper builder for production composition, examples, and tests.
/// </summary>
public sealed class DataMapper<TDomain, TData> : IDataMapper<TDomain, TData>
{
private readonly Func<TDomain, TData> _toData;
private readonly Func<TData, TDomain> _toDomain;
private readonly IReadOnlyList<Func<TDomain, DataMapperError?>> _domainValidators;
private readonly IReadOnlyList<Func<TData, DataMapperError?>> _dataValidators;

private DataMapper(
Func<TDomain, TData> toData,
Func<TData, TDomain> toDomain,
IReadOnlyList<Func<TDomain, DataMapperError?>> domainValidators,
IReadOnlyList<Func<TData, DataMapperError?>> dataValidators)
{
_toData = toData;
_toDomain = toDomain;
_domainValidators = domainValidators;
_dataValidators = dataValidators;
}

/// <summary>Creates a Data Mapper builder.</summary>
public static Builder Create() => new();

/// <inheritdoc />
public ValueTask<DataMapperResult<TData>> ToDataAsync(TDomain domain, CancellationToken cancellationToken = default)
{
if (domain is null)
throw new ArgumentNullException(nameof(domain));

cancellationToken.ThrowIfCancellationRequested();
var sourceErrors = Validate(_domainValidators, domain);
if (sourceErrors.Count > 0)
return new ValueTask<DataMapperResult<TData>>(DataMapperResult<TData>.Failed(sourceErrors));

var data = _toData(domain);
var mappedErrors = Validate(_dataValidators, data);
return new ValueTask<DataMapperResult<TData>>(mappedErrors.Count == 0
? DataMapperResult<TData>.Mapped(data)
: DataMapperResult<TData>.Failed(mappedErrors));
}

/// <inheritdoc />
public ValueTask<DataMapperResult<TDomain>> ToDomainAsync(TData data, CancellationToken cancellationToken = default)
{
if (data is null)
throw new ArgumentNullException(nameof(data));

cancellationToken.ThrowIfCancellationRequested();
var sourceErrors = Validate(_dataValidators, data);
if (sourceErrors.Count > 0)
return new ValueTask<DataMapperResult<TDomain>>(DataMapperResult<TDomain>.Failed(sourceErrors));

var domain = _toDomain(data);
var mappedErrors = Validate(_domainValidators, domain);
return new ValueTask<DataMapperResult<TDomain>>(mappedErrors.Count == 0
? DataMapperResult<TDomain>.Mapped(domain)
: DataMapperResult<TDomain>.Failed(mappedErrors));
}

private static IReadOnlyList<DataMapperError> Validate<T>(
IReadOnlyList<Func<T, DataMapperError?>> validators,
T value)
{
if (validators.Count == 0)
return Array.Empty<DataMapperError>();

var errors = new List<DataMapperError>();
foreach (var validator in validators)
{
var error = validator(value);
if (error is not null)
errors.Add(error);
}

return errors;
}

/// <summary>Fluent builder for Data Mapper instances.</summary>
public sealed class Builder
{
private readonly List<Func<TDomain, DataMapperError?>> _domainValidators = [];
private readonly List<Func<TData, DataMapperError?>> _dataValidators = [];
private Func<TDomain, TData>? _toData;
private Func<TData, TDomain>? _toDomain;

/// <summary>Configures the domain-to-data projection.</summary>
public Builder MapToData(Func<TDomain, TData> mapper)
{
_toData = mapper ?? throw new ArgumentNullException(nameof(mapper));
return this;
}

/// <summary>Configures the data-to-domain projection.</summary>
public Builder MapToDomain(Func<TData, TDomain> mapper)
{
_toDomain = mapper ?? throw new ArgumentNullException(nameof(mapper));
return this;
}

/// <summary>Adds validation that runs against domain models.</summary>
public Builder ValidateDomain(Func<TDomain, DataMapperError?> validator)
{
_domainValidators.Add(validator ?? throw new ArgumentNullException(nameof(validator)));
return this;
}

/// <summary>Adds validation that runs against data models.</summary>
public Builder ValidateData(Func<TData, DataMapperError?> validator)
{
_dataValidators.Add(validator ?? throw new ArgumentNullException(nameof(validator)));
return this;
}

/// <summary>Builds the mapper.</summary>
public DataMapper<TDomain, TData> Build()
=> new(
_toData ?? throw new InvalidOperationException("A domain-to-data mapper is required."),
_toDomain ?? throw new InvalidOperationException("A data-to-domain mapper is required."),
_domainValidators.ToArray(),
_dataValidators.ToArray());
}
}

/// <summary>Result returned by a Data Mapper operation.</summary>
public sealed class DataMapperResult<T>
{
private DataMapperResult(T? value, IReadOnlyList<DataMapperError> errors)
{
Value = value;
Errors = errors;
}

/// <summary>The mapped value when the operation succeeds.</summary>
public T? Value { get; }

/// <summary>Validation errors that prevented mapping.</summary>
public IReadOnlyList<DataMapperError> Errors { get; }

/// <summary>Gets whether the mapping completed without validation errors.</summary>
public bool Succeeded => Errors.Count == 0;

/// <summary>Creates a successful mapping result.</summary>
public static DataMapperResult<T> Mapped(T value)
=> new(value, Array.Empty<DataMapperError>());

/// <summary>Creates a failed mapping result.</summary>
public static DataMapperResult<T> Failed(IReadOnlyList<DataMapperError> errors)
{
if (errors is null)
throw new ArgumentNullException(nameof(errors));
if (errors.Count == 0)
throw new ArgumentException("At least one Data Mapper error is required.", nameof(errors));

return new(default, errors);
}
}

/// <summary>Validation error returned by a Data Mapper.</summary>
public sealed class DataMapperError
{
public DataMapperError(string code, string message)
{
Code = string.IsNullOrWhiteSpace(code)
? throw new ArgumentException("Data Mapper error code is required.", nameof(code))
: code;
Message = string.IsNullOrWhiteSpace(message)
? throw new ArgumentException("Data Mapper error message is required.", nameof(message))
: message;
}

/// <summary>Stable validation code.</summary>
public string Code { get; }

/// <summary>User-facing or log-facing validation message.</summary>
public string Message { get; }
}
Loading
Loading