Plugin Developers: What You Need to Know for v26

Plugin Developers: What You Need to Know for v26

Plugin Developers: What You Need to Know for v26

Connexion v26 moves custom device development from .NET Framework and AppDomains to .NET 10 and AssemblyLoadContext.

The overall device model remains familiar: a device belongs to a channel, has configuration and optional configuration UI, processes messages, and participates in the same operational lifecycle. However, v16 plugins are not binary- or source-compatible without changes. They must be migrated and rebuilt for v26.

The main API changes are an asynchronous lifecycle and message model, separation of cross-platform runtime code from Avalonia configuration UI, and simplified batch processing through the base device API. The examples below use C# and show the common v16 pattern followed by its current v26 equivalent.

The new project shape

In v16, runtime logic and WPF configuration UI commonly lived in one .NET Framework project:

v16 - one Windows/.NET Framework project

<TargetFrameworkVersion>v4.8</TargetFrameworkVersion> <Reference Include="PresentationFramework" /> <Reference Include="Connexion.Core" />

In v26, put runtime code and Avalonia configuration UI in separate projects:

Project

Target

Contains

Project

Target

Contains

Device runtime

net10.0

Device class, configuration model, processing logic, runtime metadata, and cross-platform dependencies

UI companion

net10.0

Avalonia controls, view models, configuration factory and overlays; the UI project references the runtime project

v26 - runtime project

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> </PropertyGroup> <ItemGroup> <Reference Include="Connexion.Core" /> </ItemGroup> </Project>

v26 - UI companion project

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Avalonia" Version="12.1.4" /> <Reference Include="Connexion.Core" /> <Reference Include="Connexion.Share.Ui" /> <ProjectReference Include="..\MyDevice\MyDevice.csproj" /> </ItemGroup> </Project>

The runtime project should not reference Avalonia, WPF, Connexion.Share.Ui, or other UI components. The UI companion targets net10.0 and uses Avalonia; port existing WPF or WinForms views to Avalonia controls. Match the Avalonia package version to the Connexion client that loads the plugin (the current development build uses 12.1.4). The generated plugin template supplies that version and the Connexion assembly references.

Runtime API migration

The v26 API is asynchronous throughout device startup, shutdown, processing, and error handling. Lifecycle methods accept a CancellationToken and return Task.

v16 pattern

v26 pattern

v16 pattern

v26 pattern

Load, ChannelStarting, ChannelLoaded, ChannelStopping, Start, Stop, and Pause

LoadAsync, ChannelStartingAsync, ChannelStartedAsync, ChannelStoppingAsync, StartAsync, StopAsync, and PauseAsync

ProcessMessage(...) or ProcessMessageAsync(...)

ProcessMessageAsync(...); the synchronous method is removed

OnError(...)

OnErrorAsync(...)

Constructor receives the device key and channel object

Parameterless construction; Connexion supplies DeviceKey and Host

MessageChannel

Host and the channel-device services supplied by v26

context.Message or context.GetMessage<T>()

await context.GetMessageAsync<T>(cancellationToken)

context.Message = value

context.SetMessage(value)

context.Clone(...)

await context.CloneAsync(...)

Top-level message metadata properties

Properties nested under context.Metadata

context.WriteEvent(...)

context.Logger.Write(...)

Separate IBatchDevice and queue batch mode

Override BaseDevice.ProcessMessagesAsync(...) only when custom batch behavior is required

Lifecycle methods

v16

public override void Load(string configuration) { base.Load(configuration); } public override void ChannelStarting() { Logger.Write(EventSeverity.Info, "Channel is starting"); } public override void Start() { OpenConnection(); } public override void Stop() { CloseConnection(); }

v26

public override async Task LoadAsync( string configuration, CancellationToken cancellationToken) { await base.LoadAsync(configuration, cancellationToken); } public override Task ChannelStartingAsync(CancellationToken cancellationToken) { Logger.Write(EventSeverity.Info, 0, "Channel is starting"); return Task.CompletedTask; } public override async Task StartAsync(CancellationToken cancellationToken) { await OpenConnectionAsync(cancellationToken); } public override async Task StopAsync(CancellationToken cancellationToken) { await CloseConnectionAsync(cancellationToken); }

If the v16 device already overrides ProcessMessageAsync, retain that basic shape and migrate the APIs used inside it. The important change is that v26 no longer falls back to the synchronous ProcessMessage method.

Message processing and error handling

v16

public override void ProcessMessage(IMessageContext context) { var message = context.GetMessage<string>(); context.Message = Transform(message); } public override void OnError(IMessageContext context, ErrorEventArgs args) { args.ShouldRetry = true; args.SleepTime = TimeSpan.FromSeconds(5); }

