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, a clearer separation between cross-platform runtime code and Windows UI code, and simplified batch processing through the base device API. The examples below use C# and show the common v16 pattern followed by its 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 cross-platform runtime code and Windows UI code in separate projects:
Project | Target | Contains |
|---|---|---|
Device runtime |
| Device class, configuration model, processing logic, runtime metadata, and cross-platform dependencies |
UI companion |
| WPF controls, view models, configuration factory, overlays, and other Windows-only UI code |
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-windows</TargetFramework>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<Reference Include="Connexion.Core" />
<Reference Include="Connexion.Share.Ui" />
<ProjectReference Include="..\MyDevice\MyDevice.csproj" />
</ItemGroup>
</Project>The server-side/runtime project should not reference WPF, Connexion.Share.Ui, or any UI components. A clean runtime device can run on both Windows and Linux. The UI companion remains Windows-only because the Connexion management client uses WPF. The UI project will reference the server-side project.
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, "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);
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.
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 the convenient materialized form and adds scalable carriers for large content:
v26 - materialized access
var text = await context.GetMessageAsync<string>(cancellationToken);v26 - streamed access
using var payload = await context.GetMessageAsync<StringPayload>(cancellationToken);
await using var source = await payload.OpenStreamAsync(cancellationToken);
await source.CopyToAsync(destination, cancellationToken);Use BinaryPayload instead of StringPayload when every input byte must be preserved exactly. Streaming is an optional access pattern inside the normal ProcessMessageAsync method, not a separate device mode.
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,
shouldCompress: true,
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())
{
await ProcessMessageAsync(context, cancellationToken);
batchContext.MarkSuccessfullyProcessedForThisDevice(context);
}
}
}If you override batch processing, ProcessMessageAsync is no longer called. You are responsible for handling both serial and batch processing within the ProcessMessagesAsync method.
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 Windows 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 FrameworkElement GetUserInterface(IUiHost uiHost)
{
return new MyDeviceView(Configuration, uiHost);
}
}The v26 factory and WPF view belong in the net10.0-windows UI companion project.
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.