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
3 changes: 3 additions & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ Welcome! This section collects small, focused demos that show **how to compose b
* **Order Canonical Data Model**
Shows fluent and source-generated order normalization into an application-owned canonical contract, with an importable `IServiceCollection` extension. See [Order Canonical Data Model](order-canonical-data-model.md).

* **Inventory Event-Carried State Transfer**
Shows fluent and source-generated inventory projection events that carry enough state to update a local read model, with an importable `IServiceCollection` extension. See [Inventory Event-Carried State Transfer](inventory-event-carried-state-transfer.md).

* **Generated Recipient List**
Shows fluent and source-generated recipient-list fan-out side by side, with an importable `IServiceCollection` extension. See [Generated Recipient List](generated-recipient-list.md).

Expand Down
12 changes: 12 additions & 0 deletions docs/examples/inventory-event-carried-state-transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Inventory Event-Carried State Transfer

The inventory event-carried state transfer example projects `InventoryAdjustedEvent` payloads into an importable inventory read model service.

```csharp
services.AddInventoryEventCarriedStateTransferDemo();

var runner = provider.GetRequiredService<InventoryEventCarriedStateTransferDemoRunner>();
var summary = runner.RunGenerated(new InventoryAdjustedEvent("SKU-100", 12, "CHI-01", 4));
```

The example includes fluent and source-generated construction plus an `IServiceCollection` extension for standard .NET hosts.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@
- name: Order Canonical Data Model
href: order-canonical-data-model.md

- name: Inventory Event-Carried State Transfer
href: inventory-event-carried-state-transfer.md

- name: Generated Claim Check
href: generated-claim-check.md

Expand Down
26 changes: 26 additions & 0 deletions docs/generators/event-carried-state-transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Event-Carried State Transfer Generator

`[GenerateEventCarriedStateTransfer]` creates a typed `EventCarriedStateTransfer<TEvent, TKey, TState>` factory from key, version, and state mapper methods.

```csharp
[GenerateEventCarriedStateTransfer(typeof(InventoryAdjustedEvent), typeof(string), typeof(InventoryReadModel), TransferName = "inventory-state")]
public static partial class InventoryStateTransfer
{
[EventCarriedStateKey]
private static string Key(InventoryAdjustedEvent evt) => evt.Sku;

[EventCarriedStateVersion]
private static long Version(InventoryAdjustedEvent evt) => evt.Version;

[EventCarriedStateMapper]
private static InventoryReadModel Map(InventoryAdjustedEvent evt) => new(evt.Sku, evt.QuantityOnHand, evt.Warehouse);
}
```

The generated factory is parameterless and can be registered directly with `IServiceCollection`.

Diagnostics:

- `PKECST001`: host type must be partial.
- `PKECST002`: exactly one key selector, version selector, and state mapper are required.
- `PKECST003`: selector or mapper signature is invalid.
5 changes: 5 additions & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` |
| [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` |
| [**Canonical Data Model**](canonical-data-model.md) | Source-to-canonical contract normalization | `[GenerateCanonicalDataModel]` |
| [**Event-Carried State Transfer**](event-carried-state-transfer.md) | State-rich event projection factories | `[GenerateEventCarriedStateTransfer]` |
| [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` |
| [**Dead Letter Channel**](dead-letter-channel.md) | Failed-message capture and replay handoff | `[GenerateDeadLetterChannel]` |
| [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` |
Expand Down Expand Up @@ -195,6 +196,10 @@ public static partial class LegacyOrderAcl { }
[GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted))]
public static partial class PartnerOrderTranslator { }

// Event-carried state transfer - state-rich projection events
[GenerateEventCarriedStateTransfer(typeof(InventoryAdjustedEvent), typeof(string), typeof(InventoryReadModel))]
public static partial class InventoryStateTransfer { }

// Claim check - external payload storage reference
[GenerateClaimCheck(typeof(LargeOrderDocument), StoreName = "document-archive")]
public static partial class LargeDocumentClaims { }
Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
- name: Canonical Data Model
href: canonical-data-model.md

- name: Event-Carried State Transfer
href: event-carried-state-transfer.md