v26

public override async Task ProcessMessageAsync( IMessageContext context, CancellationToken cancellationToken) { var message = await context.GetMessageAsync<string>(cancellationToken); context.SetMessage(Transform(message)); } public override Task OnErrorAsync( IMessageContext context, ErrorEventArgs args, CancellationToken cancellationToken) { args.ShouldRetry = true; args.SleepTime = TimeSpan.FromSeconds(5); return Task.CompletedTask; }

Construction and channel services

v16

public sealed class MyDevice : BaseDevice<MyConfiguration> { public MyDevice(Guid deviceKey, IMessageChannelDevice messageChannelDevice) : base(deviceKey, messageChannelDevice) { } private async Task PostAsync(object message) { var context = MessageChannel.CreateMessageContext(message); await MessageChannel.PostOnChannelAsync(context); } }

v26

public sealed class MyDevice : BaseDevice<MyConfiguration> { // No device constructor is required. Connexion supplies DeviceKey and Host. private async Task PostAsync(object message, CancellationToken cancellationToken) { var context = Host.CreateMessageContext(message); await Host.PostOnChannelAsync(context, cancellationToken); } }

Message access, metadata, cloning, and processing events

v16

var hl7 = context.GetMessage<HL7Message>(); context.PatientId = hl7["PID-3.1"]; context.SendingFacility = hl7["MSH-4.1"]; context.WriteEvent( EventSeverity.Info, "Validated patient {0}", context.PatientId); var branch = context.Clone( "derived message", CloneOptions.AttachmentsAndUserStorage); context.Message = hl7;

v26

var hl7 = await context.GetMessageAsync<HL7Message>(cancellationToken); context.Metadata.PatientId = hl7["PID-3.1"]; context.Metadata.SendingFacility = hl7["MSH-4.1"]; context.Logger.Write( EventSeverity.Info, "Validated patient {0}", context.Metadata.PatientId); using var branch = await context.CloneAsync( "derived message", includeAttachments: true, cancellationToken); context.SetMessage(hl7);

Direct mutations to an object returned by GetMessageAsync<T> remain authoritative. Call SetMessage when replacing the primary message with a different object. The pipeline owns the context passed into a device; do not dispose it. A caller owns a context returned by CloneAsync and must dispose that clone after use. Replacing a message that has already crossed a queue boundary does not reinterpret its stored metadata; update context.Metadata explicitly where required.

Message context, payloads, and storage

IMessageContext no longer exposes a public Message property. Use GetMessageAsync<T> to request the required representation and SetMessage to replace it. Connexion returns the existing compatible representation or performs a supported conversion; if the requested representation cannot be produced, the call fails rather than returning an ambiguous result.

Ordinary and large payloads

v16 materialized the complete string or byte array:

v16

var text = context.GetMessage<string>(); var bytes = Encoding.UTF8.GetBytes(text); await destination.WriteAsync(bytes, 0, bytes.Length);

v26 retains materialized primary-message access through the public device API. Built-in devices can use internal streaming paths when the primary payload remains unchanged:

v26 - materialized access

var text = await context.GetMessageAsync<string>(cancellationToken);

v26 - binary access

var bytes = await context.GetMessageAsync<byte[]>(cancellationToken); await destination.WriteAsync(bytes.AsMemory(), cancellationToken);

Request byte[] for a binary primary message and string for text. Both examples materialize the complete requested value. The current public API does not expose StringPayload or BinaryPayload. For public streamed access to large content, use attachments and their OpenStreamAsync method.

Attachments

v16

context.Attachments.Add("documents/report.pdf", inputStream, shouldCompress: true); using var report = context.Attachments["documents/report.pdf"].GetStream(); report.CopyTo(destination);

v26

await context.Attachments.AddAsync( "documents/report.pdf", inputStream, cancellationToken); var attachment = await context.Attachments.GetAsync( "documents/report.pdf", cancellationToken); await using var report = await attachment.OpenStreamAsync(cancellationToken); await report.CopyToAsync(destination, cancellationToken);

Replacing UserStorage

In v16, one API selected transient or persistent per-message storage:

v16

context.UserStorage.Add( UserStorageType.Transient, "routingDecision", "priority"); context.UserStorage.Add( UserStorageType.Persistent, "validationResult", new ValidationResult(true));

In v26, choose the API whose lifetime matches the data:

v26

// Memory-only and available to later devices in the current process. context.TransientItems["routingDecision"] = "priority"; // Persistent with this message across queue boundaries. context.Attachments.Add( "state/validation-result.json", new ValidationResult(true)); // Persistent state owned by this device rather than by a message. await DeviceStorage.SetAsync( new DeviceCheckpoint(DateTimeOffset.UtcNow), "checkpoint");

TransientItems is memory-only, Attachments persists with the message, and DeviceStorage persists values scoped to the device.

Batch processing and event hooks

Custom batch processing

In v16, the device implemented IBatchDevice, and batch processing also had to be enabled on the queue:

v16

public sealed class MyDevice : BaseDevice<MyConfiguration>, IBatchDevice { public async Task ProcessMessagesAsync( IMessageBatchContext batchContext, CancellationToken cancellationToken) { foreach (var context in batchContext.GetMessageContexts()) { await ProcessMessageAsync(context, cancellationToken); batchContext.MarkSuccessfullyProcessedForThisDevice(context); } } }

In v26, the separate interface and queue mode are gone. Override the base method only when custom batching is required:

v26

public sealed class MyDevice : BaseDevice<MyConfiguration> { public override async Task ProcessMessagesAsync( IMessageBatchContext batchContext, CancellationToken cancellationToken) { foreach (var context in batchContext.GetMessageContexts().ToList()) { // The base class's per-message handling: hooks, OnErrorAsync and retries, error events. await ProcessMessageWithRetryAsync(context, cancellationToken); // A cancelled message is processed again later; stop so no later message overtakes it. if (context.Cancel) { break; } if (!context.StopProcessing) { batchContext.MarkSuccessfullyProcessedForThisDevice(context); } } } }

If you override ProcessMessagesAsync, the base class no longer calls ProcessMessageAsync for you, and does not handle errors, retries or marking unless your override calls the base class methods below. You are responsible for handling both serial and batch processing within ProcessMessagesAsync.

Base class methods for batch processing

You do not have to override ProcessMessagesAsync: the base implementation calls your ProcessMessageAsync for each message, one at a time. For each message, the base class (through ProcessMessageWithRetryAsync, which every path below uses):

