Skip to content
Art2link ESB v2.02 LTS HomeDocumentationBlogContact
Tutorials/Healthcare & EDI/FHIR lab results outbound

FHIR lab results outbound

A hospital lab finishes a patient’s test, a blood count, a glucose level, and saves the result in its own lab system. A regional health platform, the shared record that doctors across the region can see, needs a copy of every finalized result. The lab system cannot reach out and send anything, so something has to notice each new result and hand it across. The rule is plain: every result the lab marks final shows up on the regional platform, once, in the platform’s own format. If a result is corrected and finalized again, the platform’s copy is updated, not duplicated. And if the platform rejects a result because something about it is wrong, that one result is held for a person to fix while the rest keep flowing.

So the shape is small. Every few minutes, look in the lab system for results that are newly final, and send each one on to the regional platform.

Every finalized result reaches the regional platform Lab system finalized results Check for new, send each on every few minutes Regional health platform result created or updated, never doubled Result rejected held for a person, the rest keep flowing accepted rejected

The lab system is the LIS, and the regional platform speaks FHIR R4, a healthcare data standard, accepting Observation resources over a REST API. Unlike EDI or HL7 v2, FHIR is native JSON, so where those flows need a custom pipeline component (a small piece of custom code that reshapes a message in transit) to speak the dialect, the edge transform here is just a map (a named rule that turns one message shape into another). Here is how Art2link ESB builds it.

The shape is the shipment-status build wearing a lab coat. Everything rides a shared message backbone called the bus: parts of the flow publish messages onto it, and other parts subscribe to the ones they care about. A Scheduler (an adapter, a connector to one kind of outside system, here a clock) ticks every 10 minutes and wakes a two-way SQL Caller send port (a configured endpoint, this one querying a database) whose procedure returns finalized results past a high-water mark, a remembered marker of how far it last read, advanced in the same transaction. That procedure ends in FOR XML, so its result is the XML SqlCallerResult envelope; a SqlCallerResultSplitter pipeline component splits it into one XML <Row> per result, and a map publishes each row onto the bus as a canonical LabResult (JSON). The delivery port, an API Caller send port, subscribes to those results (a subscription is the filter a port uses to pick up only the messages it wants), maps LabResult to a FHIR Observation at the edge, and PUTs it to the platform:

EXPRESSIONsubscription filter
{{Message.MessageType}} == "LabResult"
TEMPLATEconditional PUT URL
PUT https://fhir.region.example/r4/Observation?identifier=urn:lab:result|{{Promoted.LabResult.ResultId}}

The conditional PUT on the lab’s own result identifier is FHIR’s gift to integration: create-or-update in one verb, which makes the push idempotent by design, a replayed result updates the same Observation instead of duplicating it.

Scheduler every 10 min BUS SQL Caller (two-way) finalized results > mark LIS DB LabResult ×N API Caller map → Observation, PUT FHIR serverR4 /Observation

When it fails. The platform validates hard, a wrong LOINC code or a missing patient reference earns an HTTP 422 with an OperationOutcome explaining why. That single result fails alone under the shared exception type LabPushFailed, the O365 Mail alert carries the outcome’s diagnostics (result id, not patient name), and the rest of the poll’s results sail on; fix the code mapping, replay, and the conditional PUT converges. PHI rules from the 835 flow apply unchanged, with the logs as evidence trail.

EXPRESSIONfailure subscription
{{Message.MessageType}} == "LabPushFailed"
Map to the platform’s profile, not just to R4. Every national or regional FHIR platform constrains the base resource, required identifier systems, allowed code systems, mandatory extensions. Get their profile documentation first and treat it as the target of the map; “valid FHIR” that violates the profile still bounces with a 422.

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

Before you start. Every artifact below, ports, message types, the splitter, the maps, lives inside one Application. Under Applications, create it first, Name and Namespace GenHosp, both code-safe, no dots or special characters, and select it so the steps build into it.
1
Step One
Create the message types
The four types

Under the application’s Message types, create the four types this flow uses. A message type is a name plus a format, no schema required:

NameFormatPurpose
LabResultRowXMLone result row as it leaves the splitter (Step 4)
LabResultJSONthe canonical result the rest of the flow consumes
ObservationJSONthe FHIR R4 resource the platform accepts (Step 6)
LabPushFailedJSONthe exception type the failure path publishes under (Step 10)

Promotions live on the message type, so add them while you are here. The push port’s conditional URL (Step 9) and the alert subject (Step 10) bind these values, and adapter parameters are plain strings: they bind {{…}} tokens but never evaluate a body path.

Promotion on LabResult
PromotionPath
ResultId$.resultId
Promotion on LabPushFailed

