F136 - tech blog

Logo

A simple blog in the complex world of healthcare telematics.

Visit us:

22 December 2025

FHIRPath In Production: Why It Matters In DEMIS

by Daniel Reckel, Verena Sadlack, reading time: 13 mins

Executive Summary

FHIRPath is a compact, declarative language to navigate and filter FHIR resources optimized for hierarchical data structures used in the public health sector. In DEMIS it makes complex rules transparent, testable, and reusable across services. Instead of writing long complex code platform-independent path expressions can be used to read out content and for decision-making or analysis. This post explains the why and how, with practical examples you can adapt.

Note: This is Part I of a three-part series. Part II dives into scenario-based validation, and Part III into routing.


Part I – Why FHIRPath Matters In DEMIS: Fundamentals And Quick Wins

Motivation And Value

Health data from various sources must be processed reliably, transparently, and flexibly in the “Deutsches Elektronisches Melde- und Informationssystem für den Infektionsschutz” project which translates roughly to German Electronic Reporting and Information System for Infection Protection or short DEMIS. Requirements for traceability, auditability, and rapid adaptability are high, especially when reporting paths or validation rules change. This is where FHIRPath comes in: FHIRPath allows complex checks and filtering to be expressed directly on FHIR resources, without burying logic deep in the code. This makes collaboration between developers, domain experts, and testers easier and enables quick wins when implementing new requirements.

What Is FHIRPath?

FHIRPath is a declarative expression language designed specifically for navigating, filtering, transforming and evaluating HL7 FHIR data models. It works similarly to XPath for XML, but is tailored to FHIR’s structure which is optimized for the public health sector. With FHIRPath, you can target elements, values, and structures within a FHIR resource, filter and check them compactly and understandably. HAPI FHIR, a Java based framework, uses FHIRPath expression when showing positions of warnings, errors, and information in its validation outputs.

Example 1:

Patient.name.where(use = 'official').exists()

This expression checks whether a patient has an official name entry using navigation and filtering.

Example 2:

Patient.name.where(use = 'official')

This expression returns the official name entry or null, when none is part of the resource.


FHIRPath In A Nutshell

FHIRPath is an expression language tailored for FHIR. You use it to navigate resource trees, filter collections, and compute booleans for decisions.

Core Operators In The DEMIS Context

In DEMIS, the following FHIRPath operators are especially relevant:

With these operators, the most important validation and routing rules in DEMIS can be mapped directly and transparently.

DEMIS Notification Example

As DEMIS notification bundles can be quite large, we do not want to go beyond the scope of this blog. However, you can find an example of a laboratory report on a SARS-CoV-2 pathogen here. These reports consist of a composition, a patient, up to two practitioners, multiple organizations, pathogen detection, multiple observations and at least one specimen.

These are some examples of FHIRPath expressions we use to navigate notifications or to find and return data. You can look up the expressions in the example file mentioned earlier.

// Find LOINC-coded Observation for a specific test
Bundle.entry.resource
  .where($this is Observation)
  .where(code.coding.where(system = 'http://loinc.org' and code = '94309-2').exists())

Used on the example file this expression would return ‘true’.

// At least one phone number present
Bundle.entry.resource.where($this is Patient).telecom.where(system = 'phone').count() > 0

Used on the example file this expression would return ‘true’.

// Bundle adheres to a specific profile
Bundle.meta.profile.contains('https://demis.rki.de/fhir/StructureDefinition/NotificationBundleLaboratory')

Used on the example file this expression would return ‘true’.

// Code contained in a controlled set
Bundle.entry.resource.where($this is DiagnosticReport).code.coding.code in ('cvpd'|'mytp'|'hivp'|'invp')

Used on the example file this expression would return ‘true’.

We typically strive to work with unambigous FHIR PATH expressions that result in a true or false. These are sufficent for our purposes of validation or traversing a binary search tree. Of course, it is possible to create significantly more complex evaluations on FHIR resources. —

Context — DEMIS And The Need For Clear Rules

Public health reporting requires timely, reliable data flows from multiple senders (labs, hospitals, physicians) to various recipients (local health authorities, Robert Koch Institute (RKI) and others). Each notification travels as a FHIR Bundle that must be validated, routed, and sometimes transformed before delivery. In this environment:

FHIRPath helps by expressing rules directly against resource structures, providing a shared, declarative “rule language” that engineers, analysts, and QA can review together. —