  • Calls OnBeforeProcessMessageAsync, your ProcessMessageAsync, then OnAfterProcessMessageAsync (also when processing throws).

  • When ProcessMessageAsync throws, calls OnErrorAsync(context, args, cancellationToken) and writes an Error event on the message. args.ShouldRetry = true retries the message in place after args.SleepTime (5 seconds by default), and args.TotalRetries counts the retries so far. ShouldRetry is false unless you set it. Without a retry, the message gets StopProcessing and goes to the error queue, and the rest of the batch carries on.

  • On a cancellation (OperationCanceledException), sets context.Cancel; the channel processes the message again later.

For each batch, the base ProcessMessagesAsync also marks each message that finished without Cancel or StopProcessing as processed, and stops at a cancelled message so no later message overtakes it.

An override can call these methods:

Method

What it does

Use it when

Method

What it does

Use it when

ProcessMessageWithRetryAsync(context, cancellationToken, processMessage = null) (protected)

The per-message handling above, around your ProcessMessageAsync or the processMessage you pass. Marks nothing.

You loop over the batch yourself, as in the example above.

ProcessMessagesInParallelAsync(batchContext, maxDegreeOfParallelism, cancellationToken) (protected)

The per-message handling above on up to maxDegreeOfParallelism threads, then marks the messages processed.

Your messages can be processed in any order (see Parallel processing below).

batchContext.ProcessInParallelAsync(maxDegreeOfParallelism, processMessage, cancellationToken) (extension in Connexion.Core.BatchExtensions)

Runs processMessage on up to that many threads, starting messages in batch order, and returns the messages processed before the first cancelled one. Marks nothing.

You must do something before marking, such as one write for the whole batch; the built-in validators do this.

Rules for an override:

  • Mark every message you processed with MarkSuccessfullyProcessedForThisDevice, or fail it with MarkFailedForThisDevice(context, message). A message left unmarked, with no error, stays queued and the channel retries it after a delay.

