Mapping openEHR Medication Orders to FHIR: AQL In, Transaction Bundle Out

The previous article argued that openEHR and FHIR do different jobs and mapped a single blood pressure reading between them. A blood pressure is the easy case: one entry, two numbers, one time. This article takes a harder one, medication orders, and builds the piece a real platform needs: a batch that reads from an openEHR repository with AQL and writes a FHIR transaction Bundle that can be loaded repeatedly without creating duplicates.

Medication is harder for a structural reason. In FHIR, a MedicationRequest carries its own status. In openEHR, an order is an INSTRUCTION, and what has happened to it since is recorded in separate ACTION entries. There is no status field to copy. The mapping has to derive one.

The mapper below is table-driven, written in Python and in C#, and both versions were run against the same synthetic input. They produce identical Bundles. As before, the most useful output is the report: which rows were rejected, which were degraded, and which columns had nowhere to go.

Diagram of a pipeline from an openEHR repository through a mapping table and Bundle builder to a FHIR server, with a summary of the mapping report for six sample rows: four mapped, two rejected, and several degraded.
The pipeline built in this article, and what its report said about the sample input.

AQL in

Two queries feed the mapper. The first selects orders from the medication order archetype. The node identifiers are the archetype’s own: at0070 is the medication item, at0009 the overall directions, at0091 the route, at0018 the clinical indication, and at0012 and at0013 the order start and stop inside the order details cluster.

SELECT
  e/ehr_id/value                                                          AS ehr_id,
  c/uid/value                                                             AS composition_uid,
  c/context/start_time/value                                              AS authored,
  i/protocol[at0005]/items[at0004]/value/id                               AS order_id,
  i/activities[at0001]/description[at0002]/items[at0070]/value            AS medication,
  i/activities[at0001]/description[at0002]/items[at0009]/value/value      AS directions,
  i/activities[at0001]/description[at0002]/items[at0091]/value            AS route,
  i/activities[at0001]/description[at0002]/items[at0018]/value/value      AS indication,
  i/activities[at0001]/description[at0002]/items[at0113]/items[at0012]/value/value AS start,
  i/activities[at0001]/description[at0002]/items[at0113]/items[at0013]/value/value AS stop,
  i/activities[at0001]/description[at0002]/items[at0174]/value/value      AS dosage_justification
FROM EHR e
  CONTAINS COMPOSITION c
  CONTAINS INSTRUCTION i[openEHR-EHR-INSTRUCTION.medication_order.v3]
WHERE c/context/start_time/value >= $since

The second selects the state transitions recorded against those orders, from the medication ACTION archetype:

SELECT
  a/instruction_details/instruction_id/id/value                  AS instruction,
  a/time/value                                                   AS time,
  a/ism_transition/current_state/defining_code/code_string       AS state
FROM EHR e
  CONTAINS COMPOSITION c
  CONTAINS ACTION a[openEHR-EHR-ACTION.medication.v1]
WHERE a/time/value >= $since
ℹ️
WHAT WAS AND WAS NOT EXECUTED

Updated 12 November 2026. I ran both queries against a local EHRbase 2.39.0 repository. The first parsed and returned no rows, because no template I could obtain includes version 3 of the medication order archetype, so I had no orders to store. Its paths are checked against the published archetype but remain unproven against data. The second, run over five synthetic medication actions, failed as written with HTTP 400: Not implemented: SELECT: path instruction_details/instruction_id/id/value contains STRUCTURE_INTERMEDIATE attribute instruction_details. Without that column it returned the five actions with their times and state codes. Selecting the whole action, SELECT c/uid/value, a, also worked, and on that version it is the way to reach instruction_details: read it from the returned object. My test actions carried no link to an order, so I have not seen that field come back populated. The sample input below is still a pair of hand-written result sets in the shape the openEHR REST API returns, with actions already resolved to the order identifier.