- name: Chain
href: chain.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 @@ -53,6 +53,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Enterprise Integration | Message Envelope | `Message<TPayload>`, headers, context | Messaging generator |
| Enterprise Integration | Message Translator | `MessageTranslator<TInput, TOutput>` | Message Translator generator |
| Enterprise Integration | Canonical Data Model | `CanonicalDataModel<TCanonical>` | Canonical Data Model generator |
| Enterprise Integration | Event-Carried State Transfer | `EventCarriedStateTransfer<TEvent,TKey,TState>` | Event-Carried State Transfer generator |
| Enterprise Integration | Claim Check | `ClaimCheck<TPayload>` | Claim Check generator |
| Enterprise Integration | Dead Letter Channel | `DeadLetterChannel<TPayload>` | Dead Letter Channel generator |
| Enterprise Integration | Content-Based Router | `ContentRouter<TPayload, TResult>` | Messaging generator |
Expand Down
6 changes: 6 additions & 0 deletions docs/patterns/messaging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ Service activators invoke application service operations from typed messages whi

[Learn More](service-activator.md)

## Event-Carried State Transfer

Event-carried state transfer publishes enough state in an event for subscribers to update local read models without calling back into the source service.

[Learn More](event-carried-state-transfer.md)

## Idempotent Receiver, Inbox, and Outbox

Idempotency and handoff helpers compose message handlers with pluggable stores, inbox boundaries, and outbox records without claiming broker durability or exactly-once delivery.
Expand Down
18 changes: 18 additions & 0 deletions docs/patterns/messaging/event-carried-state-transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Event-Carried State Transfer

Event-Carried State Transfer publishes enough state in an event for subscribers to update their local model without calling back into the source service.

```csharp
var transfer = EventCarriedStateTransfer<InventoryAdjustedEvent, string, InventoryReadModel>
.Create("inventory-state")
.WithKey(evt => evt.Sku)
.WithVersion(evt => evt.Version)
.WithState(evt => new InventoryReadModel(evt.Sku, evt.QuantityOnHand, evt.Warehouse))
.Build();

var carried = transfer.Transfer(inventoryAdjusted);
```

Use it when downstream services own read models, caches, or projections that should move forward from the event stream itself. The runtime path returns explicit transfer failures for selector or mapper errors.

