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
26 changes: 26 additions & 0 deletions docs/examples/order-identity-map-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Order Identity Map Pattern

This example loads an order through a repository-backed workflow and uses an Identity Map to make repeated loads of the same key return the same object instance.

## What It Demonstrates

- fluent `IdentityMap<TEntity,TKey>` creation
- generated identity-map factory with `[GenerateIdentityMap]`
- duplicate key rejection
- request-scoped `IServiceCollection` registration
- repository integration for repeated loads

## Import

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

using var provider = services.BuildServiceProvider(validateScopes: true);
using var scope = provider.CreateScope();

var workflow = scope.ServiceProvider.GetRequiredService<OrderIdentityMapWorkflow>();
var summary = workflow.Run();
```

The registered `IIdentityMap<TrackedOrder,string>` is scoped so each request or unit of work gets its own identity cache.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@
- name: Order Data Mapper Pattern
href: order-data-mapper-pattern.md

- name: Order Identity Map Pattern
href: order-identity-map-pattern.md

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

Expand Down
27 changes: 27 additions & 0 deletions docs/generators/identity-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Identity Map Generator

`IdentityMapGenerator` emits a typed `IdentityMap<TEntity,TKey>` factory from a key selector.

```csharp
[GenerateIdentityMap(typeof(TrackedOrder), typeof(string), FactoryName = "CreateMap")]
public static partial class GeneratedOrderIdentityMap
{
[IdentityMapKeySelector]
private static string SelectKey(TrackedOrder order) => order.OrderId;
}
```

## Diagnostics

| ID | Severity | Message |
| --- | --- | --- |
| `PKIM001` | Error | The host type must be partial. |
| `PKIM002` | Error | Exactly one `[IdentityMapKeySelector]` method is required. |
| `PKIM003` | Error | The key selector must be static, non-generic, return `TKey`, and accept one `TEntity`. |

Register generated maps as scoped services when used with normal .NET hosts:

```csharp
services.AddScoped<IIdentityMap<TrackedOrder, string>>(_ =>
GeneratedOrderIdentityMap.CreateMap());
```
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**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]` |
| [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` |
| [**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 @@ -58,6 +58,9 @@
- name: Interpreter
href: interpreter.md

- name: Identity Map
href: identity-map.md

- name: Iterator
href: iterator.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 @@ -70,6 +70,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| 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 | Identity Map | `IdentityMap<TEntity,TKey>` | Identity Map generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
27 changes: 27 additions & 0 deletions docs/patterns/application/identity-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Identity Map

Identity Map preserves object identity inside a request, unit of work, or other application scope. Use it when repeated loads of the same key should return the same object instance and duplicate tracked instances should be rejected.

## Fluent Path

```csharp
var map = IdentityMap<Order, string>.Create(order => order.OrderId)
.UseComparer(StringComparer.OrdinalIgnoreCase)
.Build();

var order = map.GetOrAdd("order-100", key => repository.Load(key));
var same = map.GetOrAdd("order-100", key => repository.Load(key));
```

`same` is the same object reference as `order`. `Track` returns `IdentityMapResult<T>` so duplicate-key conflicts are explicit.

## DI Usage

Register identity maps as scoped services:

```csharp
services.AddScoped<IIdentityMap<Order, string>>(_ =>
IdentityMap<Order, string>.Create(order => order.OrderId).Build());
```

See [Order Identity Map Pattern](../../examples/order-identity-map-pattern.md).
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@
href: application/unit-of-work.md
- name: Data Mapper
href: application/data-mapper.md
- name: Identity Map
href: application/identity-map.md
- name: Specification
href: application/specification.md
- name: Type-Dispatcher
Expand Down
154 changes: 154 additions & 0 deletions src/PatternKit.Core/Application/IdentityMap/IdentityMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
namespace PatternKit.Application.IdentityMap;

/// <summary>Request or unit-of-work scoped cache that preserves object identity by key.</summary>
public interface IIdentityMap<TEntity, TKey>
where TKey : notnull
{
int Count { get; }

TEntity? Get(TKey key);

IdentityMapResult<TEntity> Track(TKey key, TEntity entity);

TEntity GetOrAdd(TKey key, Func<TKey, TEntity> factory);

bool Remove(TKey key);

void Clear();
}

/// <summary>In-memory Identity Map for preserving object identity in a scope.</summary>
public sealed class IdentityMap<TEntity, TKey> : IIdentityMap<TEntity, TKey>
where TKey : notnull
{
private readonly Dictionary<TKey, TEntity> _entities;
private readonly Func<TEntity, TKey>? _keySelector;

private IdentityMap(Func<TEntity, TKey>? keySelector, IEqualityComparer<TKey>? comparer)
{
_keySelector = keySelector;
_entities = new Dictionary<TKey, TEntity>(comparer);
}

public int Count => _entities.Count;

public static Builder Create(Func<TEntity, TKey>? keySelector = null)
=> new(keySelector);

public TEntity? Get(TKey key)
{
if (key is null)
throw new ArgumentNullException(nameof(key));

_entities.TryGetValue(key, out var entity);
return entity;
}

public IdentityMapResult<TEntity> Track(TEntity entity)
{
if (_keySelector is null)
throw new InvalidOperationException("A key selector is required to track entities without an explicit key.");

return Track(_keySelector(entity), entity);
}

public IdentityMapResult<TEntity> Track(TKey key, TEntity entity)
{
if (key is null)
throw new ArgumentNullException(nameof(key));
if (entity is null)
throw new ArgumentNullException(nameof(entity));

if (_entities.TryGetValue(key, out var existing))
{
return ReferenceEquals(existing, entity)
? IdentityMapResult<TEntity>.Existing(existing)
: IdentityMapResult<TEntity>.Conflict(existing, "A different entity instance is already tracked for this key.");
}

_entities.Add(key, entity);
return IdentityMapResult<TEntity>.Tracked(entity);
}

public TEntity GetOrAdd(TKey key, Func<TKey, TEntity> factory)
{
if (key is null)
throw new ArgumentNullException(nameof(key));
if (factory is null)
throw new ArgumentNullException(nameof(factory));

if (_entities.TryGetValue(key, out var existing))
return existing;

var created = factory(key);
_entities.Add(key, created);
return created;
}

public bool Remove(TKey key)
{
if (key is null)
throw new ArgumentNullException(nameof(key));

return _entities.Remove(key);
}

public void Clear() => _entities.Clear();

public sealed class Builder
{
private readonly Func<TEntity, TKey>? _keySelector;
private IEqualityComparer<TKey>? _comparer;

internal Builder(Func<TEntity, TKey>? keySelector)
{
_keySelector = keySelector;
}

public Builder UseComparer(IEqualityComparer<TKey> comparer)
{
_comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
return this;
}

public IdentityMap<TEntity, TKey> Build()
=> new(_keySelector, _comparer);
}
}

/// <summary>Result returned when tracking an entity in an Identity Map.</summary>
public sealed class IdentityMapResult<TEntity>
{
private IdentityMapResult(TEntity entity, IdentityMapStatus status, string? reason)
{
Entity = entity;
Status = status;
Reason = reason;
}

public TEntity Entity { get; }

public IdentityMapStatus Status { get; }

public string? Reason { get; }

public bool Succeeded => Status is IdentityMapStatus.Tracked or IdentityMapStatus.Existing;

public static IdentityMapResult<TEntity> Tracked(TEntity entity)
=> new(entity, IdentityMapStatus.Tracked, null);

public static IdentityMapResult<TEntity> Existing(TEntity entity)
=> new(entity, IdentityMapStatus.Existing, null);

public static IdentityMapResult<TEntity> Conflict(TEntity entity, string reason)
=> new(entity, IdentityMapStatus.Conflict, string.IsNullOrWhiteSpace(reason)
? throw new ArgumentException("Identity Map conflict reason is required.", nameof(reason))
: reason);
}

public enum IdentityMapStatus
{
Tracked,
Existing,
Conflict
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
using PatternKit.Examples.FlyweightDemo;
using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo;
using PatternKit.Examples.Generators.Visitors;
using PatternKit.Examples.IdentityMapDemo;
using PatternKit.Examples.MementoDemo;
using PatternKit.Examples.Messaging;
using PatternKit.Examples.ObserverDemo;
Expand Down Expand Up @@ -126,6 +127,7 @@ public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry<Loa
public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow);
public sealed record CheckoutUnitOfWorkPatternExample(CheckoutUnitOfWorkDemoRunner Runner, CheckoutUnitOfWorkWorkflow Workflow);
public sealed record OrderDataMapperPatternExample(OrderDataMapperDemoRunner Runner, OrderDataMapperWorkflow Workflow);
public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner Runner);
public sealed record PrototypeGameCharacterFactoryExample(Prototype<string, PrototypeDemo.PrototypeDemo.GameCharacter> Factory);
public sealed record ProxyPatternDemonstrationsExample(Proxy<int, string> RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy);
public sealed record FlyweightGlyphCacheExample(Func<string, IReadOnlyList<(FlyweightDemo.FlyweightDemo.Glyph Glyph, int X)>> RenderSentence);
Expand Down Expand Up @@ -185,6 +187,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddOrderRepositoryPatternExample()
.AddCheckoutUnitOfWorkPatternExample()
.AddOrderDataMapperPatternExample()
.AddOrderIdentityMapPatternExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -544,6 +547,13 @@ public static IServiceCollection AddOrderDataMapperPatternExample(this IServiceC
return services.RegisterExample<OrderDataMapperPatternExample>("Order Data Mapper Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddOrderIdentityMapPatternExample(this IServiceCollection services)
{
services.AddOrderIdentityMapDemo();
services.AddSingleton<OrderIdentityMapPatternExample>(sp => new(sp.GetRequiredService<OrderIdentityMapDemoRunner>()));
return services.RegisterExample<OrderIdentityMapPatternExample>("Order Identity Map Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services)
{
services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory());
Expand Down
77 changes: 77 additions & 0 deletions src/PatternKit.Examples/IdentityMapDemo/OrderIdentityMapDemo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Microsoft.Extensions.DependencyInjection;
using PatternKit.Application.IdentityMap;
using PatternKit.Application.Repository;
using PatternKit.Generators.IdentityMap;

namespace PatternKit.Examples.IdentityMapDemo;

public static class OrderIdentityMapDemo
{
public static OrderIdentityMapSummary RunFluent()
{
var map = OrderIdentityMapPolicies.CreateFluentMap();
return LoadTwice(map);
}

public static OrderIdentityMapSummary RunGenerated()
{
var map = GeneratedOrderIdentityMap.CreateMap();
return LoadTwice(map);
}

internal static OrderIdentityMapSummary LoadTwice(IIdentityMap<TrackedOrder, string> map)
{
var repository = InMemoryRepository<TrackedOrder, string>.Create(static order => order.OrderId).Build();
_ = repository.AddAsync(new TrackedOrder("order-100", "customer-1", 125m)).AsTask().GetAwaiter().GetResult();

var first = map.GetOrAdd("order-100", key => repository.GetAsync(key).AsTask().GetAwaiter().GetResult()!);
var second = map.GetOrAdd("order-100", key => repository.GetAsync(key).AsTask().GetAwaiter().GetResult()!);
var conflict = map.Track("order-100", new TrackedOrder("order-100", "customer-1", 999m));

return new(ReferenceEquals(first, second), conflict.Status == IdentityMapStatus.Conflict, map.Count, second.Total);
}
}

public sealed record TrackedOrder(string OrderId, string CustomerId, decimal Total);

public sealed record OrderIdentityMapSummary(bool ReusedInstance, bool DuplicateRejected, int TrackedCount, decimal Total);

public static class OrderIdentityMapPolicies
{
public static IdentityMap<TrackedOrder, string> CreateFluentMap()
=> IdentityMap<TrackedOrder, string>.Create(static order => order.OrderId)
.UseComparer(StringComparer.OrdinalIgnoreCase)
.Build();
}

public sealed class OrderIdentityMapWorkflow
{
private readonly IIdentityMap<TrackedOrder, string> _map;

public OrderIdentityMapWorkflow(IIdentityMap<TrackedOrder, string> map)
{
_map = map;
}

public OrderIdentityMapSummary Run() => OrderIdentityMapDemo.LoadTwice(_map);
}

public sealed record OrderIdentityMapDemoRunner(Func<OrderIdentityMapSummary> RunFluent, Func<OrderIdentityMapSummary> RunGenerated);

public static class OrderIdentityMapServiceCollectionExtensions
{
public static IServiceCollection AddOrderIdentityMapDemo(this IServiceCollection services)
{
services.AddScoped<IIdentityMap<TrackedOrder, string>>(_ => OrderIdentityMapPolicies.CreateFluentMap());
services.AddScoped<OrderIdentityMapWorkflow>();
services.AddSingleton(new OrderIdentityMapDemoRunner(OrderIdentityMapDemo.RunFluent, OrderIdentityMapDemo.RunGenerated));
return services;
}
}

[GenerateIdentityMap(typeof(TrackedOrder), typeof(string), FactoryName = "CreateMap")]
public static partial class GeneratedOrderIdentityMap
{
[IdentityMapKeySelector]
private static string SelectKey(TrackedOrder order) => order.OrderId;
}
Loading
Loading