By Ivan Dubrov, Principal Software Engineer at Commure
A follow up from a recent internal discussion about merits of Rust static typing for FHIR data.
In this post I'm going to take a look at the profiling in FHIR. I'll open with a statement which sets up the major constraint for FHIR applications:
FHIR is not a standard per se, but rather a "platform specification"!
What that means is that FHIR specifies only features which are common across jurisdictions and healthcare ecosystem in general. FHIR is by design incomplete.
True interoperability in FHIR is defined via a feature called "profiling". It's the way FHIR specification could be extended for specific scenarios, use cases, jurisdictions, and so on.
So far, many FHIR API implementations and users are successfully ignoring profiles (most of the time), however, my expectation is that everyone will have to deal with profiles 100% of the time, even though they could only define 20% remaining percent of the behavior!
Given that, in my opinion, it is impossible to talk about statically typing the data unless there is at least a basic understanding of profiles.
What is a profile? Basically, it's another "StructureDefinition" which "constrains" the base definition it attaches itself to. Sounds deceptively simple, but they in fact are way more powerful than they look!
The easiest thing a profile can do is straightforward restriction on a base element. For example, "Patient.name" element is optional ("min" cardinality is 0). Profile can make field required by setting "min" to 1. For example, let's look at US Core (it's also called an "Argonaut Project") profile for "Immunization". For the "Immunization.date" field the "min" cardinality is set to 1 -- making it a required field.
An application developed with such profile in mind might expect that "date" field on "Immunization" matching US Core profile is a non-optional date value2.
Another common use-case for profiles is to constrain bindings on elements. There are many possible scenarios here, but the general idea is that profile can restrict which codes could be used, but not allow for new codes (codes which are invalid in the base structure or profile cannot be made valid).
Again, this is somewhat deceiving. If base binding is "example", profile can, for example, replace it with "required" binding. Technically, this restricts possible codes, but from the application point of view it can be viewed as "replacing" binding with a different one.
An example of such profile is US Core Patient, which changes binding for the "Patient.communication.language" element to "http://hl7.org/fhir/us/core/ValueSet/simple-language".
An application developed with such profile in mind might expect the binding defined in profile, not the binding defined in the core specification. Although, the binding stays extensible -- so any value from the base definition could still be used!
Before looking at more complex cases, let me introduce another piece of the puzzle, an "Extension".
"Extension" is a complex type which is crucial to the whole FHIR extensibility story. By itself it is just a pair of URL and some value (which could be of any type). However, the idea is that you can put any kind of data in it -- annotated with URL which would define its semantics.
Every data type extends complex type "Element", which has an "extension" field with value of list of "Extension"s. Therefore, it allows to add custom data to every possible place of FHIR resource -- even inside primitives (although representation of these in JSON is a bit crazy)!
So, another possible use of profiles is defining profile for "Extension" element. This allows to restrict an "Extension" in a way that it becomes your custom data type (remember, FHIR does not allow you to define your own types and resources outside of this extension/profiling mechanism!).
It specifies that "url" field can only have one possible value, "http://hl7.org/fhir/StructureDefinition/patient-birthTime". Also, it specifies that this extension cannot have its own extensions ("max" cardinality of "Extension.extension" is "0"). Finally, it specifies that value type can only be "dateTime"1.
Now we have a way to allow custom data (typed) on an existing field (the "birthTime" extension specifies context "Patient.birthDate", which means it can only be used on "birthDate" field of "Patient").
The only guarantee provided is that if application would see an extension with this URL, it should comply to this profile. Nothing is known upfront about if this extension exists at all (Patient definition doesn't say anything about it)!
What if we want to attach our extension to the Patient resource? Let's say, we want to define an "ethnicity" field on our resource for our US Core Patient profile on Patient.
Please, meet the slicing! It's a technique to specify some constraint on a list of elements. There are two common use cases.
One use case is to specify additional requirements on a subsets of values of the repeatable element. For example, in CareConnect Patient, a "Patient.name" element is sliced by the "use" field. Then, for an element with "use" set to "official", the cardinality is set to "1" (both "min" and "max") -- which enforces that Patient has exactly one official name!
This could be seen as creating essentially a new field on a Patient, "officialName" (or, rather, a selector which will return one of the elements from the "Patient.name" array). If application is developed with this profile in mind, it might expect this field to be defined on the Patient resource!
Another common use-case for slicing (I think, it's actually the most common one) is to slice off an extensions list. The typical definition would be something like "slice me "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity" extension off the extensions on Patient resource and set its cardinality to 0..1". This would essentially add a new field to the Patient, with data type defined by the extension profile!
Again, an application assuming Argonaut profile (US Core Profiles), will expect Patient to have a field called "ethnicity" on the "Patient" resource itself!
Slices: Complex Types
As a side note, profiling extensions can also create "custom" complex types! The convention is that the extension itself will be a "complex type", with no value of itself ("max" cardinality restricted to "0").
Then, the "extension" field on the extension itself could be profiled in a way that each "slice" is given a fixed URI and restriction on value data type. The fixed URI of these "fields" is usually a simple identifier, like "individual" for the "acceptance" extension.
This essentially defines a new data type, with fields of given types!
Two Uses of Profiles
There are two common uses of profiles in the context of the system: resource profiles and system profiles. Resource profiles are ones which are guaranteed for every resource in the system. For example, US Core Patient profile used in such manner would guarantee that every Patient resource returned by an endpoint is a valid US Core Patient resource.
The system profiles, though, only guarantees that these profiles are used in the system, but they are used on a per-instance level.
An example of such profile would be a set of profiles on top of "Observation" resource, for example, "Blood Pressure" profile3. Not every observation is a blood pressure observation, but those which are, should comply to the blood pressure definition (for example, should have LOINC code of "85354-9").
This essentially defines a new type which application dealing with blood pressure might expect.
It is crucial not just for interoperability but also for safety. Note that the difference between guarantees given by a base "Observation" resource and "Blood Profile" is huge: base definition is essentially a collection of "anything we observed for whoever", whereas blood pressure observation is very specific.
The key take away should be that profiles are important, they could do a lot and they define the real guarantees. Also, they are highly dynamic across multiple dimensions (could be defined "at runtime", could apply on a per-instance basis, could modify resource significantly, and so on).
I was planning to present an idea how we can map these profiles to Rust traits, but this post is getting a bit long, so see y'all next time!
- The tricky part is that an extension is considered a valid value for primitive, which means application might still receive an empty date field! Oh, well...↩
- There is some tricky part here -- this restriction is set on "expanded" variant of the field, "valueDateTime" rather than its base definition "value[x]"↩
- It's in fact defined on top of "Vital Signs" profile, which is defined on top of "Observation" resource.↩