Two details of the result set matter to the mapper. Selecting a path that ends at /value on a coded element returns the whole data value object, including the terminology and code, where /value/value returns only the text. And a row is one order, not one composition: a composition can hold several.

The mapping as a table

The rules are data. Each line names an AQL column, the FHIR element it fills, and the transform between them. A clinical modeller can review this table without reading the engine.

AQL columnMedicationRequest elementTransformNotes
medicationmedicationCodeableConceptCoded or plain text to CodeableConceptRequired. A row without it is rejected
authoredauthoredOnText
directionsdosageInstruction[0].textTextStructured dose is left for later
routedosageInstruction[0].routeCoded or plain text to CodeableConcept
start, stopdosageInstruction[0].timing.repeat.boundsPeriodText
indicationreasonCode[0].textText
ehr_idsubjectLookup in the patient indexNo match: the row is rejected
composition_uid, order_ididentifier, meta.sourceVersion suffix removed for the identifierMakes re-runs idempotent
Latest ACTION statestatusState table belowNo action recorded: unknown
dosage_justificationNoneReported as unmapped

The state table is the part that needs a clinician. openEHR defines ten instruction states. FHIR R4 gives MedicationRequest eight statuses. This is the table the sample uses.

openEHR instruction stateCodeMedicationRequest.status
initial524draft
planned526active
scheduled529active
active245active
postponed527on-hold
suspended530on-hold
cancelled528cancelled
aborted531stopped
completed532completed
expired533stopped
⚠️
THE STATE TABLE IS A CLINICAL DECISION

Whether a planned order should appear to a receiving system as active, or an expired one as stopped, changes what a clinician downstream believes the patient is taking. The table above is illustrative. Have it reviewed and signed off by clinical modellers and your clinical safety officer, and record that decision in the hazard log.

The mapper in Python

"""
aql_to_fhir.py
Maps an openEHR AQL result set of medication orders to a FHIR R4 transaction Bundle of
MedicationRequest resources. The mapping is data: a list of rules a clinical modeller can read.
"""
import json
from dataclasses import dataclass, field
from typing import Any, Callable
from urllib.parse import quote

IDENTIFIER_SYSTEM = "urn:openehr:medication-order"
TERMINOLOGIES = {"SNOMED-CT": "http://snomed.info/sct", "LOINC": "http://loinc.org"}

# openEHR instruction state (ISM) -> MedicationRequest.status. A clinical decision: review before use.
ISM_TO_STATUS = {
    "524": "draft", "526": "active", "529": "active", "245": "active",
    "527": "on-hold", "530": "on-hold", "528": "cancelled", "531": "stopped",
    "532": "completed", "533": "stopped",
}


@dataclass
class Report:
    mapped_rows: int = 0
    rejected: list[str] = field(default_factory=list)       # rows that produced no resource, with the reason
    notes: list[str] = field(default_factory=list)          # resources produced with something degraded
    unmapped_columns: list[str] = field(default_factory=list)


def set_path(resource: dict, path: str, value: Any) -> None:
    """Sets 'a.b[0].c' on a nested dict, creating objects and list slots as needed."""
    node: Any = resource
    parts = path.split(".")
    for i, part in enumerate(parts):
        name, _, index = part.partition("[")
        last = i == len(parts) - 1
        if index:
            slot = int(index.rstrip("]"))
            items = node.setdefault(name, [])
            while len(items) <= slot:
                items.append({})
            if last:
                items[slot] = value
            else:
                node = items[slot]
        elif last:
            node[name] = value
        else:
            node = node.setdefault(name, {})


# ---- transforms: (cell value, order id, report) -> FHIR value, or None to leave the element out
def text(value: Any, order: str, report: Report) -> Any:
    return value or None