A failed push re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:

PromotionPath
ResultId$.resultId

2
Step Two
Create the Authentications

Three external parties, three 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.

LIS database, for the SQL Caller
SettingValue
NameLisDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection String (Database Config)the LIS database’s connection string, credentials included
FHIR platform, for the API Caller
SettingValue
NameFhirBearer
AdapterAPI Caller
DefinitionAPI Bearer Token
Login URL / Refresh Token URLthe FHIR platform’s token endpoint and its refresh endpoint
Token / Refresh Token JSON Pathpaths into the token response
Username / Passwordthe credentials the platform issued

The adapter acquires the token, injects the Bearer header, and refreshes it.

Alert mailbox, for O365 Mail
SettingValue
NameO365OpsMail
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted

3
Step Three
Create the database objects

Create the tables and the procedure in the LIS database; the poll port (Step 8) calls the procedure. It returns finalized results past the high-water mark in dbo.ExportWatermark and advances the mark in the same transaction; seed the watermark at zero. Effective is a DATETIMEOFFSET, so the ISO 8601 timestamp the platform expects, offset included, comes straight out of the CONVERT. The result SELECT ends in FOR XML PATH('Row'), ROOT('Result'), so the SQL Caller’s response is XML, exactly what the splitter (Step 4) expects. The seed rows give the first tick something to export; the prelim row is the one you will finalize in Step 11.

Tables, seed rows, and the finalized-results procedure
SQLLIS schema and procedure
CREATE TABLE dbo.LabResults (
    ResultSeq BIGINT IDENTITY NOT NULL PRIMARY KEY,
    ResultId  VARCHAR(40)  NOT NULL UNIQUE,
    Mrn       VARCHAR(40)  NOT NULL,
    Loinc     VARCHAR(20)  NOT NULL,
    TestName  NVARCHAR(100) NOT NULL,
    Value     DECIMAL(18,4) NOT NULL,
    Unit      VARCHAR(20)  NOT NULL,
    Status    VARCHAR(20)  NOT NULL,
    Effective DATETIMEOFFSET(0) NOT NULL
);

CREATE TABLE dbo.ExportWatermark (
    FeedName       VARCHAR(40) NOT NULL PRIMARY KEY,
    LastExportedId BIGINT      NOT NULL CONSTRAINT DF_Watermark DEFAULT 0
);
INSERT INTO dbo.ExportWatermark (FeedName, LastExportedId)
VALUES ('LabResults', 0);

INSERT INTO dbo.LabResults
    (ResultId, Mrn, Loinc, TestName, Value, Unit, Status, Effective)
VALUES
    ('LR-553201', 'MRN-104872', '718-7',  'Hemoglobin',  13.6, 'g/dL',    'final',  '2026-06-05 06:40:00 +02:00'),
    ('LR-553202', 'MRN-104872', '2345-7', 'Glucose',      5.4, 'mmol/L',  'final',  '2026-06-05 06:55:00 +02:00'),
    ('LR-553203', 'MRN-118209', '751-8',  'Neutrophils',  4.2, '10*3/uL', 'prelim', '2026-06-05 07:02:00 +02:00');

CREATE OR ALTER PROCEDURE dbo.GetFinalizedResults
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @from BIGINT, @to BIGINT;
    BEGIN TRANSACTION;
        SELECT @from = LastExportedId
        FROM dbo.ExportWatermark WITH (UPDLOCK, HOLDLOCK)
        WHERE FeedName = 'LabResults';

        SELECT @to = MAX(ResultSeq) FROM dbo.LabResults WHERE Status = 'final';

        SELECT ResultId, Mrn, Loinc, TestName, Value, Unit, Status,
               CONVERT(VARCHAR(33), Effective, 126) AS Effective
        FROM dbo.LabResults
        WHERE Status = 'final' AND ResultSeq > @from
        ORDER BY ResultSeq
        FOR XML PATH('Row'), ROOT('Result');

        UPDATE dbo.ExportWatermark
        SET LastExportedId = ISNULL(@to, @from)
        WHERE FeedName = 'LabResults';
    COMMIT;
END;

4
Step Four
Build the splitter component
SqlCallerResultSplitter

In Pipeline components, create the reusable SqlCallerResultSplitter that turns the SqlCallerResult envelope into one message per <Row>, the same component as shipment status, repeated here so this tutorial is self-contained. The poll port (Step 8) references it and supplies its RowMessageType configuration; the component itself carries no flow-specific values. SqlCallerResult is the SQL Caller’s built-in result envelope (you do not create it as a message type; the adapter classifies its two-way response under that name).

The envelope it parses