The source-generated path uses `[GenerateEventCarriedStateTransfer]`, `[EventCarriedStateKey]`, `[EventCarriedStateVersion]`, and `[EventCarriedStateMapper]`. Import the example through `AddInventoryEventCarriedStateTransferDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@
href: messaging/message-translator.md
- name: Canonical Data Model
href: messaging/canonical-data-model.md
- name: Event-Carried State Transfer
href: messaging/event-carried-state-transfer.md
- name: Claim Check
href: messaging/claim-check.md
- name: Dead Letter Channel
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
namespace PatternKit.EnterpriseIntegration.EventCarriedStateTransfer;

public sealed class EventCarriedStateTransferResult<TKey, TState>
{
private EventCarriedStateTransferResult(string transferName, TKey? key, long version, TState? state, Exception? exception, bool transferred)
=> (TransferName, Key, Version, State, Exception, Transferred) = (transferName, key, version, state, exception, transferred);

public string TransferName { get; }

public TKey? Key { get; }

public long Version { get; }

public TState? State { get; }

public Exception? Exception { get; }

public bool Transferred { get; }

public bool Failed => !Transferred;

public static EventCarriedStateTransferResult<TKey, TState> Success(string transferName, TKey key, long version, TState state)
=> new(transferName, key, version, state, null, true);

public static EventCarriedStateTransferResult<TKey, TState> Failure(string transferName, Exception exception)
=> new(transferName, default, 0, default, exception ?? throw new ArgumentNullException(nameof(exception)), false);
}

public sealed class EventCarriedStateTransfer<TEvent, TKey, TState>
{
private readonly Func<TEvent, TKey> _keySelector;
private readonly Func<TEvent, long> _versionSelector;
private readonly Func<TEvent, TState> _stateSelector;

private EventCarriedStateTransfer(
string name,
Func<TEvent, TKey>? keySelector,
Func<TEvent, long>? versionSelector,
Func<TEvent, TState>? stateSelector)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Event-carried state transfer name is required.", nameof(name));

Name = name;
_keySelector = keySelector ?? throw new InvalidOperationException("Event-carried state transfer requires a key selector.");
_versionSelector = versionSelector ?? throw new InvalidOperationException("Event-carried state transfer requires a version selector.");
_stateSelector = stateSelector ?? throw new InvalidOperationException("Event-carried state transfer requires a state mapper.");
}

public string Name { get; }

public EventCarriedStateTransferResult<TKey, TState> Transfer(TEvent @event)
{
if (@event is null)
throw new ArgumentNullException(nameof(@event));

try
{
var key = _keySelector(@event);
if (key is null)
return EventCarriedStateTransferResult<TKey, TState>.Failure(Name, new InvalidOperationException("Event-carried state key selector returned null."));

var state = _stateSelector(@event);
if (state is null)
return EventCarriedStateTransferResult<TKey, TState>.Failure(Name, new InvalidOperationException("Event-carried state mapper returned null."));

return EventCarriedStateTransferResult<TKey, TState>.Success(Name, key, _versionSelector(@event), state);
}
catch (Exception ex)
{
return EventCarriedStateTransferResult<TKey, TState>.Failure(Name, ex);
}
}

public static Builder Create(string name = "event-carried-state-transfer") => new(name);

public sealed class Builder
{
private readonly string _name;
private Func<TEvent, TKey>? _keySelector;
private Func<TEvent, long>? _versionSelector;
private Func<TEvent, TState>? _stateSelector;

internal Builder(string name) => _name = name;

public Builder WithKey(Func<TEvent, TKey> keySelector)
{
_keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector));
return this;
}

public Builder WithVersion(Func<TEvent, long> versionSelector)
{
_versionSelector = versionSelector ?? throw new ArgumentNullException(nameof(versionSelector));
return this;
}

public Builder WithState(Func<TEvent, TState> stateSelector)
{
_stateSelector = stateSelector ?? throw new ArgumentNullException(nameof(stateSelector));
return this;
}

public EventCarriedStateTransfer<TEvent, TKey, TState> Build()
=> new(_name, _keySelector, _versionSelector, _stateSelector);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
using PatternKit.Examples.DomainEventDemo;
using PatternKit.Examples.EnterpriseFeatureSlices;
using PatternKit.Examples.EventSourcingDemo;
using PatternKit.Examples.EventCarriedStateTransferDemo;
using PatternKit.Examples.ExternalConfigurationStoreDemo;
using PatternKit.Examples.FeatureToggleDemo;
using PatternKit.Examples.FlyweightDemo;
Expand Down Expand Up @@ -142,6 +143,7 @@ public sealed record InventoryServiceActivatorExampleService(ServiceActivator<In
public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner);
public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service);
public sealed record CanonicalOrderDataModelExample(CanonicalOrderDemoRunner Runner, CanonicalOrderImportService Service);
public sealed record InventoryEventCarriedStateTransferExample(InventoryEventCarriedStateTransferDemoRunner Runner, InventoryProjectionService Service);
public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow);
public sealed record GeneratedDeadLetterChannelExample(FulfillmentDeadLetterChannelExampleRunner Runner, FulfillmentDeadLetterWorkflow Workflow);
public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner);
Expand Down Expand Up @@ -229,6 +231,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddGeneratedMessageEnvelopeExample()
.AddGeneratedMessageTranslatorExample()
.AddCanonicalOrderDataModelExample()
.AddInventoryEventCarriedStateTransferExample()
.AddGeneratedClaimCheckExample()
.AddGeneratedDeadLetterChannelExample()
.AddGeneratedRecipientListExample()
Expand Down Expand Up @@ -558,6 +561,15 @@ public static IServiceCollection AddCanonicalOrderDataModelExample(this IService
return services.RegisterExample<CanonicalOrderDataModelExample>("Order Canonical Data Model", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddInventoryEventCarriedStateTransferExample(this IServiceCollection services)
{
services.AddInventoryEventCarriedStateTransferDemo();
services.AddSingleton<InventoryEventCarriedStateTransferExample>(sp => new(
sp.GetRequiredService<InventoryEventCarriedStateTransferDemoRunner>(),
sp.GetRequiredService<InventoryProjectionService>()));
return services.RegisterExample<InventoryEventCarriedStateTransferExample>("Inventory Event-Carried State Transfer", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddGeneratedClaimCheckExample(this IServiceCollection services)
{
services.AddLargeDocumentClaimCheckExample();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Microsoft.Extensions.DependencyInjection;
using PatternKit.EnterpriseIntegration.EventCarriedStateTransfer;
using PatternKit.Generators.EventCarriedStateTransfer;

namespace PatternKit.Examples.EventCarriedStateTransferDemo;

public sealed record InventoryAdjustedEvent(string Sku, int QuantityOnHand, string Warehouse, long Version);

public sealed record InventoryReadModel(string Sku, int QuantityOnHand, string Warehouse);

public sealed record InventoryProjectionSummary(string TransferName, string Sku, long Version, int QuantityOnHand, string Warehouse);

public interface IInventoryReadModelStore
{
void Upsert(string sku, long version, InventoryReadModel state);

InventoryProjectionSummary? Find(string sku);
}

public sealed class InMemoryInventoryReadModelStore : IInventoryReadModelStore
{
private readonly Dictionary<string, InventoryProjectionSummary> _summaries = new(StringComparer.OrdinalIgnoreCase);

public void Upsert(string sku, long version, InventoryReadModel state)
=> _summaries[sku] = new("inventory-state", sku, version, state.QuantityOnHand, state.Warehouse);

Comment on lines +24 to +26
public InventoryProjectionSummary? Find(string sku)
=> _summaries.TryGetValue(sku, out var summary) ? summary : null;
}

public sealed class InventoryProjectionService(
EventCarriedStateTransfer<InventoryAdjustedEvent, string, InventoryReadModel> transfer,
IInventoryReadModelStore store)
{
public InventoryProjectionSummary Project(InventoryAdjustedEvent evt)
{
var result = transfer.Transfer(evt);
if (result.Failed)
throw new InvalidOperationException("Inventory event could not transfer carried state.", result.Exception);

store.Upsert(result.Key!, result.Version, result.State!);
return store.Find(result.Key!)!;
}
}

public static class InventoryStateTransfers
{
public static EventCarriedStateTransfer<InventoryAdjustedEvent, string, InventoryReadModel> CreateFluent()
=> EventCarriedStateTransfer<InventoryAdjustedEvent, string, InventoryReadModel>.Create("inventory-state")
.WithKey(static evt => evt.Sku)
.WithVersion(static evt => evt.Version)
.WithState(ToReadModel)
.Build();

public static InventoryReadModel ToReadModel(InventoryAdjustedEvent evt)
=> new(evt.Sku, evt.QuantityOnHand, evt.Warehouse);
}

[GenerateEventCarriedStateTransfer(typeof(InventoryAdjustedEvent), typeof(string), typeof(InventoryReadModel), FactoryMethodName = "Create", TransferName = "inventory-state")]
public static partial class GeneratedInventoryStateTransfer
{
[EventCarriedStateKey]
private static string Key(InventoryAdjustedEvent evt) => evt.Sku;

[EventCarriedStateVersion]
private static long Version(InventoryAdjustedEvent evt) => evt.Version;

[EventCarriedStateMapper]
private static InventoryReadModel Map(InventoryAdjustedEvent evt) => InventoryStateTransfers.ToReadModel(evt);
}

public sealed class InventoryEventCarriedStateTransferDemoRunner(InventoryProjectionService service)
{
public InventoryProjectionSummary RunGenerated(InventoryAdjustedEvent evt) => service.Project(evt);

public static InventoryProjectionSummary RunFluent()
{
var store = new InMemoryInventoryReadModelStore();
var service = new InventoryProjectionService(InventoryStateTransfers.CreateFluent(), store);
return service.Project(new InventoryAdjustedEvent("SKU-100", 12, "CHI-01", 4));
Comment on lines +76 to +80
}
}

public static class InventoryEventCarriedStateTransferServiceCollectionExtensions
{
public static IServiceCollection AddInventoryEventCarriedStateTransferDemo(this IServiceCollection services)
{
services.AddSingleton(static _ => GeneratedInventoryStateTransfer.Create());
services.AddSingleton<IInventoryReadModelStore, InMemoryInventoryReadModelStore>();
services.AddSingleton<InventoryProjectionService>();
services.AddSingleton<InventoryEventCarriedStateTransferDemoRunner>();
return services;
}
}
Loading
Loading