def codeable(value: Any, order: str, report: Report) -> Any:
    """DV_TEXT / DV_CODED_TEXT -> CodeableConcept. Unknown terminologies keep the text and are reported."""
    if not value:
        return None
    concept: dict[str, Any] = {"text": value["value"]}
    code = value.get("defining_code")
    if not code:
        report.notes.append(f"{order}: '{value['value']}' is uncoded text")
        return concept
    system = TERMINOLOGIES.get(code["terminology_id"]["value"])
    if system is None:
        report.notes.append(f"{order}: terminology '{code['terminology_id']['value']}' has no FHIR system; code dropped")
        return concept
    concept["coding"] = [{"system": system, "code": code["code_string"], "display": value["value"]}]
    return concept


@dataclass(frozen=True)
class Rule:
    column: str
    fhir_path: str
    transform: Callable[[Any, str, Report], Any] = text
    required: bool = False


MEDICATION_ORDER_RULES = [
    Rule("medication", "medicationCodeableConcept", codeable, required=True),
    Rule("authored", "authoredOn"),
    Rule("directions", "dosageInstruction[0].text"),
    Rule("route", "dosageInstruction[0].route", codeable),
    Rule("start", "dosageInstruction[0].timing.repeat.boundsPeriod.start"),
    Rule("stop", "dosageInstruction[0].timing.repeat.boundsPeriod.end"),
    Rule("indication", "reasonCode[0].text"),
]
STRUCTURAL_COLUMNS = {"ehr_id", "composition_uid", "order_id"}       # used for identity, not clinical content


def latest_states(actions: dict) -> dict[str, str]:
    """The current state of an order is the state of its most recent ACTION, not a field on the order."""
    names = [c["name"] for c in actions["columns"]]
    latest: dict[str, tuple[str, str]] = {}
    for row in actions["rows"]:
        record = dict(zip(names, row))
        if record["order_id"] not in latest or record["time"] > latest[record["order_id"]][0]:
            latest[record["order_id"]] = (record["time"], record["state"])
    return {order: state for order, (_, state) in latest.items()}


def medication_orders_to_bundle(orders: dict, actions: dict, patient_index: dict[str, str]) -> tuple[dict, Report]:
    report, entries = Report(), []
    names = [c["name"] for c in orders["columns"]]
    used = {r.column for r in MEDICATION_ORDER_RULES} | STRUCTURAL_COLUMNS
    report.unmapped_columns = [n for n in names if n not in used]
    states = latest_states(actions)

    for row in orders["rows"]:
        record = dict(zip(names, row))
        order = record.get("order_id") or "(no order id)"
        patient_id = patient_index.get(record["ehr_id"])
        if patient_id is None:
            report.rejected.append(f"{order}: no FHIR Patient for EHR {record['ehr_id']}")
            continue
        missing = [r.column for r in MEDICATION_ORDER_RULES if r.required and not record.get(r.column)]
        if missing or not record.get("order_id"):
            report.rejected.append(f"{order}: missing required {', '.join(missing) or 'order_id'}")
            continue

        state = states.get(order)
        status = ISM_TO_STATUS.get(state or "", "unknown")
        if status == "unknown":
            report.notes.append(f"{order}: no usable state from ACTION entries; status set to unknown")

        # The identifier is stable across composition versions, so a new version replaces the resource.
        versioned_object = record["composition_uid"].rsplit("::", 1)[0]
        identifier = f"{versioned_object}:{order}"
        resource: dict[str, Any] = {
            "resourceType": "MedicationRequest",
            "meta": {"source": f"urn:openehr:composition:{record['composition_uid']}"},
            "identifier": [{"system": IDENTIFIER_SYSTEM, "value": identifier}],
            "status": status,
            "intent": "order",
            "subject": {"reference": f"Patient/{patient_id}"},
        }
        for rule in MEDICATION_ORDER_RULES:
            value = rule.transform(record.get(rule.column), order, report)
            if value is not None:
                set_path(resource, rule.fhir_path, value)
        for column in report.unmapped_columns:
            if record.get(column):
                report.notes.append(f"{order}: column '{column}' has a value and no mapping rule")

        entries.append({
            "resource": resource,
            # Conditional update: running the same batch twice creates nothing new.
            "request": {"method": "PUT",
                        "url": f"MedicationRequest?identifier={quote(IDENTIFIER_SYSTEM, safe='')}%7C{quote(identifier, safe='')}"},
        })
        report.mapped_rows += 1
    return {"resourceType": "Bundle", "type": "transaction", "entry": entries}, report