Because the procedure’s result SELECT ends in FOR XML PATH('Row'), ROOT('Result'), the SqlCallerResult body is XML, one <Row> per finalized result, each column an element. This is the shape the splitter parses and the Step 5 map reads:

XMLSqlCallerResult envelope
<Result>
  <Row>
    <ResultId>LR-553201</ResultId>
    <Mrn>MRN-104872</Mrn>
    <Loinc>718-7</Loinc>
    <TestName>Hemoglobin</TestName>
    <Value>13.6</Value>
    <Unit>g/dL</Unit>
    <Status>final</Status>
    <Effective>2026-06-05T06:40:00+02:00</Effective>
  </Row>
</Result>
The SQL Caller returns what the SELECT asks for. This query ends in FOR XML, so the result is XML, and the splitter consumes XML to match. A plain SELECT would have returned JSON, which would match nothing here. If the formats disagree, the splitter parses nothing and the message republishes empty.
The component code
C#SqlCallerResultSplitter
using System.Xml.Linq;
using System.ComponentModel.DataAnnotations;
using CC.Art2link.Pipelines.Domain.Models.PipelineComponents;

public sealed class SqlSplitterConfig
{
    // Message Type assigned to each row message before the map runs.
    [Required] public string RowMessageType { get; set; } = string.Empty;
}

public sealed class SqlCallerResultSplitter
    : PipelineComponentBase<SqlSplitterConfig>
{
    public override string Name => "SqlCallerResultSplitter";

    protected override Task<PipelineComponentOutput> ExecuteAsync(
        PipelineComponentInput input,
        SqlSplitterConfig config,
        CancellationToken cancellationToken)
    {
        try
        {
            var doc  = XDocument.Parse(input.Body);
            var rows = doc.Root?.Elements("Row") ?? Enumerable.Empty<XElement>();

            var messages = rows
                .Select(r => new PipelineMessage
                {
                    Body        = r.ToString(),
                    MessageType = config.RowMessageType
                })
                .ToList();

            return Task.FromResult(new PipelineComponentOutput
            {
                Success  = true,
                Messages = messages   // empty list => nothing published
            });
        }
        catch (Exception ex)
        {
            return Task.FromResult(new PipelineComponentOutput
            {
                Success      = false,
                ErrorMessage = $"SqlCallerResultSplitter: {ex.Message}",
                Exception    = ex
            });
        }
    }
}

5
Step Five
Build the bridge map
Create the map

Under the application’s Maps, create the bridge map. A map is not a file, it is a named resource stored in the platform, defined by its name and the two message types it sits between, both created in Step 1:

Map settingValue
NameLabResultRowToLabResult
Source message typeLabResultRow, from Step 1
Target message typeLabResult, from Step 1
The sample row

Every row the splitter emits is one XML <Row>, the map’s source:

XMLmap source row
<Row>
  <ResultId>LR-553201</ResultId>
  <Mrn>MRN-104872</Mrn>
  <Loinc>718-7</Loinc>
  <TestName>Hemoglobin</TestName>
  <Value>13.6</Value>
  <Unit>g/dL</Unit>
  <Status>final</Status>
  <Effective>2026-06-05T06:40:00+02:00</Effective>
</Row>
The canonical LabResult

The map’s target, the shape everything downstream consumes:

JSONcanonical LabResult
{
  "resultId": "LR-553201",
  "mrn": "MRN-104872",
  "loinc": "718-7",
  "test": "Hemoglobin",
  "value": 13.6,
  "unit": "g/dL",
  "status": "final",
  "effective": "2026-06-05T06:40:00+02:00"
}
Author the XSLT

Maps are authored in XSLT 3.0 and run on Saxon HE 12.9. The row is XML and the canonical LabResult is JSON, so the map reads the elements with XPath and serializes a JSON map; everything downstream, the Observation map included, reads JPath.

XSLT 3.0LabResultRowToLabResult.xslt
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="text"/>
  <xsl:template match="/Row">
    <xsl:value-of select="serialize(
      map {
        'resultId': string(ResultId),
        'mrn': string(Mrn),
        'loinc': string(Loinc),
        'test': string(TestName),
        'value': number(Value),
        'unit': string(Unit),
        'status': string(Status),
        'effective': string(Effective)
      },
      map { 'method': 'json', 'indent': true() })"/>
  </xsl:template>
</xsl:stylesheet>

6
Step Six
Build the Observation map
Create the map

The second map runs at the edge, on the push port (Step 9), against the platform’s R4 profile; the AI assistant’s map mode with both samples is the fast path. No pipeline component here: JSON to JSON is exactly what maps are for.

