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
12 changes: 12 additions & 0 deletions docs/examples/erp-channel-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# ERP Channel Adapter

The ERP channel adapter example translates partner ERP order documents into internal PatternKit messages and back out to ERP documents.

```csharp
services.AddErpChannelAdapterDemo();

var service = provider.GetRequiredService<ErpChannelAdapterService>();
var summary = service.RoundTrip(new ErpOrderDocument("ERP-100", "42.50"));
```

The example includes fluent and source-generated construction, inbound and outbound message channels, and `IServiceCollection` registration for existing .NET applications.
3 changes: 3 additions & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ Welcome! This section collects small, focused demos that show **how to compose b
* **Order Event-Driven Consumer**
Shows fluent and source-generated push consumers side by side, with an importable `IServiceCollection` extension. See [Order Event-Driven Consumer](order-event-driven-consumer.md).

* **ERP Channel Adapter**
Shows fluent and source-generated external DTO adapters over PatternKit message channels, with an importable `IServiceCollection` extension. See [ERP Channel Adapter](erp-channel-adapter.md).

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

Expand Down
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
- name: Order Event-Driven Consumer
href: order-event-driven-consumer.md

- name: ERP Channel Adapter
href: erp-channel-adapter.md

- name: Patterns Showcase — Integrated Order Processing
href: patterns-showcase.md

Expand Down
27 changes: 27 additions & 0 deletions docs/generators/channel-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Channel Adapter Generator

`[GenerateChannelAdapter]` creates a typed `ChannelAdapter<TExternal, TPayload>` factory.

```csharp
[GenerateChannelAdapter(typeof(ErpOrderDocument), typeof(OrderIntegrationMessage), FactoryName = "Create", AdapterName = "erp-orders-adapter")]
public static partial class ErpOrdersAdapter
{
[ChannelAdapterInbound]
private static Message<OrderIntegrationMessage> ToMessage(ErpOrderDocument document, MessageContext context)
=> Message<OrderIntegrationMessage>.Create(new(document.ExternalOrderId, decimal.Parse(document.Total)));

[ChannelAdapterOutbound]
private static ErpOrderDocument ToExternal(Message<OrderIntegrationMessage> message, MessageContext context)
=> new(message.Payload.OrderId, message.Payload.Total.ToString("0.00"));
}
```

The generated factory accepts inbound and outbound `MessageChannel<TPayload>` instances so the adapter can be composed through `IServiceCollection`.

Diagnostics:

- `PKCAD001`: host type must be partial.
- `PKCAD002`: exactly one inbound translator is required.
- `PKCAD003`: exactly one outbound translator is required.
- `PKCAD004`: inbound translator signature is invalid.
- `PKCAD005`: outbound translator 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 @@ -83,6 +83,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Message Channel**](message-channel.md) | Typed channel factories for in-process message queues | `[GenerateMessageChannel]` |
| [**Polling Consumer**](polling-consumer.md) | Pull-based message consumer factories | `[GeneratePollingConsumer]` |
| [**Event-Driven Consumer**](event-driven-consumer.md) | Push-based message consumer factories | `[GenerateEventDrivenConsumer]` |
| [**Channel Adapter**](channel-adapter.md) | External DTO to message-channel adapter factories | `[GenerateChannelAdapter]` |
| [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` |
| [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` |
| [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` |
Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@
- name: Event-Driven Consumer
href: event-driven-consumer.md

- name: Channel Adapter
href: channel-adapter.md

- name: Message Translator
href: message-translator.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 @@ -47,6 +47,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Enterprise Integration | Message Channel | `MessageChannel<TPayload>` | Message Channel generator |
| Enterprise Integration | Polling Consumer | `PollingConsumer<TPayload>` | Polling Consumer generator |
| Enterprise Integration | Event-Driven Consumer | `EventDrivenConsumer<TPayload>` | Event-Driven Consumer generator |
| Enterprise Integration | Channel Adapter | `ChannelAdapter<TExternal, TPayload>` | Channel Adapter generator |
| Enterprise Integration | Message Envelope | `Message<TPayload>`, headers, context | Messaging generator |
| Enterprise Integration | Message Translator | `MessageTranslator<TInput, TOutput>` | Message Translator generator |
| Enterprise Integration | Claim Check | `ClaimCheck<TPayload>` | Claim Check 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 @@ -38,6 +38,12 @@ Push-based consumers handle messages when a broker callback, background service,

[Learn More](event-driven-consumer.md)

## Channel Adapter

Channel adapters translate external transport DTOs into PatternKit message channels and translate outbound channel messages back to the transport shape.

[Learn More](channel-adapter.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
17 changes: 17 additions & 0 deletions docs/patterns/messaging/channel-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Channel Adapter

Channel Adapter bridges an external transport shape to PatternKit message channels.

```csharp
var adapter = ChannelAdapter<ErpOrderDocument, OrderIntegrationMessage>
.Create("erp-orders-adapter")
.ReceiveInto(inboundChannel)
.SendFrom(outboundChannel)
.MapInbound((document, context) => Message<OrderIntegrationMessage>.Create(command))
.MapOutbound((message, context) => ToErpDocument(message.Payload))
.Build();
Comment on lines +6 to +12
```

Use it at application boundaries where a broker callback, HTTP webhook, file reader, or partner SDK exposes a DTO that should be translated into internal messages. The outbound path translates internal messages back into the transport DTO without making PatternKit own the external infrastructure.

The source-generated path uses `[GenerateChannelAdapter]`, `[ChannelAdapterInbound]`, and `[ChannelAdapterOutbound]`. Import the ERP example through `AddErpChannelAdapterDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@
href: messaging/polling-consumer.md
- name: Event-Driven Consumer
href: messaging/event-driven-consumer.md
- name: Channel Adapter
href: messaging/channel-adapter.md
- name: Message Envelope and Context
href: messaging/message-envelope.md
- name: Message Translator
Expand Down
137 changes: 137 additions & 0 deletions src/PatternKit.Core/Messaging/Adapters/ChannelAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using PatternKit.Messaging.Channels;

namespace PatternKit.Messaging.Adapters;

/// <summary>Bridges external transport messages to PatternKit message channels.</summary>
public sealed class ChannelAdapter<TExternal, TPayload>
{
public delegate Message<TPayload> InboundTranslator(TExternal external, MessageContext context);

public delegate TExternal OutboundTranslator(Message<TPayload> message, MessageContext context);

private readonly MessageChannel<TPayload> _inboundChannel;
private readonly MessageChannel<TPayload> _outboundChannel;
private readonly InboundTranslator _inbound;
private readonly OutboundTranslator _outbound;

private ChannelAdapter(
string name,
MessageChannel<TPayload> inboundChannel,
MessageChannel<TPayload> outboundChannel,
InboundTranslator inbound,
OutboundTranslator outbound)
=> (Name, _inboundChannel, _outboundChannel, _inbound, _outbound) = (name, inboundChannel, outboundChannel, inbound, outbound);

public string Name { get; }

public ChannelAdapterInboundResult<TPayload> AcceptExternal(TExternal external, MessageContext? context = null)
{
var message = _inbound(external, context ?? MessageContext.Empty);
if (message is null)
throw new InvalidOperationException("Inbound channel adapter translator returned null.");

var result = _inboundChannel.Send(message);
return new ChannelAdapterInboundResult<TPayload>(Name, message, result);
}

public ChannelAdapterOutboundResult<TExternal> TryTakeExternal(MessageContext? context = null)
{
var received = _outboundChannel.TryReceive();
if (!received.Received)
return ChannelAdapterOutboundResult<TExternal>.Empty(Name, received.ChannelName);

var external = _outbound(received.Message!, context ?? MessageContext.From(received.Message!));
return ChannelAdapterOutboundResult<TExternal>.Success(Name, received.ChannelName, external);
}
Comment on lines +41 to +45

public static Builder Create(string name = "channel-adapter") => new(name);

public sealed class Builder
{
private readonly string _name;
private MessageChannel<TPayload>? _inboundChannel;
private MessageChannel<TPayload>? _outboundChannel;
private InboundTranslator? _inbound;
private OutboundTranslator? _outbound;

internal Builder(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Channel adapter name cannot be null, empty, or whitespace.", nameof(name));

_name = name;
}

public Builder ReceiveInto(MessageChannel<TPayload> channel)
{
_inboundChannel = channel ?? throw new ArgumentNullException(nameof(channel));
return this;
}

public Builder SendFrom(MessageChannel<TPayload> channel)
{
_outboundChannel = channel ?? throw new ArgumentNullException(nameof(channel));
return this;
}

public Builder MapInbound(InboundTranslator translator)
{
_inbound = translator ?? throw new ArgumentNullException(nameof(translator));
return this;
}

public Builder MapOutbound(OutboundTranslator translator)
{
_outbound = translator ?? throw new ArgumentNullException(nameof(translator));
return this;
}

public ChannelAdapter<TExternal, TPayload> Build()
{
if (_inboundChannel is null)
throw new InvalidOperationException("Channel adapter requires an inbound channel.");
if (_outboundChannel is null)
throw new InvalidOperationException("Channel adapter requires an outbound channel.");
if (_inbound is null)
throw new InvalidOperationException("Channel adapter requires an inbound translator.");
if (_outbound is null)
throw new InvalidOperationException("Channel adapter requires an outbound translator.");

return new(_name, _inboundChannel, _outboundChannel, _inbound, _outbound);
}
}
}

public sealed class ChannelAdapterInboundResult<TPayload>
{
internal ChannelAdapterInboundResult(string adapterName, Message<TPayload> message, MessageChannelSendResult channelResult)
=> (AdapterName, Message, ChannelResult) = (adapterName, message, channelResult);

public string AdapterName { get; }

public Message<TPayload> Message { get; }

public MessageChannelSendResult ChannelResult { get; }

public bool Accepted => ChannelResult.Accepted;
}

public sealed class ChannelAdapterOutboundResult<TExternal>
{
private ChannelAdapterOutboundResult(string adapterName, string channelName, bool produced, TExternal? external)
=> (AdapterName, ChannelName, Produced, External) = (adapterName, channelName, produced, external);

public string AdapterName { get; }

public string ChannelName { get; }

public bool Produced { get; }

public TExternal? External { get; }

internal static ChannelAdapterOutboundResult<TExternal> Success(string adapterName, string channelName, TExternal external)
=> new(adapterName, channelName, true, external);

internal static ChannelAdapterOutboundResult<TExternal> Empty(string adapterName, string channelName)
=> new(adapterName, channelName, false, default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
using PatternKit.Examples.VisitorDemo;
using PatternKit.Messaging.Channels;
using PatternKit.Messaging.Consumers;
using PatternKit.Messaging.Adapters;
using PatternKit.Messaging.Routing;
using PatternKit.Messaging.Storage;
using PatternKit.Messaging.ControlBus;
Expand Down Expand Up @@ -128,6 +129,7 @@ public sealed record MessageRouterVisitorExample(Func<RoutingSummary> Run);
public sealed record InventoryMessageChannelExampleService(MessageChannel<InventoryAdjustment> Channel, InventoryMessageChannelService Service);
public sealed record WarehousePollingConsumerExampleService(PollingConsumer<ReplenishmentRequest> Consumer, WarehousePollingConsumerService Service);
public sealed record OrderEventDrivenConsumerExampleService(EventDrivenConsumer<OrderAcceptedEvent> Consumer, OrderEventDrivenConsumerService Service);
public sealed record ErpChannelAdapterExampleService(ChannelAdapter<ErpOrderDocument, OrderIntegrationMessage> Adapter, ErpChannelAdapterService Service);
public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner);
public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service);
public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow);
Expand Down Expand Up @@ -209,6 +211,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddInventoryMessageChannelExample()
.AddWarehousePollingConsumerExample()
.AddOrderEventDrivenConsumerExample()
.AddErpChannelAdapterExample()
.AddGeneratedMessageEnvelopeExample()
.AddGeneratedMessageTranslatorExample()
.AddGeneratedClaimCheckExample()
Expand Down Expand Up @@ -486,6 +489,15 @@ public static IServiceCollection AddOrderEventDrivenConsumerExample(this IServic
return services.RegisterExample<OrderEventDrivenConsumerExampleService>("Order Event-Driven Consumer", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddErpChannelAdapterExample(this IServiceCollection services)
{
services.AddErpChannelAdapterDemo();
services.AddSingleton<ErpChannelAdapterExampleService>(sp => new(
sp.GetRequiredService<ChannelAdapter<ErpOrderDocument, OrderIntegrationMessage>>(),
sp.GetRequiredService<ErpChannelAdapterService>()));
return services.RegisterExample<ErpChannelAdapterExampleService>("ERP Channel Adapter", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services)
{
services.AddMessageEnvelopeExample();
Expand Down
Loading
Loading