Skip to content
iQBus HomeDocumentationBlogContact
Updated August 2, 2026
Tutorials/Routing & pub-sub/Two channels, one order

Two channels, one order

The same business gets orders from two places. Customers buy on the company webshop, and a separate online marketplace sends through the orders placed there too. The webshop reports each order the instant it happens. The marketplace gathers a day’s worth of orders into one file and leaves it on a server overnight, in a different format with different field names. Everyone further down the line, the warehouse, finance, the people who chase orders, should not have to learn two formats. The goal is that both sources turn into one tidy, agreed kind of order, so the rest of the business only ever sees that single shape. The reward comes when a third sales channel signs up: it slots in with almost no new work.

So picture two doors into the business. One is the live webshop feed, arriving order by order as customers check out. The other is the nightly marketplace file, a batch of orders waiting on a server to be collected. Different formats, different rhythms, but both are just orders. The whole point is to fold them into one common order before anyone downstream looks at them.

Two sources, one agreed order Webshop live, order by order Marketplace nightly file on a server Turn into one common order One order shape read by everyone Warehouse Finance Notifications

Here is how Art2link ESB does that. Messages travel across a shared backbone called the bus, and the common order is a single agreed shape, the canonical Order, that everything downstream reads. The trick is to convert each source into that one shape right at the edge, before it reaches the bus, the canonical data model earning its keep.

Channel one is the familiar intake: an API Listener (the adapter, the connector to a particular kind of system, here an HTTP endpoint) receives WebOrder JSON, and because that payload is already JSON a map (a rule that rewrites one shape into another) converts it to Order on the way in. Channel two is the scheduled-fetch shape: a Scheduler tick drives a two-way SFTP Caller that downloads the marketplace's order CSV; that CSV cannot be a map source, so a pipeline component (a small piece of code that runs inside the flow to reshape a message) disassembles it into the same canonical Order JSON and classifies the emitted message as Order. One map and one component, one target message type, normalize onto the bus.

From the bus's point of view there is now exactly one kind of order. Each downstream port states which messages it wants, a subscription, rather than being handed work directly. The fulfilment port, the finance port, the fan-out, the regional router, all subscribe to the canonical type and serve both channels without knowing two channels exist:

EXPRESSION
{{Message.MessageType}} == "Order"
Webshop MarketplaceSFTP, CSV JSON fetch API Listener map WebOrder → Order Scheduler + SFTP Caller component MarketplaceOrders → Order BUS Order Fulfilment port(s)built once Finance portbuilt once

The test of the design is the third channel. When a second marketplace signs up, the work is one receive path and one map to Order, the entire downstream estate is inherited for free. Without the canonical model that would have been N new point-to-point connections; with it, it is one normalizer.

When it fails. Each channel fails at its own edge, and edge failures are receive-side failures: a webshop payload that will not map is rejected at the API Listener as a poison message; a marketplace CSV with a malformed row fails its map on the way in. In both cases the run is held in the Suspended state, the built-in dead-letter channel, with the original payload intact in tracking, and downstream ports never see a half-translated order. For the email, turn on Activity Notifications for the Application (On Error Only) so a suspended channel pages someone; once the map or the file is fixed, Resume the held run and the order continues as if nothing happened. The downstream send ports keep their own exception handling exactly as built in order intake and fan-out.

Keep a channel marker. Carry the order's origin into a canonical field (or promote it), not so downstream logic can branch on it, but so support can answer "which channel did this order come from?" without forensics. If you find ports routing on the channel, the canonical model is leaking.
Why the marketplace channel uses a component, not a map. A map’s source format must be XML or JSON, so the CSV the marketplace publishes can never be a map source. The fetched CSV is disassembled to Order JSON by a pipeline component first; the component classifies its output as Order, so the bus carries JSON. The webshop channel keeps its map only because its payload is already JSON. The flow reads: raw CSV → component disassembles to Order JSON → canonical on the bus, with no CSV-source map anywhere.

Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.

Before you start. Create the Application AcmeOrders with its namespace under Applications and select it, so every artifact below lands in it.
1
Step One
Create the message types
The three types

Under the application’s Message types, create the three types the two channels route on. A message type is a name plus a format, no schema needed; add one later only for validation:

NameFormatPurpose
WebOrderJSONthe storefront’s payload as it arrives, the map source for channel one
MarketplaceOrdersCSVthe marketplace’s raw order file as the fetch downloads it, the Adapter Message Type the disassembler component reads; never a map source
OrderJSONthe canonical type both channels converge on

No promotions are needed in this flow: the subscriptions route on the message type or the originating port, never on a payload field.


2
Step Two
Create the Authentications

Two channels, two credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.

