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 |
|---|---|---|
Device runtime |
| Device class, configuration model, processing logic, runtime metadata, and cross-platform dependencies |
UI companion |
| 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 |
|---|---|
|
|
|
|
|
|
Constructor receives the device key and channel object | Parameterless construction; Connexion supplies |
|
|
|
|
|
|
|
|
Top-level message metadata properties | Properties nested under |
|
|
Separate | Override |
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, yourProcessMessageAsync, thenOnAfterProcessMessageAsync(also when processing throws).When
ProcessMessageAsyncthrows, callsOnErrorAsync(context, args, cancellationToken)and writes an Error event on the message.args.ShouldRetry = trueretries the message in place afterargs.SleepTime(5 seconds by default), andargs.TotalRetriescounts the retries so far.ShouldRetryis false unless you set it. Without a retry, the message getsStopProcessingand goes to the error queue, and the rest of the batch carries on.On a cancellation (
OperationCanceledException), setscontext.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 |
|---|---|---|
| The per-message handling above, around your | You loop over the batch yourself, as in the example above. |
| The per-message handling above on up to | Your messages can be processed in any order (see Parallel processing below). |
| Runs | 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 withMarkFailedForThisDevice(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 overrideProcessMessagesAsync.The setting is
DegreeOfParallelism(anint?) onDeviceConfigurationBase. Empty (null) uses the device'sDefaultDegreeOfParallelism. 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 |
|---|---|---|
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 ( | 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.