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.
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:
{{Message.MessageType}} == "LabResult"
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.
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.
{{Message.MessageType}} == "LabPushFailed"
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 four types this flow uses. A message type is a name plus a format, no schema required:
| Name | Format | Purpose |
|---|---|---|
| LabResultRow | XML | one result row as it leaves the splitter (Step 4) |
| LabResult | JSON | the canonical result the rest of the flow consumes |
| Observation | JSON | the FHIR R4 resource the platform accepts (Step 6) |
| LabPushFailed | JSON | the 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 | Path |
|---|---|
| ResultId | $.resultId |
A failed push re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:
| Promotion | Path |
|---|---|
| ResultId | $.resultId |
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.
| Setting | Value |
|---|---|
| Name | LisDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the LIS database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | FhirBearer |
| Adapter | API Caller |
| Definition | API Bearer Token |
| Login URL / Refresh Token URL | the FHIR platform’s token endpoint and its refresh endpoint |
| Token / Refresh Token JSON Path | paths into the token response |
| Username / Password | the credentials the platform issued |
The adapter acquires the token, injects the Bearer header, and refreshes it.
| Setting | Value |
|---|---|
| Name | O365OpsMail |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
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.
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;
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).
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:
<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>
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 }); } } }
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 setting | Value |
|---|---|
| Name | LabResultRowToLabResult |
| Source message type | LabResultRow, from Step 1 |
| Target message type | LabResult, from Step 1 |
Every row the splitter emits is one XML <Row>, the map’s source:
<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 map’s target, the shape everything downstream consumes:
{
"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"
}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.
<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>
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 setting | Value |
|---|---|
| Name | LabResultToObservation |
| Source message type | LabResult, from Step 1 |
| Target message type | Observation, from Step 1 |
{
"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" }
}The map reads the canonical JSON and builds the resource, identifier systems, LOINC coding, UCUM units:
<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>
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.)
| Setting | Value |
|---|---|
| Adapter | Scheduler |
| Repeat Every | 10 |
| Repeat Unit | Minutes |
Create the two-way SQL Caller send port LabResultPoll, subscribing on {{Config.PortName}} == "LabResultClock".
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | Two |
| Auth Config | LisDbSql, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.GetFinalizedResults |
In the inbound pipeline, reference SqlCallerResultSplitter, from Step 4, and set its configuration RowMessageType = LabResultRow, the classification each emitted row carries.
On the inbound Map step, set Map Assignment to Typed and add one row, selecting the bridge map from Step 5:
| Source Message Type | Map | Target Message Type |
|---|---|---|
| LabResultRow | LabResultRowToLabResult | LabResult |
Create the API Caller send port ObservationPush, one-way, subscribing on {{Message.MessageType}} == "LabResult".
| Setting | Value |
|---|---|
| Adapter | API Caller |
| Way | One |
| Authentication | FhirBearer, from Step 2 |
| Method | PUT |
| Url | the conditional template from the opening section, binding {{Promoted.LabResult.ResultId}} |
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 Type | Map | Target Message Type |
|---|---|---|
| LabResult | LabResultToObservation | Observation |
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.
Add an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "LabPushFailed", result id and OperationOutcome diagnostics in the body, nothing more:
| Setting | Value |
|---|---|
| Authentication | O365OpsMail, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | Lab push failed, result {{Promoted.LabPushFailed.ResultId}} |
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.)
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.
Finalize a test result, trigger the tick, and GET the Observation back from the platform by identifier.
Push the same result twice and confirm one resource, updated not duplicated, the conditional PUT at work.
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.