Storefront credential, for the API Listener
SettingValue
NameStorefrontToken
AdapterAPI Listener
DefinitionAPI Listener Token
Token (Partner Config)the secret the storefront will present on every call
Marketplace server, for the SFTP Caller
SettingValue
NameMarketplaceSftp
AdapterSFTP Caller
DefinitionSFTP Password Authentication
Username / Passwordthe marketplace’s credentials
Host Key Fingerprintthe marketplace’s host-key fingerprint

3
Step Three
Build the webshop map

Under the application’s Maps, create channel one’s map. The webshop payload is JSON, so a map is the right tool: a map’s source must be JSON or XML, and this source is JSON. The map is defined by its name and the two types it sits between, both from Step 1. (Channel two’s CSV gets a pipeline component in Step 4, not a map.)

Create WebOrderToOrder
Map settingValue
NameWebOrderToOrder
Source message typeWebOrder
Target message typeOrder
The sample WebOrder

A sample WebOrder as the storefront posts it:

JSON
{
  "order_no": "10472",
  "ship_region": "EMEA",
  "buyer": { "ref": "C-2201", "company": "Nordwind Retail" },
  "account_mgr": "j.driessen@example.com",
  "grand_total": 1249.50
}
Author the webshop XSLT

Maps are authored in XSLT 3.0 and run on Saxon HE 12.9. The JSON arrives as a string, so the template matches / and parses it with parse-json(); the result is serialized back to JSON, channel marker included:

XSLT 3.0
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <xsl:output method="text"/>

  <!-- The WebOrder JSON arrives as a string; parse-json() turns it into an XPath 3.1 map -->
  <xsl:template match="/">
    <xsl:variable name="in" select="parse-json(.)"/>
    <xsl:variable name="order" select="map {
      'orderNumber': concat('SO-', $in?order_no),
      'channel': 'webshop',
      'region': string($in?ship_region),
      'customer': map {
        'id': string($in?buyer?ref),
        'name': string($in?buyer?company)
      },
      'accountManager': map { 'email': string($in?account_mgr) },
      'total': number($in?grand_total),
      'currency': 'EUR'
    }"/>
    <xsl:value-of select="serialize($order,
      map { 'method': 'json', 'indent': true() })"/>
  </xsl:template>
</xsl:stylesheet>

4
Step Four
Create the marketplace disassembler component

Channel two’s file is CSV, which can never be a map source, so the parse belongs in a pipeline component. Under the application’s Pipeline components, create MarketplaceCsvDisassembler: it reads the raw CSV body, builds the same canonical Order JSON the webshop map produces (channel marker included), and classifies each emitted message as Order so the bus carries JSON. A file carrying many orders emits one message per row; the sample below carries one.

The sample marketplace CSV
CSV
order_ref,buyer_company,buyer_ref,total,currency,region
MP-99021,Brams Outdoor BV,C-2201,412.00,EUR,EMEA

becomes the same canonical shape the webshop map produces, classified as Order:

JSON
{
  "orderNumber": "MP-99021",
  "channel": "marketplace",
  "region": "EMEA",
  "customer": { "id": "C-2201", "name": "Brams Outdoor BV" },
  "total": 412.00,
  "currency": "EUR"
}
The component code
C#
using System.Text.Json;
using CC.Art2link.Pipelines.Domain.Models.PipelineComponents;

public sealed class MarketplaceConfig
{
    // No per-message values: the canonical shape is fixed by the marketplace layout.
}

public sealed class MarketplaceCsvDisassembler
    : PipelineComponentBase<MarketplaceConfig>
{
    public override string Name => "MarketplaceCsvDisassembler";

    protected override Task<PipelineComponentOutput> ExecuteAsync(
        PipelineComponentInput input,
        MarketplaceConfig config,
        CancellationToken cancellationToken)
    {
        try
        {
            // Read the raw CSV body the SFTP download handed in.
            var lines = input.Body
                .Split('\n')
                .Select(l => l.TrimEnd('\r'))
                .Where(l => l.Length > 0)
                .ToList();

            var messages = new List<PipelineMessage>();
            foreach (var line in lines.Skip(1))   // skip header row
            {
                var c = line.Split(',');
                var json = JsonSerializer.Serialize(new
                {
                    orderNumber = c[0],
                    channel     = "marketplace",   // channel marker
                    region      = c[5],
                    customer    = new { id = c[2], name = c[1] },
                    total       = decimal.Parse(c[3]),
                    currency    = c[4]
                });
                // Classify each message as the canonical type: the bus carries JSON, not CSV.
                messages.Add(new PipelineMessage
                {
                    Body        = json,
                    MessageType = "Order"
                });
            }

            return Task.FromResult(new PipelineComponentOutput
            {
                Success = true, Messages = messages
            });
        }
        catch (Exception ex)
        {
            return Task.FromResult(new PipelineComponentOutput
            {
                Success      = false,
                ErrorMessage = $"MarketplaceCsvDisassembler: {ex.Message}",
                Exception    = ex
            });
        }
    }
}
Wrap the component in a pipeline

