The European Health Data Space (EHDS) regulation requires EHR systems operating in the EU to exchange health data using FHIR. This is driving adoption of HL7 Europe's FHIR Implementation Guides across member states.
I'd just finished building a Maternity HL7-to-FHIR Pipeline targeting the Australian market - AU Base profiles, IHI identifiers, ICD-10-AM diagnosis codes. It worked. It had a solid test suite with good coverage. It was a complete portfolio piece.
Then I started asking myself: could this same pipeline work for a European hospital?
The HL7 v2 messages coming from the hospital are identical - an ADT^A01 admission in Melbourne looks the same as one in Amsterdam. The clinical data is the same - blood pressure is blood pressure, a pregnancy diagnosis is a pregnancy diagnosis. What changes is the FHIR metadata layer: which profiles to declare, which identifier systems to use, which terminology editions to bind.
If the architecture was good, this should be a configuration change - not a rewrite. If it wasn't, well, that would be a good lesson too.
GitHub: budityw23/maternity-hl7-to-fhir-pipeline
What's Actually Different Between AU and EU FHIR?
Before touching any code, I needed to understand what "European FHIR" means. The EU ecosystem is more complex than Australia's, and more interesting.
The Profiling Layer
Australia has a relatively flat structure: AU Base profiles sit directly on top of base FHIR R4. You declare http://hl7.org.au/fhir/StructureDefinition/au-patient on your Patient resource and you're done.
Europe has a layered architecture. HL7 Europe publishes three foundational Implementation Guides:
- HL7 Europe Base - flexible foundation profiles
- HL7 Europe Core - essential constraints on top of Base
- HL7 Europe Extensions - EU-specific extensions (nationality, citizenship, etc.)
On top of that sit scoped IGs - domain-specific guides like the European Patient Summary. And below all of that, individual countries can add their own national constraints (NHS England, Nictiz Netherlands, KBV Germany).
HL7 EU Extensions
↓
HL7 EU Base Profiles (flexible foundation)
↓
HL7 EU Core Profiles (essential constraints)
↓
Scoped HL7 EU IGs (e.g. European Patient Summary)
↓
National IGs (e.g. NHS England, Nictiz NL, KBV DE)
This means the EU pipeline needs to operate at the EU Base/Core level - general enough to work across countries, specific enough to satisfy EHDS requirements.
Identifiers
In Australia, every patient has an IHI (Individual Healthcare Identifier) - a single national identifier system with one URI: http://ns.electronichealth.net.au/id/hi/ihi/1.0.
Europe doesn't have a single identifier. Each country has its own:
| Country | Identifier | System URI |
|---|---|---|
| Australia | IHI | http://ns.electronichealth.net.au/id/hi/ihi/1.0 |
| UK | NHS Number | https://fhir.nhs.uk/Id/nhs-number |
| Netherlands | BSN | http://fhir.nl/fhir/NamingSystem/bsn |
| Germany | KVNR | http://fhir.de/sid/gkv/kvid-10 |
| Ireland | PPS Number | https://fhir.ie/sid/ppsn |
(These are the exact system URIs used in fastapi/app/profiles/eu_profile.py.)
The pipeline needs a configurable national identifier, not a hardcoded one.
Terminology
Australian maternity care uses ICD-10-AM (Australian Modification) for diagnosis coding. The system URI is http://hl7.org.au/fhir/CodeSystem/icd-10-am.
European systems use standard ICD-10 (WHO edition) with system URI http://hl7.org/fhir/sid/icd-10. Some countries use their own modifications (Germany has ICD-10-GM), but at the EU Base level, the WHO edition is the common ground.
SNOMED CT is used in both regions, but Australia binds to the AU refset while Europe uses the International Edition. The URI is the same (http://snomed.info/sct) - the difference is in which value sets and reference sets are expected.
LOINC and UCUM are universal. Blood pressure is still 85354-9. Weight is still 29463-7. Millimeters of mercury are still mm[Hg]. Some things, thankfully, don't need localization.
International Patient Summary
The IPS (International Patient Summary, ISO 27269) is the cross-border health document that EHDS uses for data exchange. When a Portuguese patient walks into a German emergency room, the IPS tells the German doctor what they need to know: allergies, medications, active conditions, recent vital signs, pregnancy status.
My pipeline already produces Patient, Condition, Observation, and Encounter resources. Wrapping those into an IPS Composition document is a natural extension.
GDPR
Australian healthcare has privacy regulations, but nothing that requires FHIR-level consent tracking. In the EU, GDPR Articles 6 and 9 create specific legal bases for processing health data. Modeling this as a FHIR Consent resource (recording which GDPR article authorizes the processing) isn't required by any FHIR IG, but European employers care about it.
My First Instinct (And Why It Was Wrong)
My initial approach was the obvious one: fork the repo, create a separate maternity-hl7-to-fhir-pipeline-eu project, and replace all the AU-specific values with EU ones.
Find and replace. au-patient → Patient-eu. IHI → NHS Number. ICD-10-AM → ICD-10. Ship it.
This would have worked for a demo. But it's the wrong architecture for several reasons:
Duplicated transformation logic. The mapping from PID-5.1 to Patient.name[0].family doesn't change between Australia and Europe. The blood pressure panel merging logic doesn't change. The encounter class mapping doesn't change. Forking the repo means maintaining two copies of all this logic - and if I fix a bug in the AU version, I have to remember to fix it in the EU version too.
No path to a third region. What happens when I want to add US Core profiles? Another fork? Now I have three repos with three copies of the same BP merging code. This is the kind of technical debt that turns portfolio projects into maintenance nightmares.
It hides the interesting design work. A fork says "I copied code and changed some strings." A configurable multi-profile architecture says "I designed a system that handles jurisdiction differences at the configuration layer, not the logic layer." One of these is impressive in an interview. The other isn't.
The right approach was to make the existing pipeline support multiple profiles - and to do it in a way that the transformation logic doesn't know or care which region it's operating in.
The Profile Configuration Pattern
The core design change was extracting all region-specific values into a profile configuration module:
fastapi/app/profiles/
├── __init__.py
├── base.py # ProfileConfig dataclass
├── au_profile.py # AU_PROFILE instance
├── eu_profile.py # build_eu_profile(country) factory
└── registry.py # PROFILE_REGION → config resolver
ProfileConfig is a frozen dataclass that captures everything that varies by jurisdiction:
# fastapi/app/profiles/base.py
@dataclass(frozen=True)
class ProfileConfig:
region: str
patient_profile_url: str
condition_profile_url: str
encounter_profile_url: str
bp_observation_profile_url: str
observation_profile_url: str
national_id_system: str
national_id_display: str
diagnosis_code_system: str
snomed_system: str
timezone_offset: str
default_country: str
profile_definitions: list[dict[str, str]] = field(default_factory=list)
The AU configuration is a single frozen instance:
# fastapi/app/profiles/au_profile.py
AU_PROFILE = ProfileConfig(
region="au",
patient_profile_url="http://hl7.org.au/fhir/StructureDefinition/au-patient",
condition_profile_url="http://hl7.org.au/fhir/StructureDefinition/au-condition",
encounter_profile_url="http://hl7.org.au/fhir/StructureDefinition/au-encounter",
bp_observation_profile_url="http://hl7.org.au/fhir/StructureDefinition/au-vitalsigns-bloodpressure",
observation_profile_url="http://hl7.org/fhir/StructureDefinition/vitalsigns",
national_id_system="http://ns.electronichealth.net.au/id/hi/ihi/1.0",
national_id_display="IHI",
diagnosis_code_system="http://hl7.org.au/fhir/CodeSystem/icd-10-am",
snomed_system="http://snomed.info/sct",
timezone_offset="+10:00",
default_country="AU",
profile_definitions=[
{"id": "au-patient", "url": "http://hl7.org.au/fhir/StructureDefinition/au-patient",
"name": "AUPatient", "type": "Patient"},
{"id": "au-condition", "url": "http://hl7.org.au/fhir/StructureDefinition/au-condition",
"name": "AUCondition", "type": "Condition"},
# ... plus au-encounter, au-vitalsigns-bloodpressure
],
)
The EU side is a factory rather than a static instance, because the national identifier
depends on the country. PROFILE_COUNTRY selects it:
# fastapi/app/profiles/eu_profile.py
EU_NATIONAL_ID_SYSTEMS = {
"uk": ("https://fhir.nhs.uk/Id/nhs-number", "NHS Number"),
"nl": ("http://fhir.nl/fhir/NamingSystem/bsn", "BSN"),
"de": ("http://fhir.de/sid/gkv/kvid-10", "KVNR"),
"ie": ("https://fhir.ie/sid/ppsn", "PPS Number"),
}
EU_DEFAULT_ID_SYSTEM = "http://hl7.eu/fhir/base/NamingSystem/national-id"
EU_DEFAULT_ID_DISPLAY = "National ID"
def build_eu_profile(country: str = "") -> ProfileConfig:
country_lower = country.lower().strip()
if country_lower in EU_NATIONAL_ID_SYSTEMS:
national_id_system, national_id_display = EU_NATIONAL_ID_SYSTEMS[country_lower]
else:
national_id_system = EU_DEFAULT_ID_SYSTEM
national_id_display = EU_DEFAULT_ID_DISPLAY
return ProfileConfig(
region="eu",
patient_profile_url="http://hl7.eu/fhir/base/StructureDefinition/patient-eu",
condition_profile_url="http://hl7.eu/fhir/base/StructureDefinition/condition-eu-core",
encounter_profile_url="http://hl7.org/fhir/StructureDefinition/Encounter", # base FHIR
bp_observation_profile_url="http://hl7.org/fhir/StructureDefinition/bp", # FHIR core BP
observation_profile_url="http://hl7.org/fhir/StructureDefinition/vitalsigns",
national_id_system=national_id_system,
national_id_display=national_id_display,
diagnosis_code_system="http://hl7.org/fhir/sid/icd-10",
snomed_system="http://snomed.info/sct",
timezone_offset="+01:00",
default_country=country_lower.upper() if country_lower else "EU",
profile_definitions=[
{"id": "patient-eu", "url": "http://hl7.eu/fhir/base/StructureDefinition/patient-eu",
"name": "PatientEU", "type": "Patient"},
{"id": "condition-eu-core", "url": "http://hl7.eu/fhir/base/StructureDefinition/condition-eu-core",
"name": "ConditionEUCore", "type": "Condition"},
# ... plus FHIR core BP
],
)
Two environment variables control which profile is active:
# AU mode (default - backward compatible)
docker compose up --build
# EU mode, UK national identifier (NHS Number)
PROFILE_REGION=eu PROFILE_COUNTRY=uk docker compose up --build
The registry resolves the active config from those settings:
# fastapi/app/profiles/registry.py
def get_profile() -> ProfileConfig:
if settings.profile_region.lower().strip() == "eu":
return build_eu_profile(settings.profile_country)
return AU_PROFILE
How the Transformers Changed
The transformers don't contain if/else logic for regions. Each route resolves the active ProfileConfig once via get_profile() (driven by the PROFILE_REGION / PROFILE_COUNTRY settings) and passes it straight into the transformer as an argument:
# fastapi/app/main.py — inside the /fhir/Patient handler
profile = get_profile()
patient = build_patient(payload, profile)
The transformer signature takes that config and uses it for every jurisdiction-specific value:
# fastapi/app/transformers/patient.py
def build_patient(payload: AdtPayload, profile: ProfileConfig) -> Patient:
...
# Before (hardcoded AU):
# meta={"profile": ["http://hl7.org.au/fhir/StructureDefinition/au-patient"]}
# After (profile-configured):
patient = Patient(
meta={"profile": [profile.patient_profile_url]},
...
identifier=[..., Identifier(system=profile.national_id_system, value=payload.ihi)],
address=[Address(..., country=payload.address.country or profile.default_country)],
)
The transformation logic (mapping PID fields to Patient attributes, merging BP panels, resolving patient references by MRN) stays unchanged. It doesn't know whether it's producing an AU Patient or an EU Patient. It builds a valid FHIR Patient resource and applies whatever metadata the profile config provides. (It's plain function-argument passing, not FastAPI's Depends() injection — the profile is process-wide config, resolved once per request from the environment.)
Adding a third region is one new file. A US Core profile would need us_profile.py, one new entry in the registry, and zero changes to the transformers.
Same Message, Different FHIR
To see the impact, compare the same ADT^A01 admission message processed in both modes.
The HL7 v2 message is identical. A pregnant woman is admitted for a routine antenatal visit. The message carries her name, date of birth, identifier, and a pregnancy diagnosis.
AU mode produces (real transformer output, trimmed):
{
"resourceType": "Patient",
"meta": {
"profile": ["http://hl7.org.au/fhir/StructureDefinition/au-patient"]
},
"identifier": [
{
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "MR"}]},
"system": "http://hospital.local/mrn",
"value": "1234567"
},
{
"system": "http://ns.electronichealth.net.au/id/hi/ihi/1.0",
"value": "8003608166690503"
}
],
"name": [{"use": "official", "family": "TEST", "given": ["PATIENT", "MARY"]}],
"gender": "female",
"birthDate": "1992-03-15"
}
EU mode (PROFILE_REGION=eu PROFILE_COUNTRY=uk) produces:
{
"resourceType": "Patient",
"meta": {
"profile": ["http://hl7.eu/fhir/base/StructureDefinition/patient-eu"]
},
"identifier": [
{
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "MR"}]},
"system": "http://hospital.local/mrn",
"value": "1234567"
},
{
"system": "https://fhir.nhs.uk/Id/nhs-number",
"value": "9000000009"
}
],
"name": [{"use": "official", "family": "TEST", "given": ["PATIENT", "MARY"]}],
"gender": "female",
"birthDate": "1992-03-15"
}
Same patient, same clinical data, different regulatory metadata. The profile URL and the national identifier system changed (au-patient → patient-eu, IHI → NHS Number). Name,gender, birthDate, MRN (including its MR type coding) are identical.
The Condition resource follows the same pattern:
AU: "system": "http://hl7.org.au/fhir/CodeSystem/icd-10-am", "code": "O80"
EU: "system": "http://hl7.org/fhir/sid/icd-10", "code": "O80"
Same ICD code, different system URI. The clinical meaning is identical; only the regulatory packaging shifts.
The IPS Endpoint
A pregnant woman from the Netherlands visits a hospital in Ireland. The Irish clinician needs her active conditions, recent vital signs, medications, and allergies, right now, in a format they can read. The IPS is that format, and it's what makes this pipeline relevant to EHDS.
The pipeline already had all the data. After processing ADT^A01 (Patient + Condition), ORM^O01 (Encounter), and ORU^R01 (Observations), HAPI FHIR contains a complete clinical picture for that patient. The IPS endpoint assembles those existing resources into a single FHIR Composition document.
POST /fhir/IPS
{
"correlationId": "ips-001",
"mrn": "1234567"
}
The endpoint resolves the Patient by MRN (422 if the patient hasn't been admitted yet), queries HAPI FHIR for that patient's resources, then builds an IPS Composition (LOINC 60591-5, "Patient summary Document") with six sections:
IPS Composition (LOINC 60591-5) → wrapped in a Document Bundle
├── Allergies and Intolerances (48765-2) → emptyReason "notasked"
├── Medications (10160-0) → emptyReason "notasked"
├── Problems (11450-4) → non-pregnancy Conditions
├── Results (30954-2) → non-vital-sign Observations
├── Vital Signs (8716-3) → vital-sign Observations (BP panel, weight, ...)
└── Pregnancy History (10162-6) → pregnancy-related Conditions (by SNOMED/ICD-10)
The whole thing is wrapped in a FHIR Document Bundle (Bundle-uv-ips profile), so the result is a single, self-contained document any EHDS-compliant system can consume.
The Allergies and Medications sections use emptyReason (notasked) because the pipeline doesn't ingest those HL7 message types yet. Any section that ends up with no entries gets the same treatment. The IPS spec requires these sections but supports the "no information available"
pattern — an empty section with an emptyReason is more credible than fake data.
GDPR Consent
European healthcare data processing requires a legal basis under GDPR. For health data specifically, Article 9(2)(h) permits processing forhealthcare purposes. The pipeline models
this as a FHIR Consent resource:
POST /fhir/Consent
{
"correlationId": "consent-001",
"mrn": "1234567",
"policyRule": "gdpr-art-9-2-h",
"provisionType": "permit"
}
The policyRule field carries the GDPR basis; the pipeline recognises gdpr-art-6-1-a (explicit consent, the default), gdpr-art-9-2-h (health-data processing), and gdpr-art-6-1-e (public interest), mapping each to a human-readable display in the Consent's policyRule.coding.
provisionType is permit or deny, and optional periodStart / periodEnd set the validity window. On success it returns 200 with {"consentId": "...", "correlationId": "..."}.
This endpoint is EU-only: it returns 404 when PROFILE_REGION=au, because GDPR doesn't apply in Australia. The profile configuration controls not just metadata values but also which endpoints are available.
No FHIR IG requires this. But EU health IT companies want engineers who understand why health data processing needs a legal basis and how consent flows work, not just engineers who can write valid FHIR resources.
Testing Across Profiles
The existing AU tests must not break. The EU extension adds capability without changing existing behavior.
The CI pipeline now runs tests in a matrix:
# .github/workflows/ci.yml
strategy:
matrix:
profile_region: [au, eu]
env:
PROFILE_REGION: ${{ matrix.profile_region }}
PROFILE_COUNTRY: ${{ matrix.profile_region == 'eu' && 'uk' || '' }}
Every test runs twice - once with PROFILE_REGION=au and once with PROFILE_REGION=eu (PROFILE_COUNTRY=uk). The AU test suite is unchanged. The EU tests add EU-specific assertions:
- EU Patient resources carry the
patient-euprofile URL, AU ones carryau-patient - EU Conditions use ICD-10 (WHO), AU Conditions use ICD-10-AM
- EU identifiers use the configured national system (NHS Number for
uk), not IHI - The IPS endpoint produces a valid IPS Document Bundle
- The Consent endpoint returns
404in AU mode and200in EU mode
The suite is now 277 tests at 92% line coverage (unit + integration + a tests/e2e/ suite
that drives real HL7 over MLLP against a live Docker stack and auto-skips when it isn't running).
Adding EU capability did not change a single existing AU assertion.
EU Sample Messages
The samples/ directory now includes EU-specific HL7 messages:
samples/
├── adt_a01_normal_delivery.hl7 # AU sample
├── adt_a01_escaped_name.hl7 # AU sample with HL7 escape sequences
├── orm_o01_antenatal_28w.hl7 # AU sample
├── oru_r01_vitals.hl7 # AU sample
├── invalid/
│ └── adt_missing_mrn.hl7
└── eu/
├── adt_a01_normal_delivery.hl7 # EU sample (NHS Number, ICD-10)
├── orm_o01_antenatal_28w.hl7 # EU sample
└── oru_r01_vitals.hl7 # EU sample
The EU samples use NHS Number identifiers (NHS...^^^NHS^NH), ICD-10 (WHO) diagnosis codes, and UK-style facility naming (e.g. ST_THOMAS, LONDON_TRUST). The HL7 v2 message structure is identical to the AU samples — the same segments, the same field positions. Only the content in the identifier and diagnosis fields differs.
What I Learned
Building the first version taught me about HL7, FHIR, and healthcare integration. Extending it to Europe taught me different things.
The clinical domain is more universal than the regulatory layer. Blood pressure merging, patient admission flows, observation linking: none of this changes between Australia and Europe. The HL7-to-FHIR mapping logic is universal. What changes is the metadata layer: profile URLs, identifier systems, terminology editions, timezone offsets. Separating these two layers is the design challenge.
The EU FHIR ecosystem is more layered than AU. Australia's approach is relatively flat: AU Base sits on FHIR R4 and that's mostly it. Europe has EU Base → EU Core → Scoped IGs → National IGs. Understanding where your pipeline sits in that stack matters. I targeted EU Base/Core because it's the broadest level - a Dutch hospital and a German hospital can both accept resources profiled at this level. Going deeper into country-specific profiles (Nictiz, KBV) is a natural next step, but the foundation needs to be right first.
If you're building a FHIR portfolio for Europe, implement IPS. EHDS is built around it, MyHealth@EU exchanges it, and European health IT companies are hiring for it. A pipeline that can both ingest HL7 v2 messages and produce an IPS document covers both sides of the interoperability problem.
Configuration-driven architecture pays compound interest. The ProfileConfig pattern took maybe two hours to design. When I was implementing the EU profile, I never had to think about "what does this transformer do?" Only "what values should the EU config provide?" Two hours of design saved days of implementation.
GDPR awareness is a soft skill encoded in code. European employers want engineers who understand why health data processing requires a legal basis, what Article 6 vs Article 9 means, and how consent flows work. A Consent resource in your repo shows this better than a resume bullet point.
Backward compatibility is a feature. The hardest part of the EU extension was ensuring the existing AU pipeline stayed unchanged. Every AU test passing without modification was non-negotiable. Adding features is easy. Adding features without breaking existing behavior takes discipline.
What's Next
The pipeline now supports two regulatory jurisdictions with a common transformation core. Next on the list:
-
US Core - a third
PROFILE_REGION=usoption targeting the US market (US Core IG, Argonaut profiles, USCDI) - Terminology validation - integrating with a terminology server to validate SNOMED CT and ICD-10 codes against the correct value sets for each region
- Additional HL7 message types - adding allergies (ADT^A60) and medications (RDE^O11) to populate the empty IPS sections
-
Full IG package loading - replacing placeholder StructureDefinitions with the complete HL7 EU Base/Core IG package in HAPI for meaningful
$validateresults
The full source code, documentation, and Docker Compose setup are on GitHub: maternity-hl7-to-fhir-pipeline.
This is Part 2 of the Maternity HL7-to-FHIR Pipeline series. Part 1 covered the initial AU pipeline architecture. If you're working on FHIR interoperability in the European market - especially around EHDS compliance or IPS implementation - I'd like to hear what challenges you're facing.
Source code: github.com/budityw23/maternity-hl7-to-fhir-pipeline
Tags: #fhir #healthit #architecture #showdev
Top comments (0)