if __name__ == "__main__":
    bundle, rep = medication_orders_to_bundle(
        json.load(open("medication_orders.json", encoding="utf-8")),
        json.load(open("medication_actions.json", encoding="utf-8")),
        {"ehr-aaa": "pat-001"})
    print(json.dumps(bundle, indent=2, sort_keys=True))

The same mapper in C#

The C# version uses System.Text.Json nodes and nothing else, so it adds no package dependency to a .NET service. The rule table is an array of records, and each transform is a delegate.

using System.Text.Json.Nodes;

namespace Interop;

/// <summary>What happened to every row: mapped, rejected with a reason, or mapped with something degraded.</summary>
public sealed class MappingReport
{
    public int MappedRows { get; set; }
    public List<string> Rejected { get; } = [];
    public List<string> Notes { get; } = [];
    public List<string> UnmappedColumns { get; } = [];
}

public delegate JsonNode? Transform(JsonNode? value, string order, MappingReport report);

/// <summary>One line of the mapping table: an AQL column, the FHIR element it fills, and how.</summary>
public sealed record Rule(string Column, string FhirPath, Transform Apply, bool Required = false);

/// <summary>
/// Maps an openEHR AQL result set of medication orders to a FHIR R4 transaction Bundle of
/// MedicationRequest resources. Uses only System.Text.Json.
/// </summary>
public static class MedicationOrderMapper
{
    public const string IdentifierSystem = "urn:openehr:medication-order";

    private static readonly Dictionary<string, string> Terminologies = new()
    {
        ["SNOMED-CT"] = "http://snomed.info/sct",
        ["LOINC"] = "http://loinc.org",
    };

    // openEHR instruction state (ISM) -> MedicationRequest.status. A clinical decision: review before use.
    private static readonly Dictionary<string, string> IsmToStatus = new()
    {
        ["524"] = "draft", ["526"] = "active", ["529"] = "active", ["245"] = "active",
        ["527"] = "on-hold", ["530"] = "on-hold", ["528"] = "cancelled", ["531"] = "stopped",
        ["532"] = "completed", ["533"] = "stopped",
    };

    private static readonly Rule[] Rules =
    [
        new("medication", "medicationCodeableConcept", Codeable, Required: true),
        new("authored", "authoredOn", Text),
        new("directions", "dosageInstruction[0].text", Text),
        new("route", "dosageInstruction[0].route", Codeable),
        new("start", "dosageInstruction[0].timing.repeat.boundsPeriod.start", Text),
        new("stop", "dosageInstruction[0].timing.repeat.boundsPeriod.end", Text),
        new("indication", "reasonCode[0].text", Text),
    ];

    private static readonly HashSet<string> StructuralColumns = ["ehr_id", "composition_uid", "order_id"];