The fetch port in Step 8 cannot point at this component directly. It points at a pipeline, a named, ordered list of components that also carries the default values for each component’s configuration properties. Create one to hold the disassembler; MarketplaceConfig exposes no properties, because the canonical shape is fixed by the marketplace layout, so the pipeline is simply the named holder the port selects:

SettingValue
NameMarketplaceImportPipeline
ComponentsMarketplaceCsvDisassembler (this step)

5
Step Five
Create the webshop receive port
General

If the online order intake build already lives in this Application, skip this step: the channel below is that build. Otherwise create channel one now: a receive port WebshopOrders bound to the API Listener. Everything it references already exists, so each field is a pure selection. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)

SettingValue
AdapterAPI Listener
WayOne
Pair the storefront credential

In the adapter configuration, pair the port with StorefrontToken, from Step 2, so every caller must present it.

Declare the routing headers

Declare the two matching headers that keep this Listener unique on the shared ingress (routing on a shared ingress covers the full rule):

HeaderValueWhy
AppAcmeOrdersthe Application’s namespace, narrows the request to this Application
PurposeOrderIntakekeeps this Listener unique if the App later gains a second one (e.g. a stock lookup)
Type the message at the adapter

Every receive port carries an Adapter Message Type, and it is required: the runtime stamps it the instant the Listener hands the posted body to the next step inside the port, so nothing travels untyped.

SettingValue
Adapter Message TypeWebOrder, from Step 1
Select the map

On the inbound side, select WebOrderToOrder, from Step 3, so the storefront’s payload publishes as the canonical Order.


6
Step Six
Create the OrderSink stand-in

Give the bus one observable consumer: a send port OrderSink with no adapter (a send port without an adapter), subscribing on {{Message.MessageType}} == "Order". It stands in for the downstream estate, the fulfilment, finance, and notification ports of the fan-out, so the convergence is visible in tracking even on an empty environment.


7
Step Seven
Create the marketplace clock

Create a receive port MarketplaceClock on the Scheduler:

SettingValue
Repeat Every1
Repeat UnitDays
Daily Windowopening 06:00:00 AM, after the marketplace’s overnight publish

8
Step Eight
Create the marketplace fetch port
General

Create a two-way SFTP Caller send port MarketplaceFetch, subscribing on {{Config.PortName}} == "MarketplaceClock", the clock from Step 7:

SettingValue
AdapterSFTP Caller
WayTwo
Hostsftp.marketplace.example
CommandDownload
Remote path/outbound/orders.csv, the file the marketplace publishes overnight
Pair the marketplace credential

Set Authentication to MarketplaceSftp, from Step 2.

Type and disassemble the fetched file

The downloaded file comes back on the port’s response side, which is an inbound position: the message is entering the bus there, so that side carries a required Adapter Message Type too. The raw CSV is typed the moment the SFTP Caller releases it, then MarketplaceImportPipeline, from Step 4, runs the MarketplaceCsvDisassembler it holds, which splits the file and states Order on each message it emits. The component re-typing the message is not a special case, it is the rule: every message a component returns carries the type the component puts on it. There is no map on this channel, because the CSV could not be a map source.

SettingValue
Adapter Message TypeMarketplaceOrders, from Step 1
Response pipelineMarketplaceImportPipeline, from Step 4

9
Step Nine
Touch nothing downstream

This step is the point: do not create or modify any downstream port. Every subscriber on {{Message.MessageType}} == "Order" serves the new channel as-is: the OrderSink stand-in from Step 6 today, the fulfilment, finance, and notification ports of a real estate tomorrow.

The webshop channel keeps its order-intake API Listener unchanged, App and Purpose headers and all, because the new channel arrives over SFTP, not the shared API ingress, so there is no Listener to make unique here.


10
Step Ten
Wire the edge alarms

Turn on Activity Notifications for the Application (On Error Only). A payload that fails on either channel suspends at its own edge and emails the team: a webshop payload that will not map, or a marketplace CSV the disassembler component cannot parse. The original payload is held in tracking for Resume.


11
Step Eleven
Start the ports and test
Start in dependency order

Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, OrderSink and MarketplaceFetch, then the receive ports WebshopOrders and MarketplaceClock.

Turn on tracking

Set the Application’s tracking level to Enabled + Body so you can walk each run step by step, with its message body. Tracking is an Application-level setting, so this one choice covers every port the flow touches, and you can drop it back to Only on Error once the flow is proven.

Run one order through each channel

Run one order through each channel, a webshop POST and a marketplace CSV fetch. To test the fetch without waiting for the daily window, temporarily set MarketplaceClock to repeat every 1 Minutes, then restore the schedule. What you should see: the fetch run in tracking shows the download, the MarketplaceCsvDisassembler turning the CSV into JSON, and one canonical Order published to the bus.

Prove the convergence

In tracking, both runs converge on identical Order publications and identical downstream deliveries; only the channel marker differs. That equivalence is your acceptance test for the canonical model.