Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated May 5, 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 fallback 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
            });
        }
    }
}

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)
Set the fallback type

Set Message Type Fallback to WebOrder, 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.

Classify and disassemble the inbound file

In the port’s inbound flow, set Message Type Fallback to MarketplaceOrders, from Step 1, so the raw CSV carries a type, then reference the MarketplaceCsvDisassembler component, from Step 4, in the inbound flow. The component emits the canonical Order JSON and classifies it as Order; there is no map on this channel, because the CSV could not be a map source.


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 Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.

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.

Turn bodies off in production. Enabled + Body is the most expensive tracking level, extra processing and database space, and it records every payload including successful runs. Once the flow is proven, set the ports to Only on Error instead: failures still capture the full body for diagnosis, while healthy runs are not recorded.