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
28 changes: 28 additions & 0 deletions docs/examples/order-table-data-gateway-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Order Table Data Gateway Pattern

This production-shaped example shows a row-oriented order table gateway.

It demonstrates:

- fluent `InMemoryTableDataGateway<OrderTableRow,string>` construction
- generated gateway factory with `[GenerateTableDataGateway]`
- row insert, update, query, and delete behavior
- scoped `ITableDataGateway<OrderTableRow,string>` registration through `IServiceCollection`

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

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

var workflow = scope.ServiceProvider.GetRequiredService<OrderTableGatewayWorkflow>();
var summary = await workflow.CloseAsync("order-100", "customer-1", 125m);
```

The registered gateway is scoped so importing applications can compose it with request-scoped storage adapters, database sessions, tenant services, or transaction boundaries.

Files:

- `src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs`
- `test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs`
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@
- name: Order Domain Event Pattern
href: order-domain-event-pattern.md

- name: Order Table Data Gateway Pattern
href: order-table-data-gateway-pattern.md

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

Expand Down
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` |
| [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` |
| [**Domain Event**](domain-event.md) | Domain event dispatcher factories | `[GenerateDomainEventDispatcher]` |
| [**Table Data Gateway**](table-data-gateway.md) | Row gateway factories from key selectors | `[GenerateTableDataGateway]` |
| [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` |
| [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` |

Expand Down
26 changes: 26 additions & 0 deletions docs/generators/table-data-gateway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Table Data Gateway Generator

`GenerateTableDataGatewayAttribute` creates a typed `InMemoryTableDataGateway<TRow,TKey>` factory from a key selector method.

```csharp
[GenerateTableDataGateway(typeof(OrderTableRow), typeof(string), FactoryName = "CreateGateway", TableName = "orders")]
public static partial class GeneratedOrderTableGateway
{
[TableGatewayKeySelector]
private static string SelectKey(OrderTableRow row) => row.OrderId;
}
```

The generated factory is equivalent to:

```csharp
InMemoryTableDataGateway<OrderTableRow, string>
.Create("orders", SelectKey)
.Build();
```

Diagnostics:

- `PKTDG001`: host type must be partial.
- `PKTDG002`: exactly one `[TableGatewayKeySelector]` method is required.
- `PKTDG003`: key selector must be static and return `TKey` from one `TRow` parameter.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@
- name: State Machine
href: state-machine.md

- name: Table Data Gateway
href: table-data-gateway.md

- name: Strategy
href: strategy.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 @@ -74,6 +74,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Application Architecture | Transaction Script | `TransactionScript<TRequest,TResponse>` | Transaction Script generator |
| Application Architecture | Service Layer | `IServiceOperation<TRequest,TResponse>` and `ServiceLayerOperation<TRequest,TResponse>` | Service Layer generator |
| Application Architecture | Domain Event | `IDomainEvent` and `DomainEventDispatcher<TEventBase>` | Domain Event generator |
| Application Architecture | Table Data Gateway | `ITableDataGateway<TRow,TKey>` and `InMemoryTableDataGateway<TRow,TKey>` | Table Data Gateway generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
24 changes: 24 additions & 0 deletions docs/patterns/application/table-data-gateway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Table Data Gateway

Table Data Gateway models row-level access to a single table or table-like persistence boundary. Use it when an application needs simple row CRUD and query operations without mapping rows into richer domain objects.

PatternKit provides `ITableDataGateway<TRow,TKey>` and `InMemoryTableDataGateway<TRow,TKey>` in `PatternKit.Application.TableDataGateway`.

```csharp
var gateway = InMemoryTableDataGateway<OrderTableRow, string>
.Create("orders", row => row.OrderId)
.Build();

await gateway.InsertAsync(new OrderTableRow("order-100", "customer-1", "Pending", 125m));
await gateway.UpdateAsync(new OrderTableRow("order-100", "customer-1", "Closed", 125m));
var closed = await gateway.QueryAsync(row => row.Status == "Closed");
```

The gateway returns `TableGatewayResult<TRow>` for mutations so callers can distinguish inserted, updated, deleted, conflict, and missing rows.

Use the source-generated path when the table row type and key selector are stable. Register `ITableDataGateway<TRow,TKey>` as scoped when the gateway is composed with request-scoped storage, transaction, or tenant services.

See also:

- [Table Data Gateway generator](../../generators/table-data-gateway.md)
- [Order Table Data Gateway example](../../examples/order-table-data-gateway-pattern.md)
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@
href: application/service-layer.md
- name: Domain Event
href: application/domain-event.md
- name: Table Data Gateway
href: application/table-data-gateway.md
- name: Specification
href: application/specification.md
- name: Type-Dispatcher
Expand Down
174 changes: 174 additions & 0 deletions src/PatternKit.Core/Application/TableDataGateway/TableDataGateway.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
namespace PatternKit.Application.TableDataGateway;

/// <summary>Row-oriented gateway for one table or table-like persistence boundary.</summary>
public interface ITableDataGateway<TRow, TKey>
where TKey : notnull
{
string TableName { get; }

ValueTask<TableGatewayResult<TRow>> InsertAsync(TRow row, CancellationToken cancellationToken = default);

ValueTask<TRow?> GetAsync(TKey key, CancellationToken cancellationToken = default);

ValueTask<IReadOnlyList<TRow>> ListAsync(CancellationToken cancellationToken = default);

ValueTask<IReadOnlyList<TRow>> QueryAsync(Func<TRow, bool> predicate, CancellationToken cancellationToken = default);

ValueTask<TableGatewayResult<TRow>> UpdateAsync(TRow row, CancellationToken cancellationToken = default);

ValueTask<TableGatewayResult<TRow>> DeleteAsync(TKey key, CancellationToken cancellationToken = default);
}

/// <summary>In-memory Table Data Gateway for samples, tests, and embedded applications.</summary>
public sealed class InMemoryTableDataGateway<TRow, TKey> : ITableDataGateway<TRow, TKey>
where TKey : notnull
{
private readonly Dictionary<TKey, TRow> _rows;
private readonly Func<TRow, TKey> _keySelector;

private InMemoryTableDataGateway(string tableName, Func<TRow, TKey> keySelector, IEqualityComparer<TKey>? comparer)
{
TableName = tableName;
_keySelector = keySelector;
_rows = new Dictionary<TKey, TRow>(comparer);
}

public string TableName { get; }

public static Builder Create(string tableName, Func<TRow, TKey> keySelector)
=> new(tableName, keySelector);

public ValueTask<TableGatewayResult<TRow>> InsertAsync(TRow row, CancellationToken cancellationToken = default)
{
if (row is null)
throw new ArgumentNullException(nameof(row));

cancellationToken.ThrowIfCancellationRequested();
var key = _keySelector(row);
if (_rows.ContainsKey(key))
return new(TableGatewayResult<TRow>.Conflict(row, $"Row with key '{key}' already exists in '{TableName}'."));

_rows[key] = row;
return new(TableGatewayResult<TRow>.Inserted(row));
}

public ValueTask<TRow?> GetAsync(TKey key, CancellationToken cancellationToken = default)
{
if (key is null)
throw new ArgumentNullException(nameof(key));

cancellationToken.ThrowIfCancellationRequested();
_rows.TryGetValue(key, out var row);
return new(row);
}

public ValueTask<IReadOnlyList<TRow>> ListAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return new((IReadOnlyList<TRow>)_rows.Values.ToArray());
}

public ValueTask<IReadOnlyList<TRow>> QueryAsync(Func<TRow, bool> predicate, CancellationToken cancellationToken = default)
{
if (predicate is null)
throw new ArgumentNullException(nameof(predicate));

cancellationToken.ThrowIfCancellationRequested();
return new((IReadOnlyList<TRow>)_rows.Values.Where(predicate).ToArray());
}

public ValueTask<TableGatewayResult<TRow>> UpdateAsync(TRow row, CancellationToken cancellationToken = default)
{
if (row is null)
throw new ArgumentNullException(nameof(row));

cancellationToken.ThrowIfCancellationRequested();
var key = _keySelector(row);
if (!_rows.ContainsKey(key))
return new(TableGatewayResult<TRow>.Missing(row, $"Row with key '{key}' was not found in '{TableName}'."));

_rows[key] = row;
return new(TableGatewayResult<TRow>.Updated(row));
}

public ValueTask<TableGatewayResult<TRow>> DeleteAsync(TKey key, CancellationToken cancellationToken = default)
{
if (key is null)
throw new ArgumentNullException(nameof(key));

cancellationToken.ThrowIfCancellationRequested();
if (!_rows.TryGetValue(key, out var row))
return new(TableGatewayResult<TRow>.Missing(default, $"Row with key '{key}' was not found in '{TableName}'."));

_rows.Remove(key);
return new(TableGatewayResult<TRow>.Deleted(row));
}

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

internal Builder(string tableName, Func<TRow, TKey> keySelector)
{
_tableName = string.IsNullOrWhiteSpace(tableName)
? throw new ArgumentException("Table Data Gateway table name is required.", nameof(tableName))
: tableName;
_keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector));
}

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

public InMemoryTableDataGateway<TRow, TKey> Build()
=> new(_tableName, _keySelector, _comparer);
}
}

/// <summary>Result returned by Table Data Gateway mutation operations.</summary>
public sealed class TableGatewayResult<TRow>
{
private TableGatewayResult(TRow? row, TableGatewayStatus status, string? reason)
{
Row = row;
Status = status;
Reason = reason;
}

public TRow? Row { get; }

public TableGatewayStatus Status { get; }

public string? Reason { get; }

public bool Succeeded => Status is TableGatewayStatus.Inserted or TableGatewayStatus.Updated or TableGatewayStatus.Deleted;

public static TableGatewayResult<TRow> Inserted(TRow row) => new(row, TableGatewayStatus.Inserted, null);

public static TableGatewayResult<TRow> Updated(TRow row) => new(row, TableGatewayStatus.Updated, null);

public static TableGatewayResult<TRow> Deleted(TRow row) => new(row, TableGatewayStatus.Deleted, null);

public static TableGatewayResult<TRow> Conflict(TRow row, string reason) => new(row, TableGatewayStatus.Conflict, Validate(reason));

public static TableGatewayResult<TRow> Missing(TRow? row, string reason) => new(row, TableGatewayStatus.Missing, Validate(reason));

private static string Validate(string reason)
=> string.IsNullOrWhiteSpace(reason)
? throw new ArgumentException("Table Data Gateway result reason is required.", nameof(reason))
: reason;
}

/// <summary>Mutation status for Table Data Gateway operations.</summary>
public enum TableGatewayStatus
{
Inserted,
Updated,
Deleted,
Conflict,
Missing
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
using PatternKit.Examples.SpecificationDemo;
using PatternKit.Examples.Strategies.Coercion;
using PatternKit.Examples.Strategies.Composed;
using PatternKit.Examples.TableDataGatewayDemo;
using PatternKit.Examples.TemplateDemo;
using PatternKit.Examples.TransactionScriptDemo;
using PatternKit.Examples.UnitOfWorkDemo;
Expand Down Expand Up @@ -134,6 +135,7 @@ public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner R
public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner Runner);
public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner);
public sealed record OrderDomainEventPatternExample(OrderDomainEventDemoRunner Runner);
public sealed record OrderTableDataGatewayPatternExample(OrderTableDataGatewayDemoRunner 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 @@ -197,6 +199,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddOrderTransactionScriptPatternExample()
.AddCustomerServiceLayerPatternExample()
.AddOrderDomainEventPatternExample()
.AddOrderTableDataGatewayPatternExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -584,6 +587,13 @@ public static IServiceCollection AddOrderDomainEventPatternExample(this IService
return services.RegisterExample<OrderDomainEventPatternExample>("Order Domain Event Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddOrderTableDataGatewayPatternExample(this IServiceCollection services)
{
services.AddOrderTableDataGatewayDemo();
services.AddSingleton<OrderTableDataGatewayPatternExample>(sp => new(sp.GetRequiredService<OrderTableDataGatewayDemoRunner>()));
return services.RegisterExample<OrderTableDataGatewayPatternExample>("Order Table Data Gateway Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services)
{
services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost,
["DomainEvent"],
["aggregate event dispatch", "source-generated dispatcher factory", "DI composition"]),
Descriptor(
"Order Table Data Gateway Pattern",
"src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs",
"test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs",
"docs/examples/order-table-data-gateway-pattern.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost,
["TableDataGateway"],
["row-oriented table boundary", "source-generated gateway factory", "DI composition"]),
Descriptor(
"Generated Mailbox",
"src/PatternKit.Examples/Messaging/MailboxExample.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog
"test/PatternKit.Examples.Tests/DomainEventDemo/OrderDomainEventDemoTests.cs",
["fluent domain event dispatcher", "generated handler registry", "DI-importable projection and audit workflow"]),

Pattern("Table Data Gateway", PatternFamily.ApplicationArchitecture,
"docs/patterns/application/table-data-gateway.md",
"src/PatternKit.Core/Application/TableDataGateway/TableDataGateway.cs",
"test/PatternKit.Tests/Application/TableDataGateway/TableDataGatewayTests.cs",
"docs/generators/table-data-gateway.md",
"src/PatternKit.Generators/TableDataGateway/TableDataGatewayGenerator.cs",
"test/PatternKit.Generators.Tests/TableDataGatewayGeneratorTests.cs",
null,
"docs/examples/order-table-data-gateway-pattern.md",
"src/PatternKit.Examples/TableDataGatewayDemo/OrderTableDataGatewayDemo.cs",
"test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs",
["fluent row gateway", "generated table gateway factory", "DI-importable row workflow"]),

Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture,
"docs/patterns/application/anti-corruption-layer.md",
"src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs",
Expand Down
Loading
Loading