    public static (JsonObject Bundle, MappingReport Report) ToBundle(
        JsonNode orders, JsonNode actions, IReadOnlyDictionary<string, string> patientIndex)
    {
        MappingReport report = new();
        JsonArray entries = [];
        string[] names = ColumnNames(orders);
        report.UnmappedColumns.AddRange(
            names.Where(n => !StructuralColumns.Contains(n) && Rules.All(r => r.Column != n)));
        Dictionary<string, string> states = LatestStates(actions);

        foreach (JsonNode? row in orders["rows"]!.AsArray())
        {
            Dictionary<string, JsonNode?> record = names.Zip(row!.AsArray()).ToDictionary(p => p.First, p => p.Second);
            string? orderId = (string?)record["order_id"];
            string order = orderId ?? "(no order id)";
            string ehrId = (string)record["ehr_id"]!;

            if (!patientIndex.TryGetValue(ehrId, out string? patientId))
            {
                report.Rejected.Add($"{order}: no FHIR Patient for EHR {ehrId}");
                continue;
            }

            string[] missing = [.. Rules.Where(r => r.Required && record[r.Column] is null).Select(r => r.Column)];
            if (missing.Length > 0 || orderId is null)
            {
                report.Rejected.Add($"{order}: missing required {(missing.Length > 0 ? string.Join(", ", missing) : "order_id")}");
                continue;
            }

            string status = states.TryGetValue(order, out string? state) && IsmToStatus.TryGetValue(state, out string? mapped)
                ? mapped
                : "unknown";
            if (status == "unknown")
            {
                report.Notes.Add($"{order}: no usable state from ACTION entries; status set to unknown");
            }

            // The identifier is stable across composition versions, so a new version replaces the resource.
            string compositionUid = (string)record["composition_uid"]!;
            string versionedObject = compositionUid[..compositionUid.LastIndexOf("::", StringComparison.Ordinal)];
            string identifier = $"{versionedObject}:{order}";

            JsonObject resource = new()
            {
                ["resourceType"] = "MedicationRequest",
                ["meta"] = new JsonObject { ["source"] = $"urn:openehr:composition:{compositionUid}" },
                ["identifier"] = new JsonArray(new JsonObject { ["system"] = IdentifierSystem, ["value"] = identifier }),
                ["status"] = status,
                ["intent"] = "order",
                ["subject"] = new JsonObject { ["reference"] = $"Patient/{patientId}" },
            };

            foreach (Rule rule in Rules)
            {
                JsonNode? value = rule.Apply(record[rule.Column], order, report);
                if (value is not null)
                {
                    SetPath(resource, rule.FhirPath, value);
                }
            }

            foreach (string column in report.UnmappedColumns.Where(c => record[c] is not null))
            {
                report.Notes.Add($"{order}: column '{column}' has a value and no mapping rule");
            }

            entries.Add(new JsonObject
            {
                ["resource"] = resource,
                // Conditional update: running the same batch twice creates nothing new.
                ["request"] = new JsonObject
                {
                    ["method"] = "PUT",
                    ["url"] = $"MedicationRequest?identifier={Uri.EscapeDataString(IdentifierSystem)}%7C{Uri.EscapeDataString(identifier)}",
                },
            });
            report.MappedRows++;
        }

        return (new JsonObject { ["resourceType"] = "Bundle", ["type"] = "transaction", ["entry"] = entries }, report);
    }

    /// <summary>The current state of an order is the state of its most recent ACTION, not a field on the order.</summary>
    public static Dictionary<string, string> LatestStates(JsonNode actions)
    {
        string[] names = ColumnNames(actions);
        int order = Array.IndexOf(names, "order_id"), time = Array.IndexOf(names, "time"), state = Array.IndexOf(names, "state");
        return actions["rows"]!.AsArray()
            .Select(r => r!.AsArray())
            .GroupBy(r => (string)r[order]!)
            .ToDictionary(
                g => g.Key,
                g => (string)g.OrderBy(r => (string)r[time]!, StringComparer.Ordinal).Last()[state]!);
    }

    /// <summary>Sets "a.b[0].c" on a nested object, creating objects and array slots as needed.</summary>
    public static void SetPath(JsonObject resource, string path, JsonNode value)
    {
        JsonObject node = resource;
        string[] parts = path.Split('.');
        for (int i = 0; i < parts.Length; i++)
        {
            bool last = i == parts.Length - 1;
            int bracket = parts[i].IndexOf('[');
            if (bracket < 0)
            {
                if (last)
                {
                    node[parts[i]] = value;
                }
                else
                {
                    node = (JsonObject)(node[parts[i]] ??= new JsonObject());
                }

                continue;
            }

            string name = parts[i][..bracket];
            int slot = int.Parse(parts[i][(bracket + 1)..^1]);
            JsonArray items = (JsonArray)(node[name] ??= new JsonArray());
            while (items.Count <= slot)
            {
                items.Add(new JsonObject());
            }

            if (last)
            {
                items[slot] = value;
            }
            else
            {
                node = (JsonObject)items[slot]!;
            }
        }
    }

