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 @@ -87,6 +87,9 @@ Welcome! This section collects small, focused demos that show **how to compose b
* **Generated Message Envelope**
Shows fluent and source-generated message envelope contracts side by side, with an importable `IServiceCollection` extension. See [Generated Message Envelope](generated-message-envelope.md).

* **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).

* **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/order-canonical-data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Order Canonical Data Model

The order canonical data model example normalizes partner and marketplace order contracts into `CanonicalCommerceOrder`.

```csharp
services.AddCanonicalOrderDataModelDemo();

var service = provider.GetRequiredService<CanonicalOrderImportService>();
var summary = service.ImportPartnerOrder(new PartnerOrderDocument("P-100", 42.50m, "usd"));
```

The example includes fluent and source-generated construction plus an importable `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 @@ -79,6 +79,9 @@
- name: Generated Message Translator
href: generated-message-translator.md

- name: Order Canonical Data Model
href: order-canonical-data-model.md

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

Expand Down
20 changes: 20 additions & 0 deletions docs/generators/canonical-data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Canonical Data Model Generator

`[GenerateCanonicalDataModel]` creates a typed `CanonicalDataModel<TCanonical>` factory from a source-to-canonical mapper.

```csharp
[GenerateCanonicalDataModel(typeof(PartnerOrderDocument), typeof(CanonicalCommerceOrder), ModelName = "commerce-orders", AdapterName = "partner-orders")]
public static partial class PartnerOrderCanonicalModel
{
[CanonicalDataModelMapper]
private static CanonicalCommerceOrder Map(PartnerOrderDocument order) => new(order.ExternalOrderId, order.Amount, "USD");
}
```

The generated factory is parameterless, so applications can register the canonical model directly in `IServiceCollection`.

Diagnostics:

- `PKCDM001`: host type must be partial.
- `PKCDM002`: exactly one mapper is required.
- `PKCDM003`: mapper signature is invalid.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Service Activator**](service-activator.md) | Message-to-service operation factories | `[GenerateServiceActivator]` |
| [**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]` |
| [**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
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
- name: Cache-Aside
href: cache-aside.md

- name: Canonical Data Model
href: canonical-data-model.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 @@ -52,6 +52,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Enterprise Integration | Service Activator | `ServiceActivator<TRequest, TResponse>` | Service Activator generator |
| 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 | 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
19 changes: 19 additions & 0 deletions docs/patterns/messaging/canonical-data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Canonical Data Model

Canonical Data Model normalizes partner, vendor, or application-specific records into one application-owned contract.

```csharp
var model = CanonicalDataModel<CanonicalCommerceOrder>
.Create("commerce-orders")
.From<PartnerOrderDocument>("partner-orders", order => new(
order.ExternalOrderId,
order.Amount,
order.CurrencyCode.ToUpperInvariant()))
.Build();

var result = model.Normalize(partnerOrder);
```

Use it when multiple integrations need to feed routers, sagas, service activators, or application services through the same stable payload shape. The runtime path reports missing adapters and mapper failures as explicit results.

The source-generated path uses `[GenerateCanonicalDataModel]` and `[CanonicalDataModelMapper]`. Import the example through `AddCanonicalOrderDataModelDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@
href: messaging/message-envelope.md
- name: Message Translator
href: messaging/message-translator.md
- name: Canonical Data Model
href: messaging/canonical-data-model.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,106 @@
namespace PatternKit.EnterpriseIntegration.CanonicalDataModel;

public sealed class CanonicalDataModelResult<TCanonical>
{
private CanonicalDataModelResult(string modelName, string adapterName, TCanonical? value, Exception? exception, bool normalized)
=> (ModelName, AdapterName, Value, Exception, Normalized) = (modelName, adapterName, value, exception, normalized);

public string ModelName { get; }

public string AdapterName { get; }

public TCanonical? Value { get; }

public Exception? Exception { get; }

public bool Normalized { get; }

public bool Failed => !Normalized;

public static CanonicalDataModelResult<TCanonical> Success(string modelName, string adapterName, TCanonical value)
=> new(modelName, adapterName, value, null, true);

public static CanonicalDataModelResult<TCanonical> Failure(string modelName, string adapterName, Exception exception)
=> new(modelName, adapterName, default, exception ?? throw new ArgumentNullException(nameof(exception)), false);
}

public sealed class CanonicalDataModel<TCanonical>
{
private readonly Dictionary<Type, Adapter> _adapters;

private CanonicalDataModel(string name, IReadOnlyList<Adapter> adapters)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Canonical data model name is required.", nameof(name));
if (adapters is null)
throw new ArgumentNullException(nameof(adapters));
if (adapters.Count == 0)
throw new InvalidOperationException("Canonical data model requires at least one source adapter.");

Name = name;
_adapters = adapters.ToDictionary(static adapter => adapter.SourceType);
}

public string Name { get; }

public IReadOnlyCollection<Type> SourceTypes => _adapters.Keys;

public CanonicalDataModelResult<TCanonical> Normalize<TSource>(TSource source)
{
if (source is null)
throw new ArgumentNullException(nameof(source));

if (!_adapters.TryGetValue(typeof(TSource), out var adapter))
return CanonicalDataModelResult<TCanonical>.Failure(Name, typeof(TSource).Name, new InvalidOperationException($"No canonical data model adapter is registered for '{typeof(TSource).FullName}'."));

Comment on lines +53 to +55
try
{
var value = adapter.Normalize(source);
if (value is null)
return CanonicalDataModelResult<TCanonical>.Failure(Name, adapter.Name, new InvalidOperationException($"Canonical data model adapter '{adapter.Name}' returned null."));

return CanonicalDataModelResult<TCanonical>.Success(Name, adapter.Name, value);
}
catch (Exception ex)
{
return CanonicalDataModelResult<TCanonical>.Failure(Name, adapter.Name, ex);
}
}

public static Builder Create(string name = "canonical-data-model") => new(name);

public sealed class Builder
{
private readonly string _name;
private readonly List<Adapter> _adapters = [];

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

public Builder From<TSource>(string adapterName, Func<TSource, TCanonical> normalize)
{
if (string.IsNullOrWhiteSpace(adapterName))
throw new ArgumentException("Canonical adapter name is required.", nameof(adapterName));
if (normalize is null)
throw new ArgumentNullException(nameof(normalize));
if (_adapters.Any(adapter => adapter.SourceType == typeof(TSource)))
throw new InvalidOperationException($"Canonical adapter for '{typeof(TSource).FullName}' is already registered.");

_adapters.Add(new(adapterName, typeof(TSource), source => normalize((TSource)source)));
return this;
}

public CanonicalDataModel<TCanonical> Build() => new(_name, _adapters.ToArray());
}

private sealed class Adapter
{
public Adapter(string name, Type sourceType, Func<object, TCanonical> normalize)
=> (Name, SourceType, Normalize) = (name, sourceType, normalize);

public string Name { get; }

public Type SourceType { get; }

public Func<object, TCanonical> Normalize { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Microsoft.Extensions.DependencyInjection;
using PatternKit.EnterpriseIntegration.CanonicalDataModel;
using PatternKit.Generators.CanonicalDataModel;

namespace PatternKit.Examples.CanonicalDataModelDemo;

public sealed record PartnerOrderDocument(string ExternalOrderId, decimal Amount, string CurrencyCode);

public sealed record MarketplaceOrderDocument(string MarketplaceId, int AmountInCents, string Currency);

public sealed record CanonicalCommerceOrder(string OrderId, decimal Total, string Currency);

public sealed record CanonicalOrderSummary(string ModelName, string AdapterName, string OrderId, decimal Total, string Currency);

public sealed class CanonicalOrderImportService(CanonicalDataModel<CanonicalCommerceOrder> model)
{
public CanonicalOrderSummary ImportPartnerOrder(PartnerOrderDocument order)
{
var result = model.Normalize(order);
if (result.Failed)
throw new InvalidOperationException("Partner order could not be normalized.", result.Exception);

var canonical = result.Value!;
return new(result.ModelName, result.AdapterName, canonical.OrderId, canonical.Total, canonical.Currency);
}
}

public static class CanonicalOrderModels
{
public static CanonicalDataModel<CanonicalCommerceOrder> CreateFluent()
=> CanonicalDataModel<CanonicalCommerceOrder>.Create("commerce-orders")
.From<PartnerOrderDocument>("partner-orders", ToCanonical)
.From<MarketplaceOrderDocument>("marketplace-orders", static order => new(
order.MarketplaceId,
order.AmountInCents / 100m,
order.Currency.ToUpperInvariant()))
.Build();

public static CanonicalCommerceOrder ToCanonical(PartnerOrderDocument order)
=> new(order.ExternalOrderId, order.Amount, order.CurrencyCode.ToUpperInvariant());
}

[GenerateCanonicalDataModel(typeof(PartnerOrderDocument), typeof(CanonicalCommerceOrder), FactoryMethodName = "Create", ModelName = "commerce-orders", AdapterName = "partner-orders")]
public static partial class GeneratedPartnerOrderCanonicalDataModel
{
[CanonicalDataModelMapper]
private static CanonicalCommerceOrder Map(PartnerOrderDocument order) => CanonicalOrderModels.ToCanonical(order);
}

public sealed class CanonicalOrderDemoRunner(CanonicalOrderImportService service)
{
public CanonicalOrderSummary RunGenerated(PartnerOrderDocument order) => service.ImportPartnerOrder(order);

public static CanonicalOrderSummary RunFluent()
{
var result = CanonicalOrderModels.CreateFluent().Normalize(new MarketplaceOrderDocument("M-200", 7500, "usd"));
var canonical = result.Value!;
return new(result.ModelName, result.AdapterName, canonical.OrderId, canonical.Total, canonical.Currency);
}

public static CanonicalOrderSummary RunGeneratedStatic()
{
var service = new CanonicalOrderImportService(GeneratedPartnerOrderCanonicalDataModel.Create());
return service.ImportPartnerOrder(new PartnerOrderDocument("P-100", 42.50m, "usd"));
}
}

public static class CanonicalOrderServiceCollectionExtensions
{
public static IServiceCollection AddCanonicalOrderDataModelDemo(this IServiceCollection services)
{
services.AddSingleton(static _ => GeneratedPartnerOrderCanonicalDataModel.Create());
services.AddSingleton<CanonicalOrderImportService>();
services.AddSingleton<CanonicalOrderDemoRunner>();
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
using PatternKit.Examples.AuditLogDemo;
using PatternKit.Examples.BulkheadDemo;
using PatternKit.Examples.CacheAsideDemo;
using PatternKit.Examples.CanonicalDataModelDemo;
using PatternKit.Examples.Chain;
using PatternKit.Examples.Chain.ConfigDriven;
using PatternKit.Examples.CircuitBreakerDemo;
Expand Down Expand Up @@ -140,6 +141,7 @@ public sealed record PaymentMessagingGatewayExampleService(MessagingGateway<Paym
public sealed record InventoryServiceActivatorExampleService(ServiceActivator<InventoryReservationRequest, InventoryReservationResult> Activator, InventoryServiceActivatorService Service);
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 GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow);
public sealed record GeneratedDeadLetterChannelExample(FulfillmentDeadLetterChannelExampleRunner Runner, FulfillmentDeadLetterWorkflow Workflow);
public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner);
Expand Down Expand Up @@ -226,6 +228,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddInventoryServiceActivatorExample()
.AddGeneratedMessageEnvelopeExample()
.AddGeneratedMessageTranslatorExample()
.AddCanonicalOrderDataModelExample()
.AddGeneratedClaimCheckExample()
.AddGeneratedDeadLetterChannelExample()
.AddGeneratedRecipientListExample()
Expand Down Expand Up @@ -546,6 +549,15 @@ public static IServiceCollection AddGeneratedMessageTranslatorExample(this IServ
return services.RegisterExample<GeneratedMessageTranslatorExample>("Generated Message Translator", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

public static IServiceCollection AddCanonicalOrderDataModelExample(this IServiceCollection services)
{
services.AddCanonicalOrderDataModelDemo();
services.AddSingleton<CanonicalOrderDataModelExample>(sp => new(
sp.GetRequiredService<CanonicalOrderDemoRunner>(),
sp.GetRequiredService<CanonicalOrderImportService>()));
return services.RegisterExample<CanonicalOrderDataModelExample>("Order Canonical Data Model", 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
Expand Up @@ -296,6 +296,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog
ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection,
["MessageTranslator"],
["partner event normalization", "source-generated translator", "DI composition"]),
Descriptor(
"Order Canonical Data Model",
"src/PatternKit.Examples/CanonicalDataModelDemo/OrderCanonicalDataModelDemo.cs",
"test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs",
"docs/examples/order-canonical-data-model.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost,
["Canonical Data Model"],
["partner order normalization", "source-generated canonical adapter", "DI composition"]),
Descriptor(
"Generated Claim Check",
"src/PatternKit.Examples/Messaging/LargeDocumentClaimCheckExample.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog
"test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs",
["fluent payload translator", "generated translator factory", "DI-importable partner event normalization example"]),

Pattern("Canonical Data Model", PatternFamily.EnterpriseIntegration,
"docs/patterns/messaging/canonical-data-model.md",
"src/PatternKit.Core/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModel.cs",
"test/PatternKit.Tests/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModelTests.cs",
"docs/generators/canonical-data-model.md",
"src/PatternKit.Generators/CanonicalDataModel/CanonicalDataModelGenerator.cs",
"test/PatternKit.Generators.Tests/CanonicalDataModelGeneratorTests.cs",
null,
"docs/examples/order-canonical-data-model.md",
"src/PatternKit.Examples/CanonicalDataModelDemo/OrderCanonicalDataModelDemo.cs",
"test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs",
["fluent source normalization", "generated canonical adapter", "DI-importable canonical order example"]),

Pattern("Claim Check", PatternFamily.EnterpriseIntegration,
"docs/patterns/messaging/claim-check.md",
"src/PatternKit.Core/Messaging/Transformation/ClaimCheck.cs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace PatternKit.Generators.CanonicalDataModel;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
public sealed class GenerateCanonicalDataModelAttribute(Type sourceType, Type canonicalType) : Attribute
{
public Type SourceType { get; } = sourceType ?? throw new ArgumentNullException(nameof(sourceType));

public Type CanonicalType { get; } = canonicalType ?? throw new ArgumentNullException(nameof(canonicalType));

public string FactoryMethodName { get; set; } = "Create";

public string ModelName { get; set; } = "canonical-data-model";

public string AdapterName { get; set; } = "source-adapter";
}

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class CanonicalDataModelMapperAttribute : Attribute
{
}
Loading
Loading