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.

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
I had no openEHR repository available. The node identifiers were checked against the published medication order archetype, and the state codes against the openEHR terminology, but neither query has been run. The sample input is a pair of hand-written result sets in the shape the openEHR REST API returns: columns with a name and path each, and rows as arrays. In the sample, actions are already resolved to the order identifier, a join your own pipeline has to make from instruction_details.
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 column | MedicationRequest element | Transform | Notes |
|---|---|---|---|
medication | medicationCodeableConcept | Coded or plain text to CodeableConcept | Required. A row without it is rejected |
authored | authoredOn | Text | |
directions | dosageInstruction[0].text | Text | Structured dose is left for later |
route | dosageInstruction[0].route | Coded or plain text to CodeableConcept | |
start, stop | dosageInstruction[0].timing.repeat.boundsPeriod | Text | |
indication | reasonCode[0].text | Text | |
ehr_id | subject | Lookup in the patient index | No match: the row is rejected |
composition_uid, order_id | identifier, meta.source | Version suffix removed for the identifier | Makes re-runs idempotent |
| Latest ACTION state | status | State table below | No action recorded: unknown |
dosage_justification | None | Reported 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 state | Code | MedicationRequest.status |
|---|---|---|
| initial | 524 | draft |
| planned | 526 | active |
| scheduled | 529 | active |
| active | 245 | active |
| postponed | 527 | on-hold |
| suspended | 530 | on-hold |
| cancelled | 528 | cancelled |
| aborted | 531 | stopped |
| completed | 532 | completed |
| expired | 533 | stopped |
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-10-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-10-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 October and active on 2 October. 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.

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 (10.0.115) 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, the AQL was not executed, and 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:#4C1D95There 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
$sinceparameter 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
DosageandTiming. 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.
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.
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
unknownwhen 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
- → Medication order archetype in CKM — openEHR-EHR-INSTRUCTION.medication_order.v3, the source of the node identifiers used here
- → openEHR support terminology — The instruction state codes
- → openEHR REST API: Query — AQL execution and the result set structure
- → openEHR specifications — Reference model, AQL and archetype formalism
- → FHIR R4: MedicationRequest — Elements and status values
- → FHIR R4: RESTful API — Transactions and conditional update
- → FHIRconnect: Towards a seamless integration of openEHR and FHIR — A declarative mapping language and engine for larger mapping sets
- → FHIR and openEHR: When to Use Which, and How They Coexist — The companion article on architecture
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.