  • Do not let an exception escape ProcessMessagesAsync. The channel treats it as a device bug, fails the messages the override had not finished, and backs off before the next batch.

Parallel processing

A device can process the messages of a batch on several threads. The batch keeps its order when it is handed to the next device; only the order the messages are processed in changes.

Opt in with the plugin flag

For a device whose work on one message does not depend on any other message (converters, validators, most transforms), set DeviceDefinitionFlags.CanProcessInParallel on the plugin:

[DevicePlugin( "My Converter", "Converts a message", DeviceDefinitionFlags.CanProcessInParallel)] public sealed class MyConverterDevice : BaseDevice<MyConfiguration> { // Optional: the degree used while the user leaves the setting empty (1 unless overridden). public override int DefaultDegreeOfParallelism => 2; public override async Task ProcessMessageAsync( IMessageContext context, CancellationToken cancellationToken) { // ... } }

That is all the device needs to do:

  • BaseDevice<T> processes each batch on up to the device's degree of parallelism; do not override ProcessMessagesAsync.

  • The setting is DegreeOfParallelism (an int?) on DeviceConfigurationBase. Empty (null) uses the device's DefaultDegreeOfParallelism. It is saved only when set, so existing configurations are unchanged. Valid values are 1 to 64; a value outside that stops the device when it loads.

  • The Client and Gateway show the setting in the device header, next to the globals and help icons, for devices with the flag. A click picks 1, 2, 4, 8, 12 or 16.

  • ProcessMessageAsync, any custom code hooks, and any device state they use must be safe to run concurrently.

Built-in devices: the HL7v2 to FHIR, HL7v2 to C-CDA, FHIR Validator and C-CDA Validator devices set the flag, with a default of 2. Their own Degree of Parallelism settings moved to the device header; saved values load unchanged.

Senders and other devices: call the base method yourself

Do not set the flag on a device where message order matters to something outside the device, such as most senders. A device whose order does not matter for its own reasons (a sender whose receiver accepts any order) overrides ProcessMessagesAsync and calls the base method, with a limit suited to the service it calls:

public override Task ProcessMessagesAsync( IMessageBatchContext batchContext, CancellationToken cancellationToken) { return ProcessMessagesInParallelAsync(batchContext, maxDegreeOfParallelism: 4, cancellationToken); }

How errors, retries and cancellation behave

Situation

One at a time

In parallel

Situation

One at a time

In parallel

A message fails (no retry)

It goes to the error queue; the rest of the batch carries on.

The same.

A message retries in place (ShouldRetry)

Later messages wait until it succeeds.

Later messages are processed while it retries, so their work and side effects happen first. The batch is still handed on only once it succeeds, in its original order.

A message is cancelled

Processing stops there; it and the later messages are processed again.

No message after it is marked processed, even one that already finished, so none overtakes it; they are processed again.

Processing event subscriptions

v16

[EventSubscription("topic://MessageChannel/BeforeDeviceProcessMessage")] public void OnBeforeDeviceProcessed(object sender, ProcessingEventArgs args) { Inspect(args.MessageContext); }

v26

[AsyncEventSubscription("topic://Channel/BeforeDeviceProcessMessage")] public async ValueTask OnBeforeDeviceProcessed( object sender, ProcessingEventArgs args, CancellationToken cancellationToken) { await InspectAsync(args.MessageContext, cancellationToken); }

Handlers registered with [AsyncEventSubscription] are awaited, allowing cancellation and failures to participate in the normal processing flow.

Configuration, metadata, and UI

Configuration models

v16

[DataContract(IsReference = true)] public sealed class MyConfiguration : NotifyBase, IDataErrorInfo { private string m_Endpoint = string.Empty; [DataMember] public string Endpoint { get => m_Endpoint; set { m_Endpoint = value; RaisePropertyChanged(); } } public string Error => string.Empty; public string this[string columnName] => string.Empty; }

v26

[DataContract(IsReference = true)] public sealed class MyConfiguration : DeviceConfigurationBase, IDataErrorInfo { private string m_Endpoint = string.Empty; [DataMember] public string Endpoint { get => m_Endpoint; set { m_Endpoint = value; RaisePropertyChanged(); } } public string Error => string.Empty; public string this[string columnName] => string.Empty; }

Alternatively, a configuration type can implement IDeviceConfiguration directly.

Device metadata

In v16, the attribute constructor included message types and the UI factory type:

v16

[DevicePlugin( "My Device", "Transforms a message", DeviceDefinitionFlags.None, typeof(object), typeof(object), typeof(MyDeviceFactory))] public sealed class MyDevice : BaseDevice<MyConfiguration> { }

In v26, the constructor has three required values; runtime and UI metadata use named properties:

v26

[DevicePlugin( "My Device", "Transforms a message", DeviceDefinitionFlags.None, Categories = ["Transformation"], DetailedDescriptionResourceName = "MyCompany.MyDevice.DetailedDescription.md", DeviceImageResourceName = "MyCompany.MyDevice.DeviceIcon.png", UserInterfaceFactoryTypeName = "MyCompany.MyDevice.Ui.MyDeviceFactory")] public sealed class MyDevice : BaseDevice<MyConfiguration> { }

Rich runtime descriptions allow API and MCP clients to understand a plugin without loading its UI assembly.

Configuration UI host

v16

public sealed class MyDeviceFactory : BaseDeviceFactory<MyConfiguration> { public override FrameworkElement GetUserInterface( IDeviceUIParams deviceUiParams) { return new MyDeviceView(Configuration, deviceUiParams); } }

v26

public sealed class MyDeviceFactory : BaseDeviceFactory<MyConfiguration> { public override Avalonia.Controls.Control GetUserInterface(IUiHost uiHost) { return new MyDeviceView(Configuration, uiHost); } }

The v26 factory and Avalonia view belong in the net10.0 UI companion project. BaseDeviceFactory<T> remains the factory base class; GetUserInterface(IUiHost) now returns an Avalonia.Controls.Control.

Shared messages between devices

Do not define a typed message inside one device assembly and assume another isolated device can use it reliably. Put shared DTOs, records, enums, value objects, and stable interfaces in a small dedicated contract assembly referenced and packaged at the same version by every participating device.

v16

var order = context.GetMessage<MyCompanyOrder>();

v26

var order = await context.GetMessageAsync<MyCompanyOrder>(cancellationToken);

Treat a patch version as a promise of runtime compatibility. Use a minor or major version when older consumers must remain on the contract they were built against.

Custom Code and external services

The Custom Code device remains available on .NET 10 and uses the same asynchronous lifecycle and message-context APIs as compiled devices.

Generated SOAP/WCF client method names depend on the service contract, but the migration generally looks like this:

v16 - blocking proxy call

var request = context.GetMessage<SubmitRequest>(); var response = client.Submit(request); context.Message = response;

v26 - async-first generated proxy

var request = await context.GetMessageAsync<SubmitRequest>(cancellationToken); var response = await client.SubmitAsync(request); context.SetMessage(response);

The Add Service Reference workflow now uses Microsoft's dotnet-svcutil toolchain and can generate from HTTP/HTTPS metadata or a local WSDL file. Connexion's own runtime transport has moved away from WCF, but custom code can still consume an external SOAP/WCF service through the generated client.

For REST services, prefer modern HttpClient asynchronous methods:

using var response = await httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode();

Inbound endpoints in dynamic clusters

A custom device that opens an inbound TCP, TLS, HTTP, HTTPS, WebSocket, gRPC, or other listener should register that listener when it may run in a multi-active or dynamically scaled cluster. Registration lets Connexion keep the external route aligned with whichever application server or Kubernetes pod currently owns the channel.

For a socket listener, call the protected BaseDevice.RegisterRouteAsync helper after the device has bound its listener successfully:

await RegisterRouteAsync( InboundRouteProtocol.Tcp, listenerPort, cancellationToken);

The helper supplies the device key and accesses the route manager on the device's behalf. Use the InboundRouteRegistration overload when an HTTP-style route needs a PathPrefix or HostMatch, or when the route needs diagnostic Metadata. Each registration call replaces the device's previously registered route set. A device with multiple listeners should call RegisterRoutesAsync with its complete collection of InboundRouteRegistration values.

A registration describes a route that Connexion can expose. It is published only while the channel is running and its owning application server is healthy. Stopping or moving the channel therefore removes or redirects the active route automatically. Registration does not create the public load balancer, Kubernetes Gateway listener, firewall rule, TLS policy, or network policy; those remain deployment responsibilities.

See Dynamic Inbound Routing for supported publishing integrations and the end-to-end routing model.

Windows and Linux compatibility

A net10.0 target does not automatically guarantee Linux compatibility. Replace Windows-only assumptions in runtime code or isolate the device to Windows-capable execution groups.

Windows-only pattern

var folder = @"C:\ProgramData\MyCompany\MyDevice"; var endpoint = Registry.GetValue( @"HKEY_LOCAL_MACHINE\Software\MyCompany", "Endpoint", null) as string;

Cross-platform pattern

var folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "MyCompany", "MyDevice"); var endpoint = Configuration.Endpoint;

Also review the runtime project for COM components, Windows service APIs, WPF or Windows Forms references, Windows-only native DLLs, authentication behavior, file permissions, case-sensitive filenames, and platform-specific certificate-store access.

Development and debugging

Normal unit and integration tests remain the first line of defense. Also load the packaged plugin into a test Connexion instance and review its assembly-load diagnostics.

The v26 MCP tools can assist by inspecting plugin metadata, inserting a device into a test channel, patching configuration, validating and saving channel structure, and correlating runtime events, logs, queue state, and device statistics. See AI-Assisted Connexion with MCP.

MCP does not itself control the Visual Studio or Visual Studio Code step-through debugger. A local AI connected to both Connexion MCP and IDE tooling could combine the two contexts, while the debugger remains responsible for breakpoints, stepping, variables, and source navigation.