How DEMIS Applies FHIRPath (Preview Of Parts II And III)

Right now we have implemented two core services that use FHIRPath to process notifications: Lifecycle-Validation-Service and Notification-Routing-Service. They are part of a larger validation and routing step, which is firstly executed when a notification arrives in the system. While the schema is validated with the help of a HAPI-FHIR-validator based Validation-Service (which, funnily enough, also uses FHIRPath) the Lifecycle-Validation-Service checks the broader context of the report and verifies whether the values set correspond to specific points in the life cycle of a disease or infection. We also check whether follow-up notifications were actually preceded by primary notifications. The Notification-Routing-Service uses FHIRPath to determine which technical type a notification has and which paragraph of the Infektionsschutzgesetz (IfSG), the German Infection Protection Act, is addressed by the notification.

DEMIS high-level flow with FHIRPath evaluation points

Lifecycle Validation Service (LVS)

Combines multiple conditions to accept or reject a Bundle. Example pattern:

Bundle.entry.resource.where($this is Composition).where(status = 'final').exists()
and Bundle.entry.resource.where($this is Condition).clinicalStatus.coding.where(code = 'active').exists()
and Bundle.entry.resource.where($this is Condition).verificationStatus.coding.where(code = 'confirmed').exists()

This check makes sure that only notifications are accepted that have a final status and clinicalStatus active and the verificationStatus is confirmed.

Notification Routing Service (NRS)

Decides recipients and downstream steps using FHIRPath filters.

// Profile-based filter
Bundle.meta.profile.contains('https://demis.rki.de/fhir/StructureDefinition/NotificationBundleLaboratory')

// Disease-based routing on resolved entries
Bundle.entry.resource.where($this is Composition)
  .section.entry.reference.resolve().code.coding.where(code in {'cvdd','mytp','mytd','mybd'}).exists()

Edge Cases To Plan For (Production)

Designing resilient FHIRPath rules means being explicit about ambiguous cases. Below are the key edge domains and how we address them in DEMIS.

1. Arrays And Multiplicity

FHIR elements that can repeat (e.g. Patient.name, Observation.component, Condition.evidence) often tempt developers to treat the first element as “the one”. That breaks as soon as additional entries appear.

Best practice:

// Prefer filtering + existence instead of positional access
Patient.name.where(use = 'official').exists()

Anti‑pattern:

// Fragile: assumes official name is first
Patient.name[0].use = 'official'

If you really need a single value, reduce explicitly:

Patient.name.where(use = 'official').given.first()

2. Polymorphic Elements

FHIR uses choice types (e.g. value[x], onset[x], effective[x]). You must check the actual type before casting.

Safe pattern:

Observation.value is Quantity and
Observation.value.as(Quantity).unit = 'mg'

Combined predicate pattern:

Encounter.period.start.is(DateTime) and Encounter.period.end.is(DateTime)
  and Encounter.period.start.as(DateTime) < Encounter.period.end.as(DateTime)

Anti‑pattern:

// Unsafe: assumes DateTime, breaks if Period used
Encounter.period.start < today()

3. Empty Vs. Null Semantics

FHIRPath treats absent elements as empty collections, not null references. Use empty() or exists() appropriately.

// Check that there is no official name
Patient.name.where(use = 'official').empty()

// Ensure at least one phone exists
Patient.telecom.where(system = 'phone').exists()

Avoid comparing to literal null; it will not behave as in Java or SQL.

4. Reference Resolution Boundaries

resolve() is powerful but can be costly and unsafe if used unguarded. Always:

Safe multi‑step pattern:

let sectionRefs := Composition.section.entry.reference
sectionRefs.exists() and
let targets := sectionRefs.resolve()
  targets.where($this is Condition).code.coding.where(code = 'cvdd').exists()

Anti‑pattern:

// Repeated unnecessary resolve calls
Composition.section.entry.reference.resolve().code.coding.where(code = 'cvdd').exists()
Composition.section.entry.reference.resolve().code.coding.where(code = 'mytp').exists()

5. Profile Evolution And Drift

Rules tied to profile URLs (Bundle.meta.profile.contains('...')) must track version changes. Mitigations:

Example with injected constant:

Bundle.meta.profile.contains(%DEMIS_NOTIFICATION_PROFILE)

%DEMIS_NOTIFICATION_PROFILE is supplied by the evaluation engine.

6. Performance Under Load

Large Bundles (hundreds of entries) amplify inefficiencies.

Optimized pattern:

// Narrow to Composition sections first
let comps := Bundle.entry.resource.where($this is Composition)
comps.section.entry.reference.exists() and
let refs := comps.section.entry.reference
let condTargets := refs.resolve().where($this is Condition)
condTargets.code.coding.where(code in {'cvdd','mytp'}).exists()

7. Ambiguous Coding Systems

Multiple codings may appear for the same concept (LOINC + local). Always restrict by system.

Observation.code.coding.where(system = 'http://loinc.org' and code = '94500-6').exists()

Anti‑pattern:

Observation.code.coding.where(code = '94500-6').exists() // may match unintended system

8. Temporal Comparisons

FHIR uses date, dateTime, instant. Ensure consistent granularity.

Encounter.period.start.is(DateTime) and
Encounter.period.start.as(DateTime) >= today() - 30 days

Use is() + as() to avoid implicit truncation.

9. Boolean Logic Clarity

Complex chains can hide intent. Prefer intermediate let bindings.

let activeCond := Bundle.entry.resource.where($this is Condition)
  .where(clinicalStatus.coding.where(code = 'active').exists())
activeCond.exists() and activeCond.count() <= 3

10. Defensive Resolve (Concise)

Composition.section.entry.reference.exists() and
Composition.section.entry.reference.resolve().exists()

If external resolution (database/API) is introduced, wrap calls and cache.


Performance Considerations

Efficient expressions keep the pipeline fast:

// Early, selective filtering example
Bundle.entry.resource.where(
$this is Condition and verificationStatus.coding.where(code = 'confirmed').exists()
)

Implementation Note: HAPI FHIR And Controlled resolve()

In DEMIS, resolve() was deliberately limited to references within the current bundle. References were to be resolved deterministically, performantly, and fail-fast for unresolved references. However, requirements have expanded, so that we now need to resolve IDs beyond the boundaries of a bundle with resolve.

// Conceptual integration sketch with HAPI FHIR
FhirContext ctx = FhirContext.forR4();
FhirPathEngine engine = new FhirPathEngine(new HapiWorkerContext(ctx, ctx.getValidationSupport()));
engine.

setHostServices(new DemisEvaluationContext(bundle)); // custom resolve within bundle

boolean isFinal = engine.evaluateToBoolean(bundle,
        "Bundle.entry.resource.where($this is Composition).where(status = 'final').exists()");

Sequence: Bundle-only resolve()

Here, we will first present our original implementation for the resolve method and then discuss the use of an external database in the Part II.

public class CustomEvaluationContext implements IFhirPathEvaluationContext {

    private final Bundle bundle;

    public CustomEvaluationContext(Bundle bundle) {
        this.bundle = bundle;
    }

    @Override
    public IBase resolveReference(@Nonnull IIdType theReference, @Nullable IBase theContext) {
        String referenceValue = theReference.getValue(); // Full reference value
        String referenceIdPart = theReference.getIdPart(); // Just the ID part (e.g., "123456")

        for (Bundle.BundleEntryComponent entry : bundle.getEntry()) {
            // Check if the fullUrl matches
            if (referenceValue.equals(entry.getFullUrl())) {
                return entry.getResource();
            }

            // Check if the resource type and ID match
            if (entry.getResource().getIdElement().getIdPart().equals(referenceIdPart)
                    && entry
                    .getResource()
                    .getResourceType()
                    .toString()
                    .equals(theReference.getResourceType())) {
                return entry.getResource();
            }
        }

        throw new UnsupportedOperationException(
                "Reference resolution not supported for: " + referenceValue);
    }
}

What’s Next

In Part II, we’ll dive into scenario‑driven validation: robust rule design, edge handling, testing, and performance patterns. In Part III, we’ll cover smart routing and orchestration with practical integration notes.


About The Authors

Daniel is a Senior Software Developer and works on the DEMIS backend since 2021 with great expertise in Java and FHIRPath.

Verena is a Software Architect with fullstack software development expertise gained in various projects i.a. as a freelancer.

Both are in the DEMIS NAVY team at gematik, working on DEMIS - Deutsches Elektronisches Melde- und Informationssystem für den Infektionsschutz. Together they have an expertise of 17+ years in software development. They helped moving DEMIS to a microservice architecture and migrating the application to a Kubernetes Cluster on the way to enabling continuous deployments. Their main goal is to ensure future functionality and expandability in critical situations, besides reliable data pipelines and developer-friendly rule tooling.