The Flow Implementation is an implementation of the flow definition using the CQLab SDK.
The Flow Definition is a high level description of clinical flows. It does not contain information about the data model, database queries, technology stack, or even user interface controls. By keeping the flow definition focused purely on clinical logic, there is a clean separation of concerns. Once the clinical domain experts create the flow definition specification, the engineering team is then able to take over and productize the specification into a fully functional software product.
Each flow definition can have many different flow implementations.
For instance, a single flow definition could be implemented with a FHIR R4 implementation and an OMOP implementation. Or, one could be implemented as an interactive flow, while another could be implemented as a non-interactive backend process. One could be specially built to power a CDS Hook, while another could be surfaced as a custom UI wrapped in a SMART on FHIR app.
The key point is that a single flow definition that has been approved by one or many clinical domain experts for relevance and validity, can then be deployed by many different teams, each customizing and taking into account their unique product requirements. This can be especially useful for standards organizations that want to release a specification that can be implemented and customized by different vendors.
A flow implementation is generally exposed and executed through the CQServer. This is not required though, and a flow implementation can be executed in any TypeScript environment, including the browser. For instance, all examples within this documentation are executed with MockData in the browser. For production use cases, it is most important to ensure the execution environment has access to the underlying medical data, and access follows appropriate security and privacy protocols.
Each component of the TypeScript SDK is fully-typed. This focus on type safety is a core design principle of the CQLab SDK, and has proven to provide a tremendous amount of value to the developer. Other approaches that lack type safety significantly increase the chances of introducing hard to reason about edge cases and bugs, especially when dealing with the variability of concept representation inherent in clinical data models.
Let's now walk through the core technical components of a flow implementation:
Every flow implementation requires a Flow Implementation Context.
The flow context that has 3 primary purposes.
interface IntitialData {
patientId: string;
}
interface ScreeningRecommendation {
recommendation: string;
}
export class BreastCancerScreeningContext extends InteractiveFlowContext<
IntitialData,
ScreeningRecommendation
> {
patientBundle: fhir4.Bundle;
constructor(opts: InteractiveFlowContextOptions<IntitialData>) {
super(opts);
const { patientId } = opts.interactiveFlowState.initialData
const patientBundle = getCachedTestPatientById(patientId);
if (!patientBundle) {
throw new Error(
"Patient not found: " + patientId
);
}
this.patientBundle = patient;
}
getPatientBundle() {
return this.patientBundle;
}
}
First, we define our input in the InitialData as a patientId. This is the only information we need to initialize this flow. Another example of an input would be a medicationId for a medication order.
Next, we define our emitted output as a ScreeningRecommendation. In this case we output a simple message, but any structured data can emitted, including FHIR resources or other standard data structures.
Finally, we retrieve the patient data and make it accessible to the node implementations. In this case, we use getCachedTestPatientById to retrieve the bundle, but there are many other possibilities here such as using a CQLab DataRetriever in tandem with a FHIR Server or other async data source.
A key concept to understand about CQFlow is that a node is defined by a node definition and implemented with a node implementation.
// An ExecNode node implementation of a TrueFalse node definition
class IsFemale extends ExecNode<BreastCancerScreeningContext> {
override async evaluate(context: BreastCancerScreeningContext): Promise<TernaryEnum> {
const patient = getPatientFromBundle(context.getPatientBundle());
if (!patient) {
throw new Error("Patient resource not found in bundle");
}
if (!patient.gender) {
return TernaryEnum.UNKNOWN;
}
return patient.gender === "female"
? TernaryEnum.TRUE
: TernaryEnum.FALSE;
}
}
class IsOver45 extends ExecNode<BreastCancerScreeningContext> {
override async evaluate(context: BreastCancerScreeningContext): Promise<TernaryEnum> {
const patient = getPatientFromBundle(context.getPatientBundle());
if (!patient) {
throw new Error("Patient resource not found in bundle");
}
if (!patient.birthDate) {
return TernaryEnum.UNKNOWN;
}
const birthDate = dayjs(patient.birthDate);
const fortyFiveYearsAgo = dayjs().subtract(45, "years");
return birthDate.isBefore(fortyFiveYearsAgo)
? TernaryEnum.TRUE
: TernaryEnum.FALSE;
}
}
// Utility function returning the patient resource from a bundle
export function getPatientFromBundle (bundle: fhir4.Bundle): fhir4.Patient | null {
const patientEntry = context.getPatientBundle().find(
(entry) => entry.resource?.resourceType === "Patient"
)
return patientEntry
? patientEntry.resource as fhir4.Patient
: null;
}
Here we create two ExecNode node implementations that evaluate whether a patient IsFemale and IsOver45.
Within the evaluate function, we have access to the BreastCancerScreeningContext defined in the previous code sample. This allows us to access the patient data, and any other data stored on the context. In more complex cases, this pattern can effectively be used to allow one node to access information from a previous step in the flow.
Each ExecNode evaluation returns a Ternary value. Instead of just True/False, a Ternary value can be True/False/Unknown. Although it is important to know what you know, it is just as important to know what you don't know.
As an example, if we are want to determine "if a patient is over 18", we first search for a patient's birth date in the medical record. If we find the birth date, we are able to calculate whether the patient is over 18 and can return TRUE or FALSE. If we are unable to find a birth date, we return UNKNOWN.
As a slightly trickier example, if we are searching for a "breast cancer screening in the last 2 years" and we find one, we know the patient had a screening and return TRUE. However, if we don't find the screening, we can not guarantee they have not had a screening (only that we were not able to find it in our data set) and therefore return UNKNOWN.
This usage of Ternary logic is something to think through carefully, and the specific design choices are dependent on the needs of the application.
After implementing the context and node bindings, we wire everything together.
// Declare the flow implementation
const breastCancerScreeningImplemenation =
new InteractiveFlowImplementation<BreastCancerScreeningContext>();
// Register the node implementation with the flow implementation
breastCancerScreeningImplemenation.registerTrueFalse(
"is_female",
(trueFalseDef) => new IsFemale(trueFalseDef)
);
// Register the node implementation with the flow implementation
breastCancerScreeningImplemenation.registerTrueFalse(
"is_over_45",
(trueFalseDef) => new IsOver45(trueFalseDef)
);
By using the bindId defined in the node definition, we register the node implementation as fulfilling the contract required by the node definition.
As a quick recap:
This approach provides full control and flexibility over how a clinical flow is implemented and evaluated. It provides software engineering teams the tools they need to build powerful, efficient, and well tested clinical flows that can satisfy even the most demanding product requirements.
Check out the examples section for more detailed examples of flow implementations.