    private static string[] ColumnNames(JsonNode resultSet) =>
        [.. resultSet["columns"]!.AsArray().Select(c => (string)c!["name"]!)];

    private static JsonNode? Text(JsonNode? value, string order, MappingReport report) =>
        value is null ? null : JsonValue.Create((string)value!);

    /// <summary>DV_TEXT / DV_CODED_TEXT -> CodeableConcept. Unknown terminologies keep the text and are reported.</summary>
    private static JsonNode? Codeable(JsonNode? value, string order, MappingReport report)
    {
        if (value is null)
        {
            return null;
        }

        string display = (string)value["value"]!;
        JsonObject concept = new() { ["text"] = display };
        JsonNode? code = value["defining_code"];
        if (code is null)
        {
            report.Notes.Add($"{order}: '{display}' is uncoded text");
            return concept;
        }

        string terminology = (string)code["terminology_id"]!["value"]!;
        if (!Terminologies.TryGetValue(terminology, out string? system))
        {
            report.Notes.Add($"{order}: terminology '{terminology}' has no FHIR system; code dropped");
            return concept;
        }

        concept["coding"] = new JsonArray(new JsonObject
        {
            ["system"] = system, ["code"] = (string)code["code_string"]!, ["display"] = display,
        });
        return concept;
    }
}

FHIR Bundle out

One entry from the output. The request line is a conditional update: the server updates the resource carrying that identifier if one exists and creates it otherwise.

{
  "request": {
    "method": "PUT",
    "url": "MedicationRequest?identifier=urn%3Aopenehr%3Amedication-order%7C11111111-0000-4000-8000-000000000001%3A%3Asynthetic.example%3AORD-1001"
  },
  "resource": {
    "resourceType": "MedicationRequest",
    "meta": {"source": "urn:openehr:composition:11111111-0000-4000-8000-000000000001::synthetic.example::2"},
    "identifier": [{"system": "urn:openehr:medication-order",
                    "value": "11111111-0000-4000-8000-000000000001::synthetic.example:ORD-1001"}],
    "status": "active",
    "intent": "order",
    "subject": {"reference": "Patient/pat-001"},
    "medicationCodeableConcept": {"text": "Metformin 500 mg tablets"},
    "authoredOn": "2026-07-01T09:00:00Z",
    "dosageInstruction": [{
      "text": "One tablet twice daily with food",
      "route": {"text": "Oral route",
                "coding": [{"system": "http://snomed.info/sct", "code": "26643006", "display": "Oral route"}]},
      "timing": {"repeat": {"boundsPeriod": {"start": "2026-07-01T00:00:00Z"}}}
    }],
    "reasonCode": [{"text": "Type 2 diabetes"}]
  }
}

Three design choices are visible in that entry.

  • The identifier leaves out the composition version. The source composition is at version 2. The identifier uses the part before the version suffix, so when version 3 is committed the next run replaces this resource. The exact version it was derived from is kept in meta.source.
  • The medication has text and no coding. The source carried a code from a local formulary. Exporting a code under a system URI the receiver cannot resolve is worse than exporting none, so the mapper keeps the display text, drops the code and says so in the report.
  • The status came from somewhere else. Order ORD-1001 had two actions: planned on 1 July and active on 2 July. The mapper took the later one.

What the report said

Both implementations printed the same report for the six sample rows:

mapped rows      -> 4
rejected         -> ORD-2001: no FHIR Patient for EHR ehr-zzz; ORD-1005: missing required medication
unmapped columns -> dosage_justification
note             -> ORD-1001: terminology 'local-formulary' has no FHIR system; code dropped
note             -> ORD-1001: column 'dosage_justification' has a value and no mapping rule
note             -> ORD-1002: 'Amoxicillin 500 mg capsules' is uncoded text
note             -> ORD-1002: 'by mouth' is uncoded text
note             -> ORD-1003: 'Gliclazide 80 mg tablets' is uncoded text
note             -> ORD-1004: no usable state from ACTION entries; status set to unknown
note             -> ORD-1004: 'Atorvastatin 20 mg tablets' is uncoded text

Read as an operations dashboard, that report says more about the source data than about the mapper: most medication in this sample is free text, one EHR has no patient link, and one order has no recorded action. Each of those is a data quality finding that somebody upstream can fix. A mapper that silently produced four tidy resources would have hidden all of them.

Table of seven mismatches between an openEHR medication order and a FHIR MedicationRequest, such as state held in ACTION entries versus a status field, and the decision the mapping must make for each.
The mismatches behind each line of the report.
💡
HOW THIS WAS CHECKED

The Python mapper and its tests ran on Python 3.13. The C# mapper compiled with warnings treated as errors and ran on the .NET 10 SDK with the same assertions. The Bundle written by the C# program was then compared with the Python Bundle and the two were identical. Both listings above are the tested files in full. The input is synthetic and hand-written, not the output of a repository: the AQL was only partly executed, as described under the queries. The Bundle was not posted to a FHIR server.

How a row is decided

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    R[Row from the AQL result set] --> P{EHR id in the
patient index?} P -- "No" --> X1[Reject: no FHIR Patient] P -- "Yes" --> M{Required columns
present?} M -- "No" --> X2[Reject: name what is missing] M -- "Yes" --> S{Latest ACTION state
found and mapped?} S -- "No" --> U[status = unknown
add a note] S -- "Yes" --> T[status from the state table] U --> C[Apply the rule table] T --> C C --> D{Any code without
a FHIR system?} D -- "Yes" --> N[Keep text, drop code
add a note] D -- "No" --> E[Bundle entry with
conditional update] N --> E class R,P,M,S,D edge class X1,X2 alert class U,N mesh class T,C compute class E store classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E classDef edge fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74 classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202 classDef alert fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B classDef mesh fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95

There are three outcomes and the distinction matters. A rejected row produces no resource, because a medication order attached to the wrong patient or naming no medication is worse than a missing one. A degraded row produces a resource that is true but poorer than its source. A clean row needs no comment. The receiving system sees only the Bundle, so the report is the only place the first two are visible.

Loading the Bundle on Azure

%%{init: {'theme':'base','themeVariables':{'actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E','labelBoxBkgColor':'#FFF3E0','labelBoxBorderColor':'#FF9900','labelTextColor':'#232F3E','loopTextColor':'#232F3E','activationBkgColor':'#EBF8FF','activationBorderColor':'#0073BB','lineColor':'#232F3E','textColor':'#232F3E'}}}%%
sequenceDiagram
    participant J as Mapping job
    participant O as openEHR repository
    participant I as Patient index
    participant F as FHIR service
    J->>O: AQL: orders and actions since last run
    O-->>J: Two result sets
    J->>I: Resolve EHR ids to Patient ids
    I-->>J: Matches, and the ones with none
    J->>F: POST transaction Bundle
    F-->>J: 200 with one outcome per entry
    Note over J,F: Same Bundle sent again:
updates in place, no new resources
  • Run it as a scheduled or event-driven job. A container job or a function fits. It needs read access to the openEHR repository and write access to the FHIR service in Azure Health Data Services, both through a managed identity.
  • Keep a watermark. The $since parameter is the time of the last successful run. Advance it only after the transaction succeeds, and overlap runs slightly. The conditional update makes the overlap harmless.
  • Chunk the Bundle. FHIR servers cap the number of entries in a transaction. Split by patient so that one patient’s orders succeed or fail together.
  • Check conditional update behaviour on your server. A conditional update that matches more than one resource fails with a precondition error by specification. That is the correct outcome: it means the identifier scheme has been broken somewhere and must not be papered over.
  • Send the report somewhere people look. Counts to metrics, lines to a log workspace, and an alert when rejections exceed a threshold or a new unmapped column appears.

