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.
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:
{{Message.MessageType}} == "Order"
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.
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
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:
| Name | Format | Purpose |
|---|---|---|
| WebOrder | JSON | the storefront’s payload as it arrives, the map source for channel one |
| MarketplaceOrders | CSV | the marketplace’s raw order file as the fetch downloads it, the fallback type the disassembler component reads; never a map source |
| Order | JSON | the 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.
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.
| Setting | Value |
|---|---|
| Name | StorefrontToken |
| Adapter | API Listener |
| Definition | API Listener Token |
| Token (Partner Config) | the secret the storefront will present on every call |
| Setting | Value |
|---|---|
| Name | MarketplaceSftp |
| Adapter | SFTP Caller |
| Definition | SFTP Password Authentication |
| Username / Password | the marketplace’s credentials |
| Host Key Fingerprint | the marketplace’s host-key fingerprint |
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.)
| Map setting | Value |
|---|---|
| Name | WebOrderToOrder |
| Source message type | WebOrder |
| Target message type | Order |
A sample WebOrder as the storefront posts it:
{
"order_no": "10472",
"ship_region": "EMEA",
"buyer": { "ref": "C-2201", "company": "Nordwind Retail" },
"account_mgr": "j.driessen@example.com",
"grand_total": 1249.50
}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:
<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>
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.
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:
{
"orderNumber": "MP-99021",
"channel": "marketplace",
"region": "EMEA",
"customer": { "id": "C-2201", "name": "Brams Outdoor BV" },
"total": 412.00,
"currency": "EUR"
}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 }); } } }
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.)
| Setting | Value |
|---|---|
| Adapter | API Listener |
| Way | One |
In the adapter configuration, pair the port with StorefrontToken, from Step 2, so every caller must present it.
Declare the two matching headers that keep this Listener unique on the shared ingress (routing on a shared ingress covers the full rule):
| Header | Value | Why |
|---|---|---|
| App | AcmeOrders | the Application’s namespace, narrows the request to this Application |
| Purpose | OrderIntake | keeps this Listener unique if the App later gains a second one (e.g. a stock lookup) |
Set Message Type Fallback to WebOrder, from Step 1.
On the inbound side, select WebOrderToOrder, from Step 3, so the storefront’s payload publishes as the canonical Order.
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.
Create a receive port MarketplaceClock on the Scheduler:
| Setting | Value |
|---|---|
| Repeat Every | 1 |
| Repeat Unit | Days |
| Daily Window | opening 06:00:00 AM, after the marketplace’s overnight publish |
Create a two-way SFTP Caller send port MarketplaceFetch, subscribing on {{Config.PortName}} == "MarketplaceClock", the clock from Step 7:
| Setting | Value |
|---|---|
| Adapter | SFTP Caller |
| Way | Two |
| Host | sftp.marketplace.example |
| Command | Download |
| Remote path | /outbound/orders.csv, the file the marketplace publishes overnight |
Set Authentication to MarketplaceSftp, from Step 2.
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.
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.
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.
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.
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, 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.
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.