Map settingValue
NameLabResultToObservation
Source message typeLabResult, from Step 1
Target message typeObservation, from Step 1
The sample Observation
JSONFHIR R4 Observation
{
  "resourceType": "Observation",
  "identifier": [ { "system": "urn:lab:result", "value": "LR-553201" } ],
  "status": "final",
  "code": { "coding": [ { "system": "http://loinc.org", "code": "718-7", "display": "Hemoglobin" } ] },
  "subject": { "identifier": { "system": "urn:genhosp:mrn", "value": "MRN-104872" } },
  "effectiveDateTime": "2026-06-05T06:40:00+02:00",
  "valueQuantity": { "value": 13.6, "unit": "g/dL", "system": "http://unitsofmeasure.org", "code": "g/dL" }
}
Author the XSLT

The map reads the canonical JSON and builds the resource, identifier systems, LOINC coding, UCUM units:

XSLT 3.0LabResultToObservation.xslt
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="text"/>
  <xsl:template match="/">
    <xsl:variable name="in" select="parse-json(.)"/>
    <xsl:value-of select="serialize(
      map {
        'resourceType': 'Observation',
        'identifier': array {
          map { 'system': 'urn:lab:result', 'value': string($in?resultId) }
        },
        'status': string($in?status),
        'code': map {
          'coding': array {
            map {
              'system': 'http://loinc.org',
              'code': string($in?loinc),
              'display': string($in?test)
            }
          }
        },
        'subject': map {
          'identifier': map {
            'system': 'urn:genhosp:mrn',
            'value': string($in?mrn)
          }
        },
        'effectiveDateTime': string($in?effective),
        'valueQuantity': map {
          'value': number($in?value),
          'unit': string($in?unit),
          'system': 'http://unitsofmeasure.org',
          'code': string($in?unit)
        }
      },
      map { 'method': 'json', 'indent': true() })"/>
  </xsl:template>
</xsl:stylesheet>

7
Step Seven
Create the Scheduler clock
General

Create the Scheduler receive port LabResultClock, the tick that drives the poll. Everything the ports from here on reference 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
AdapterScheduler
Repeat Every10
Repeat UnitMinutes

8
Step Eight
Create the poll port
General

Create the two-way SQL Caller send port LabResultPoll, subscribing on {{Config.PortName}} == "LabResultClock".

SettingValue
AdapterSQL Caller
WayTwo
Auth ConfigLisDbSql, from Step 2
Command TypeStoredProcedure
Command Textdbo.GetFinalizedResults
Reference the splitter

In the inbound pipeline, reference SqlCallerResultSplitter, from Step 4, and set its configuration RowMessageType = LabResultRow, the classification each emitted row carries.

Select the map

On the inbound Map step, set Map Assignment to Typed and add one row, selecting the bridge map from Step 5:

Source Message TypeMapTarget Message Type
LabResultRowLabResultRowToLabResultLabResult

9
Step Nine
Create the push port
General

Create the API Caller send port ObservationPush, one-way, subscribing on {{Message.MessageType}} == "LabResult".

SettingValue
AdapterAPI Caller
WayOne
AuthenticationFhirBearer, from Step 2
MethodPUT
Urlthe conditional template from the opening section, binding {{Promoted.LabResult.ResultId}}
Select the map

On the outbound Map step, Typed, one row, selecting the Observation map from Step 6, so the resource is shaped at the edge, just before the call:

Source Message TypeMapTarget Message Type
LabResultLabResultToObservationObservation

10
Step Ten
Wire the failure path
Set the Exception Message Type

On both send ports, LabResultPoll and ObservationPush, set Exception Message Type to LabPushFailed, from Step 1. A rejected push re-publishes under that type, and the promotion you defined on it carries the result id into the alert subject.

Create the OpsAlert send port

Add an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "LabPushFailed", result id and OperationOutcome diagnostics in the body, nothing more:

SettingValue
AuthenticationO365OpsMail, from Step 2
Fromnoreply@acme.example
Toops@acme.example
SubjectLab push failed, result {{Promoted.LabPushFailed.ResultId}}

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

There is nothing to deploy, everything you configured is already saved and live, because Art2link applies changes immediately. To bring the flow online you start the send ports first, then the receive port: start ObservationPush, OpsAlert and LabResultPoll, then start LabResultClock. (To take the flow offline, stop the receive port first, for the same reason.)

Turn on tracking

Before you test, 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.

Trigger the tick

Finalize a test result, trigger the tick, and GET the Observation back from the platform by identifier.

Prove the idempotency

Push the same result twice and confirm one resource, updated not duplicated, the conditional PUT at work.

Break it on purpose

Push a result with a junk LOINC code and confirm the 422 surfaces as one LabPushFailed alert with the platform’s diagnostics, while other results keep flowing.

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.