Running it in production

  • Deletions and corrections. A query for changed orders does not return deleted compositions. Subscribe to the repository’s change events, or reconcile periodically, and decide what a deleted order becomes in FHIR: usually entered-in-error, not a physical delete.
  • Structure the dose in stages. The sample exports directions as text. Structured dose and timing live in separate cluster archetypes and map to FHIR Dosage and Timing. Add them one tested pattern at a time, and keep the text alongside.
  • Terminology through a service. The two-entry dictionary in the sample stands in for a terminology service and concept maps. Local formulary codes should be translated to a published medication terminology there, not dropped forever.
  • Pin to template versions. AQL paths are stable within an archetype version. A template change that swaps an archetype version can make a column come back empty without any error. Monitor the share of null values per column.
  • Test with paired fixtures. For each mapping, keep a result set and the Bundle it must produce. Run both language implementations against the same fixtures if you maintain both, as this article does.
  • Use a mapping engine when the table grows. Past a handful of archetypes, move the rules into a declarative mapping language and an engine built for it. The reporting discipline carries over unchanged.
⚖️
A DERIVED MEDICATION LIST IS STILL A MEDICATION RECORD

The Bundle contains special-category data and the report contains order identifiers, so both need the access control, residency and retention you apply to the source. Under GDPR, rectification and erasure at source must reach the FHIR copy, which is another reason to handle deletions explicitly. Keep an audit record of each run: the watermark, the counts and the mapping version. When a clinician asks why the FHIR view showed an order as stopped, that record is the answer.

⚠️
MEDICATION MAPPING ERRORS REACH PATIENTS

A wrong status, a dropped stop date or an order attached to the wrong patient can lead to a missed dose or a duplicated one. Nothing in this article has been clinically validated. Before any use that can influence care, the mapping needs clinical review, a hazard log entry with its controls, and test evidence, under your clinical safety process.

Key takeaways

  • A medication order’s status is not a field in openEHR. Derive it from the latest ACTION, and say unknown when there is none.
  • Write the mapping as a table that a clinical modeller can review, with the engine separate from the rules.
  • Make loads idempotent with conditional updates on an identifier that ignores the composition version.
  • Reject rows that would be dangerous, degrade rows that would be incomplete, and report both.
  • Never export a code under a terminology the receiver cannot resolve. Keep the text and fix the terminology upstream.

If you run an openEHR to FHIR pipeline for medication, I would like to hear how you mapped the instruction states and who signed it off. Leave a comment below.

Glossary

AQL
Archetype Query Language. Queries openEHR data by archetype path.
Result set
The tabular response to an AQL query: column definitions and rows.
INSTRUCTION
The openEHR entry type for an order or request, such as a medication order.
ACTION
The openEHR entry type recording something done in response to an instruction, with a state transition.
Instruction state machine (ISM)
The standard openEHR states an instruction moves through, such as planned, active, suspended and completed.
MedicationRequest
The FHIR resource for an order or request for medication.
Transaction Bundle
A FHIR Bundle whose entries are processed as one unit: all succeed or none do.
Conditional update
A FHIR update addressed by search criteria. It updates the match, or creates the resource if there is none.
Idempotent
Producing the same end state however many times the operation runs.
CodeableConcept
The FHIR data type for a concept with optional codings and text.
Versioned object
In openEHR, the container of all versions of one composition. Its identifier is the composition uid without the version suffix.
Patient index
The service that links an openEHR EHR identifier to a FHIR Patient.
Watermark
The stored point in time from which the next incremental run reads.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.