<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Budi Widhiyanto</title>
    <description>The latest articles on DEV Community by Budi Widhiyanto (@budiwidhiyanto).</description>
    <link>https://dev.to/budiwidhiyanto</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F178123%2Fe07b4355-179c-44ca-8bc9-ee89ec2c17de.png</url>
      <title>DEV Community: Budi Widhiyanto</title>
      <link>https://dev.to/budiwidhiyanto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/budiwidhiyanto"/>
    <language>en</language>
    <item>
      <title>FHIR R4 for AI Engineers: What Actually Lives in a Patient Record</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:23:16 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/fhir-r4-for-ai-engineers-what-actually-lives-in-a-patient-record-1839</link>
      <guid>https://dev.to/budiwidhiyanto/fhir-r4-for-ai-engineers-what-actually-lives-in-a-patient-record-1839</guid>
      <description>&lt;p&gt;When I started building a &lt;a href="https://dev.to/budiwidhiyanto/i-built-a-rag-system-that-answers-questions-from-fhir-records-and-refuses-to-guess-3n73"&gt;grounded RAG system over FHIR records&lt;/a&gt;, I assumed the hard part would be retrieval and generation. Two years of working with FHIR had made the resource model familiar, but that familiarity turned out to be the easy half. The real question was which parts of the FHIR structure the pipeline had to preserve instead of flattening the record into a document.&lt;/p&gt;

&lt;p&gt;A FHIR record is not a pile of prose. It is a graph of typed, coded, cross-referencing objects, where the meaning of any one node depends on nodes it points at. Bringing the document-RAG mental model to it builds something that technically runs and quietly answers the wrong questions.&lt;/p&gt;

&lt;p&gt;FHIR R4 patient records contain resources, terminology codes, and references. For an engineer who knows embeddings but has not worked with a patient chart, those details determine the pipeline design.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Bundle is not a document
&lt;/h2&gt;

&lt;p&gt;The unit we get is a &lt;strong&gt;Bundle&lt;/strong&gt;: a JSON envelope with an &lt;code&gt;entry&lt;/code&gt; array. Each entry has a &lt;code&gt;fullUrl&lt;/code&gt; and a &lt;code&gt;resource&lt;/code&gt;. The resources are not sections of one document. They are independent records that happen to have been shipped together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bundle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fullUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:uuid:8f2c..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8f2c..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fullUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:uuid:1a4d..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Observation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1a4d..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction matters immediately. There is no document order to preserve, no narrative flow to keep intact across a chunk boundary, and no reason to concatenate entries. Each resource stands alone, which is why my ingestion treats one resource as one chunk and stops there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The types that carry clinical meaning
&lt;/h2&gt;

&lt;p&gt;FHIR R4 defines over 140 resource types. For question answering over a patient record, ten of them do almost all the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/fhir_parser.py
&lt;/span&gt;&lt;span class="n"&gt;SUPPORTED_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Observation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MedicationRequest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Encounter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AllergyIntolerance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Procedure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DiagnosticReport&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Immunization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CarePlan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly: &lt;code&gt;Condition&lt;/code&gt; is a diagnosis or problem. &lt;code&gt;Observation&lt;/code&gt; is a measured value (a lab result, a vital sign, a BMI percentile). &lt;code&gt;MedicationRequest&lt;/code&gt; is a prescription. &lt;code&gt;Encounter&lt;/code&gt; is a visit. &lt;code&gt;Procedure&lt;/code&gt; is something done to the patient. &lt;code&gt;DiagnosticReport&lt;/code&gt; groups observations into a report. &lt;code&gt;CarePlan&lt;/code&gt; is the plan of care.&lt;/p&gt;

&lt;p&gt;In a real Synthea corpus, &lt;code&gt;Observation&lt;/code&gt; outnumbers everything else by an order of magnitude. My 78-patient corpus indexed 41,837 observations against 2,791 conditions and 3,416 medication requests. That imbalance is not a quirk of synthetic data. Real records look like that too, and it means a naive top-k retrieval is heavily biased toward lab values unless the pipeline corrects for it.&lt;/p&gt;

&lt;p&gt;The types I skip are just as informative: &lt;code&gt;Provenance&lt;/code&gt;, &lt;code&gt;Claim&lt;/code&gt;, &lt;code&gt;ExplanationOfBenefit&lt;/code&gt;, &lt;code&gt;Organization&lt;/code&gt;. They are billing, audit, and directory infrastructure. They are enormous, they are numerous, and no clinician asks questions of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  CodeableConcept, and why &lt;code&gt;display&lt;/code&gt; is not a label
&lt;/h2&gt;

&lt;p&gt;This is the single data structure I had to internalise. Almost every clinically meaningful field in FHIR is a &lt;strong&gt;CodeableConcept&lt;/strong&gt;: a set of codings from standard terminologies, optionally with free text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"coding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://snomed.info/sct"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"44054006"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Diabetes mellitus type 2"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are easy to get wrong.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;coding&lt;/code&gt; is an array. The same concept can be coded in SNOMED CT and ICD-10 and a local system simultaneously. Taking &lt;code&gt;coding[0]&lt;/code&gt; is a heuristic, not a rule.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;display&lt;/code&gt; is a convenience string, not an identifier. It is allowed to be absent, allowed to be a local translation, and allowed to disagree with what the code actually means. The code plus its system is the fact. The display is a hint.&lt;/p&gt;

&lt;p&gt;Third, some fields are a single CodeableConcept and some are an &lt;em&gt;array&lt;/em&gt; of them. This caused a real bug in my system. &lt;code&gt;Condition.code&lt;/code&gt; is singular. &lt;code&gt;Encounter.type&lt;/code&gt; is plural. My path walker, &lt;code&gt;_first_coding&lt;/code&gt;, assumed singular, so its &lt;code&gt;isinstance(value, dict)&lt;/code&gt; check failed and every Encounter in the database rendered as &lt;code&gt;Encounter: unspecified.&lt;/code&gt; until I fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/text_renderer.py
# Several FHIR fields (Encounter.type, AllergyIntolerance.reaction[].
# manifestation) are arrays of CodeableConcept rather than a single
# one; without this the walk yields "unspecified" for every Encounter.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;codings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the spec page for the specific field. There is no general rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The terminologies, briefly
&lt;/h2&gt;

&lt;p&gt;Three code systems cover most of what I see, and knowing which is which tells me what a resource is about before I read it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SNOMED CT&lt;/strong&gt; for clinical findings, conditions, and procedures. &lt;code&gt;http://snomed.info/sct&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LOINC&lt;/strong&gt; for lab tests and measurements. &lt;code&gt;http://loinc.org&lt;/code&gt;. LOINC &lt;code&gt;4548-4&lt;/code&gt; is hemoglobin A1c.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RxNorm&lt;/strong&gt; for medications. &lt;code&gt;http://www.nlm.nih.gov/research/umls/rxnorm&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I flatten these into a queryable JSONB column at ingestion, walking the whole resource recursively rather than looking in specific places, because codings show up nested at several depths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/chunker.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_codes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Extract all coded values (SNOMED, LOINC, RxNorm) from a resource.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;coding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;coding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                        &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
                        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                            &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These codes are a retrieval signal that a text splitter throws away, and they are the difference between matching "diabetes" as a string and matching the concept regardless of how it was spelled.&lt;/p&gt;

&lt;h2&gt;
  
  
  References, and the &lt;code&gt;urn:uuid&lt;/code&gt; trap
&lt;/h2&gt;

&lt;p&gt;Resources point at each other through &lt;code&gt;Reference&lt;/code&gt; objects, which contain a &lt;code&gt;reference&lt;/code&gt; string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MedicationRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient/123"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"encounter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Encounter/456"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside a Bundle, this is more complicated than it looks. Synthea (and plenty of real systems) writes references as &lt;code&gt;urn:uuid:...&lt;/code&gt; pointing at the entry's &lt;code&gt;fullUrl&lt;/code&gt;, not as canonical &lt;code&gt;ResourceType/id&lt;/code&gt;. If we store the reference string as it appears, the foreign keys point at nothing, and patient-scoped retrieval silently misses records.&lt;/p&gt;

&lt;p&gt;The fix is to build a Bundle-local map from &lt;code&gt;fullUrl&lt;/code&gt; to canonical id while parsing, then normalise every reference through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/fhir_parser.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_reference_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Map Bundle fullUrls to canonical FHIR resource IDs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;full_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fullUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;resource_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_resource_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;full_url&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resource_id&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;references&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also why I parse Bundles as raw JSON instead of running them through a validating FHIR model library. The library discards &lt;code&gt;fullUrl&lt;/code&gt; context by the time we have objects, whole-Bundle validation on large Synthea bundles is slow enough to stall ingestion outright, and some emitted fields do not match the installed model version. Strict validation belongs at API boundaries. For bulk ingestion I want tolerant parsing plus explicit checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patient link is not one field
&lt;/h2&gt;

&lt;p&gt;I expected every resource to reference its patient the same way. It does not. Most use &lt;code&gt;subject&lt;/code&gt;, some use &lt;code&gt;patient&lt;/code&gt;, and &lt;code&gt;Patient&lt;/code&gt; itself is its own answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/fhir_parser.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_patient_ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resource_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# FHIR resources use either subject or patient for their patient link.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_reference_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_normalize_reference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference_map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This handful of lines is essential. Patient scoping is the highest-value filter in the system. Without it, a question about one person can be answered from all 78 people in the corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Synthea gives us, and where it differs from real data
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://synthetichealth.github.io/synthea/" rel="noopener noreferrer"&gt;Synthea&lt;/a&gt; generates synthetic patients with realistic disease progression, and it is the right way to develop against FHIR without touching real data. It is also not real data, and the differences affect retrieval quality.&lt;/p&gt;

&lt;p&gt;It emits administrative records with clinical resource types. One &lt;code&gt;Medication review due&lt;/code&gt; Condition per visit, dozens per patient, all nearly identical. It records social determinants ("Unemployed", "Limited social contact") as active Conditions, which are often the most &lt;em&gt;recent&lt;/em&gt; conditions, so ordering a problem list by date buries the actual diagnoses. And its coverage of clinical workflows is uneven: there is no continuous glucose monitor history, no insulin pump model.&lt;/p&gt;

&lt;p&gt;None of that makes it useless. It makes it a corpus with known distortions, which is better for development than a corpus with unknown ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes about the pipeline
&lt;/h2&gt;

&lt;p&gt;The structure is not overhead to strip before embedding. It is signal we get for free, and every part of it maps to a design decision. Resource type becomes a filter. Codes become a second retrieval signal. References become a graph we can walk for context. Resource ids become citation targets.&lt;/p&gt;

&lt;p&gt;The next article in the series makes the chunking argument in full, and shows what happens when we ignore all of that and reach for a recursive character splitter instead.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on clinical data systems at a research unit, mostly FHIR R4, HL7 v2, and HAPI FHIR. This series documents an open-source grounded RAG system over FHIR records: &lt;a href="https://github.com/budityw23/fhir-rag" rel="noopener noreferrer"&gt;github.com/budityw23/fhir-rag&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthcare</category>
      <category>ai</category>
      <category>rag</category>
    </item>
    <item>
      <title>FHIR R4 for ML Engineers: What Actually Lives in a Patient Record</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Mon, 07 Sep 2026 06:30:02 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/fhir-r4-for-ml-engineers-what-actually-lives-in-a-patient-record-1ne4</link>
      <guid>https://dev.to/budiwidhiyanto/fhir-r4-for-ml-engineers-what-actually-lives-in-a-patient-record-1ne4</guid>
      <description>&lt;p&gt;When I started building a &lt;a href="https://dev.to/budiwidhiyanto/i-built-a-rag-system-that-answers-questions-from-fhir-records-and-refuses-to-guess-3n73"&gt;grounded RAG system over FHIR records&lt;/a&gt;, I assumed the hard part would be retrieval and generation. Two years of working with FHIR had made the resource model familiar, but that familiarity turned out to be the easy half. The real question was which parts of the FHIR structure the pipeline had to preserve instead of flattening the record into a document.&lt;/p&gt;

&lt;p&gt;A FHIR record is not a pile of prose. It is a graph of typed, coded, cross-referencing objects, where the meaning of any one node depends on nodes it points at. Bringing the document-RAG mental model to it builds something that technically runs and quietly answers the wrong questions.&lt;/p&gt;

&lt;p&gt;FHIR R4 patient records contain resources, terminology codes, and references. For an engineer who knows embeddings but has not worked with a patient chart, those details determine the pipeline design.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Bundle is not a document
&lt;/h2&gt;

&lt;p&gt;The unit we get is a &lt;strong&gt;Bundle&lt;/strong&gt;: a JSON envelope with an &lt;code&gt;entry&lt;/code&gt; array. Each entry has a &lt;code&gt;fullUrl&lt;/code&gt; and a &lt;code&gt;resource&lt;/code&gt;. The resources are not sections of one document. They are independent records that happen to have been shipped together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bundle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fullUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:uuid:8f2c..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8f2c..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fullUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:uuid:1a4d..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Observation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1a4d..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction matters immediately. There is no document order to preserve, no narrative flow to keep intact across a chunk boundary, and no reason to concatenate entries. Each resource stands alone, which is why my ingestion treats one resource as one chunk and stops there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The types that carry clinical meaning
&lt;/h2&gt;

&lt;p&gt;FHIR R4 defines over 140 resource types. For question answering over a patient record, ten of them do almost all the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/fhir_parser.py
&lt;/span&gt;&lt;span class="n"&gt;SUPPORTED_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Observation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MedicationRequest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Encounter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AllergyIntolerance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Procedure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DiagnosticReport&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Immunization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CarePlan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly: &lt;code&gt;Condition&lt;/code&gt; is a diagnosis or problem. &lt;code&gt;Observation&lt;/code&gt; is a measured value (a lab result, a vital sign, a BMI percentile). &lt;code&gt;MedicationRequest&lt;/code&gt; is a prescription. &lt;code&gt;Encounter&lt;/code&gt; is a visit. &lt;code&gt;Procedure&lt;/code&gt; is something done to the patient. &lt;code&gt;DiagnosticReport&lt;/code&gt; groups observations into a report. &lt;code&gt;CarePlan&lt;/code&gt; is the plan of care.&lt;/p&gt;

&lt;p&gt;In a real Synthea corpus, &lt;code&gt;Observation&lt;/code&gt; outnumbers everything else by an order of magnitude. My 78-patient corpus indexed 41,837 observations against 2,791 conditions and 3,416 medication requests. That imbalance is not a quirk of synthetic data. Real records look like that too, and it means a naive top-k retrieval is heavily biased toward lab values unless the pipeline corrects for it.&lt;/p&gt;

&lt;p&gt;The types I skip are just as informative: &lt;code&gt;Provenance&lt;/code&gt;, &lt;code&gt;Claim&lt;/code&gt;, &lt;code&gt;ExplanationOfBenefit&lt;/code&gt;, &lt;code&gt;Organization&lt;/code&gt;. They are billing, audit, and directory infrastructure. They are enormous, they are numerous, and no clinician asks questions of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  CodeableConcept, and why &lt;code&gt;display&lt;/code&gt; is not a label
&lt;/h2&gt;

&lt;p&gt;This is the single data structure I had to internalise. Almost every clinically meaningful field in FHIR is a &lt;strong&gt;CodeableConcept&lt;/strong&gt;: a set of codings from standard terminologies, optionally with free text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"coding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://snomed.info/sct"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"44054006"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Diabetes mellitus type 2"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are easy to get wrong.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;coding&lt;/code&gt; is an array. The same concept can be coded in SNOMED CT and ICD-10 and a local system simultaneously. Taking &lt;code&gt;coding[0]&lt;/code&gt; is a heuristic, not a rule.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;display&lt;/code&gt; is a convenience string, not an identifier. It is allowed to be absent, allowed to be a local translation, and allowed to disagree with what the code actually means. The code plus its system is the fact. The display is a hint.&lt;/p&gt;

&lt;p&gt;Third, some fields are a single CodeableConcept and some are an &lt;em&gt;array&lt;/em&gt; of them. This caused a real bug in my system. &lt;code&gt;Condition.code&lt;/code&gt; is singular. &lt;code&gt;Encounter.type&lt;/code&gt; is plural. My path walker, &lt;code&gt;_first_coding&lt;/code&gt;, assumed singular, so its &lt;code&gt;isinstance(value, dict)&lt;/code&gt; check failed and every Encounter in the database rendered as &lt;code&gt;Encounter: unspecified.&lt;/code&gt; until I fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/text_renderer.py
# Several FHIR fields (Encounter.type, AllergyIntolerance.reaction[].
# manifestation) are arrays of CodeableConcept rather than a single
# one; without this the walk yields "unspecified" for every Encounter.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;codings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the spec page for the specific field. There is no general rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The terminologies, briefly
&lt;/h2&gt;

&lt;p&gt;Three code systems cover most of what I see, and knowing which is which tells me what a resource is about before I read it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SNOMED CT&lt;/strong&gt; for clinical findings, conditions, and procedures. &lt;code&gt;http://snomed.info/sct&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LOINC&lt;/strong&gt; for lab tests and measurements. &lt;code&gt;http://loinc.org&lt;/code&gt;. LOINC &lt;code&gt;4548-4&lt;/code&gt; is hemoglobin A1c.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RxNorm&lt;/strong&gt; for medications. &lt;code&gt;http://www.nlm.nih.gov/research/umls/rxnorm&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I flatten these into a queryable JSONB column at ingestion, walking the whole resource recursively rather than looking in specific places, because codings show up nested at several depths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/chunker.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_codes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Extract all coded values (SNOMED, LOINC, RxNorm) from a resource.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;coding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;coding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                        &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
                        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                            &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These codes are a retrieval signal that a text splitter throws away, and they are the difference between matching "diabetes" as a string and matching the concept regardless of how it was spelled.&lt;/p&gt;

&lt;h2&gt;
  
  
  References, and the &lt;code&gt;urn:uuid&lt;/code&gt; trap
&lt;/h2&gt;

&lt;p&gt;Resources point at each other through &lt;code&gt;Reference&lt;/code&gt; objects, which contain a &lt;code&gt;reference&lt;/code&gt; string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MedicationRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient/123"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"encounter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Encounter/456"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside a Bundle, this is more complicated than it looks. Synthea (and plenty of real systems) writes references as &lt;code&gt;urn:uuid:...&lt;/code&gt; pointing at the entry's &lt;code&gt;fullUrl&lt;/code&gt;, not as canonical &lt;code&gt;ResourceType/id&lt;/code&gt;. If we store the reference string as it appears, the foreign keys point at nothing, and patient-scoped retrieval silently misses records.&lt;/p&gt;

&lt;p&gt;The fix is to build a Bundle-local map from &lt;code&gt;fullUrl&lt;/code&gt; to canonical id while parsing, then normalise every reference through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/fhir_parser.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_reference_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Map Bundle fullUrls to canonical FHIR resource IDs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;full_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fullUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;resource_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_resource_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;full_url&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resource_id&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;references&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also why I parse Bundles as raw JSON instead of running them through a validating FHIR model library. The library discards &lt;code&gt;fullUrl&lt;/code&gt; context by the time we have objects, whole-Bundle validation on large Synthea bundles is slow enough to stall ingestion outright, and some emitted fields do not match the installed model version. Strict validation belongs at API boundaries. For bulk ingestion I want tolerant parsing plus explicit checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patient link is not one field
&lt;/h2&gt;

&lt;p&gt;I expected every resource to reference its patient the same way. It does not. Most use &lt;code&gt;subject&lt;/code&gt;, some use &lt;code&gt;patient&lt;/code&gt;, and &lt;code&gt;Patient&lt;/code&gt; itself is its own answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/fhir_parser.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_patient_ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resource_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# FHIR resources use either subject or patient for their patient link.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_reference_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_normalize_reference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference_map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This handful of lines is essential. Patient scoping is the highest-value filter in the system. Without it, a question about one person can be answered from all 78 people in the corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Synthea gives us, and where it differs from real data
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://synthetichealth.github.io/synthea/" rel="noopener noreferrer"&gt;Synthea&lt;/a&gt; generates synthetic patients with realistic disease progression, and it is the right way to develop against FHIR without touching real data. It is also not real data, and the differences affect retrieval quality.&lt;/p&gt;

&lt;p&gt;It emits administrative records with clinical resource types. One &lt;code&gt;Medication review due&lt;/code&gt; Condition per visit, dozens per patient, all nearly identical. It records social determinants ("Unemployed", "Limited social contact") as active Conditions, which are often the most &lt;em&gt;recent&lt;/em&gt; conditions, so ordering a problem list by date buries the actual diagnoses. And its coverage of clinical workflows is uneven: there is no continuous glucose monitor history, no insulin pump model.&lt;/p&gt;

&lt;p&gt;None of that makes it useless. It makes it a corpus with known distortions, which is better for development than a corpus with unknown ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes about the pipeline
&lt;/h2&gt;

&lt;p&gt;The structure is not overhead to strip before embedding. It is signal we get for free, and every part of it maps to a design decision. Resource type becomes a filter. Codes become a second retrieval signal. References become a graph we can walk for context. Resource ids become citation targets.&lt;/p&gt;

&lt;p&gt;The next article in the series makes the chunking argument in full, and shows what happens when we ignore all of that and reach for a recursive character splitter instead.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on clinical data systems at a research unit, mostly FHIR R4, HL7 v2, and HAPI FHIR. This series documents an open-source grounded RAG system over FHIR records: &lt;a href="https://github.com/budityw23/fhir-rag" rel="noopener noreferrer"&gt;github.com/budityw23/fhir-rag&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthcare</category>
      <category>ai</category>
      <category>rag</category>
    </item>
    <item>
      <title>I Built a RAG System That Answers Questions From FHIR Records and Refuses to Guess</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:58:41 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/i-built-a-rag-system-that-answers-questions-from-fhir-records-and-refuses-to-guess-3n73</link>
      <guid>https://dev.to/budiwidhiyanto/i-built-a-rag-system-that-answers-questions-from-fhir-records-and-refuses-to-guess-3n73</guid>
      <description>&lt;p&gt;Every job listing I saw this year mentioned RAG, and many of them specifically mentioned graph RAG. I work with FHIR R4 and clinical data daily, and one question kept coming back: FHIR already gives you structured, coded, individually-addressable resources for every clinical fact in a patient record, and those resources link to each other through explicit references (an Observation points to its Encounter, a MedicationRequest points to its Medication and Patient). The data is already a graph. If any data was a good fit for retrieval-augmented generation, this should be it.&lt;/p&gt;

&lt;p&gt;I decided to build it and find out. The system answers clinical questions over FHIR R4 records, with one hard constraint: every claim in the answer has to point at the resource it came from. When the record cannot support an answer, the system has to say so instead of producing something plausible.&lt;/p&gt;

&lt;p&gt;A question like "What is this patient's most recent HbA1c, and how has it trended?" touches a dozen &lt;code&gt;Observation&lt;/code&gt; resources, at least one &lt;code&gt;Condition&lt;/code&gt;, and the &lt;code&gt;Encounter&lt;/code&gt; each result was drawn at. "What insulin is she on?" needs &lt;code&gt;MedicationRequest&lt;/code&gt; resources plus enough context to know which ones are still active. Retrieving the right resources was the easier part. Getting the system to refuse when the data is not there was harder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why grounding, not just answering
&lt;/h2&gt;

&lt;p&gt;The system could answer questions after the first week. The problem was that wrong answers looked exactly like right ones. A fabricated HbA1c value of 7.2% reads the same as a real one. There is nothing in the sentence that marks it as invented, and the reader has no way to check it without going back to the source record, which is the work the system was supposed to save.&lt;/p&gt;

&lt;p&gt;In most RAG demos, a confidently wrong answer is embarrassing. In a clinical context it is the failure mode that matters. The design target became &lt;strong&gt;grounding&lt;/strong&gt;: a machine-checkable link from every claim back to a specific FHIR resource, and an explicit refusal when that link cannot be made.&lt;/p&gt;

&lt;p&gt;That constraint shaped every layer below it. The chunk boundary is one resource, one chunk, because a citation needs a stable id. The prompt enforces citation format. The API returns a grounding verdict alongside every answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;The request path has seven stages:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    Q[Question] --&amp;gt; Embed
    Embed --&amp;gt; Retrieve
    Retrieve --&amp;gt; Resolve[Follow References]
    Resolve --&amp;gt; Context[Build Context]
    Context --&amp;gt; Generate
    Generate --&amp;gt; Verify[Verify Citations]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The code reads the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/api/routes.py
&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;patient_ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patient_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resource_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_types&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;supplementary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resolve_references&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_hops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_reference_hops&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;supplementary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;system_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_prompt_environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clinical_qa.jinja2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;llm_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;llm_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;grounded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;map_citations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;supplementary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Embed the question. Retrieve candidate resources with hybrid search. Follow FHIR references to pull in supporting context. Assemble a citation-ready context block. Render the prompt. Generate. Then verify what came back.&lt;/p&gt;

&lt;p&gt;Ingestion is the mirror image. Parse Synthea bundles into individual resources, render each one to human-readable text, embed that text, and store it with its metadata in Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  One resource, one chunk
&lt;/h2&gt;

&lt;p&gt;The common RAG approach is to split documents into overlapping token windows, but a FHIR resource is already a clinically meaningful unit with a stable identifier. Splitting one destroys the citation target. Merging several puts unrelated clinical facts in the same retrievable blob. The right chunk boundary is the one FHIR already defines.&lt;/p&gt;

&lt;p&gt;The chunker does almost nothing, and that is intentional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/ingestion/chunker.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chunk_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ParsedResource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FHIRChunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Convert a ParsedResource into a FHIRChunk with structured metadata.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;FHIRChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;patient_ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patient_ref&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;resource_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;extract_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;extract_codes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;text_content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;render_resource_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The metadata carried alongside the text is what makes the rest of the system work. &lt;code&gt;patient_ref&lt;/code&gt; scopes retrieval to one record. &lt;code&gt;resource_date&lt;/code&gt; supports temporal filters. &lt;code&gt;codes&lt;/code&gt; holds every SNOMED, LOINC, and RxNorm coding found in the resource. &lt;code&gt;references&lt;/code&gt; holds outbound links, which is how the reference resolver walks the graph later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres does all four jobs
&lt;/h2&gt;

&lt;p&gt;The system needs a vector index, a full-text index, relational filters, and joins. pgvector inside PostgreSQL gives me all four against the same rows. There is no second datastore to keep in sync and no network hop between "find similar" and "filter to this patient".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- db/init.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;fhir_chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;resource_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;patient_ref&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resource_date&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;codes&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;"references"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;text_content&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;text_search&lt;/span&gt; &lt;span class="n"&gt;TSVECTOR&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text_content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;text_search&lt;/code&gt; is a generated column so the lexical index can never drift from the text it indexes, and re-ingestion needs no extra write path. Embeddings are 384-dimensional, from &lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt; running on CPU.&lt;/p&gt;

&lt;p&gt;Retrieval runs two arms over that table, a vector arm and a full-text arm, and fuses them with weighted Reciprocal Rank Fusion. That query went through the most debugging iterations of any part of the system, and it gets its own article later in this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification, not trust
&lt;/h2&gt;

&lt;p&gt;The prompt asks for citations in a fixed format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rules:
1. Base your answer exclusively on the provided FHIR context.
2. Cite specific resource IDs using [ResourceType/id] format.
3. If the provided context does not contain enough information to answer, say:
   "Insufficient data in the available FHIR resources to answer this question."
4. Never fabricate clinical information, medications, diagnoses, or dates.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model does not always follow those rules. Every citation the model emits is parsed back out and checked against the set of resources that were actually retrieved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/generation/citation_mapper.py
&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CITATION_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;available_resources&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;valid_count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ungrounded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;valid_count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grounded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partially_grounded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cited id that was never retrieved does not become a citation. It becomes evidence that the answer is not fully grounded, and the API says so. The UI shows that verdict next to the answer rather than hiding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Postgres with pgvector and the app both come up from one Compose file. Data comes from &lt;a href="https://github.com/synthetichealth/synthea" rel="noopener noreferrer"&gt;Synthea&lt;/a&gt;, which generates synthetic patient records that are realistic enough to be useful and carry no privacy risk at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-jar&lt;/span&gt; synthea-with-dependencies.jar &lt;span class="nt"&gt;-p&lt;/span&gt; 70 &lt;span class="nt"&gt;--exporter&lt;/span&gt;.fhir.export&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
cp &lt;/span&gt;output/fhir/&lt;span class="k"&gt;*&lt;/span&gt;.json data/synthea/
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app python &lt;span class="nt"&gt;-m&lt;/span&gt; src.ingestion.ingest &lt;span class="nt"&gt;--data-dir&lt;/span&gt; data/synthea/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:8000&lt;/code&gt;, pick a patient, and ask. One thing from the README worth repeating: do not use Synthea's &lt;code&gt;-m diabetes&lt;/code&gt; flag to build a diabetes cohort. &lt;code&gt;-m&lt;/code&gt; filters loaded modules, not patients, and it will produce a corpus with no &lt;code&gt;Observation&lt;/code&gt; or &lt;code&gt;MedicationRequest&lt;/code&gt; resources at all. This cost me an afternoon of debugging before I checked the generated bundles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it scores
&lt;/h2&gt;

&lt;p&gt;Against a 78-patient corpus, 52 evaluation questions across two cohorts (adults with type 2 diabetes, and a pediatric patient with the atopic march and no glucose data at all):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval recall&lt;/td&gt;
&lt;td&gt;0.971&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Citation accuracy&lt;/td&gt;
&lt;td&gt;0.957&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer-keyword coverage&lt;/td&gt;
&lt;td&gt;0.952&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grounded / partial / ungrounded&lt;/td&gt;
&lt;td&gt;45 / 0 / 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency (mean / max)&lt;/td&gt;
&lt;td&gt;14.7 s / 27.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five of the seven ungrounded results are the negative-control questions, where refusing is the correct behaviour. Latency is entirely generation-bound; patient-scoped retrieval itself runs in 5 to 24 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it still gets wrong
&lt;/h2&gt;

&lt;p&gt;Two known problems.&lt;/p&gt;

&lt;p&gt;Near-duplicate resources compete for retrieval slots. Synthea emits one &lt;code&gt;Medication review due&lt;/code&gt; Condition per visit, they embed to nearly identical vectors, and at one point twelve of them occupied every Condition slot in the top 25 for a generic "what are the active problems?" question. That is fixed in practice but not fixed properly. Diversity-aware retrieval is still unimplemented.&lt;/p&gt;

&lt;p&gt;There is no authentication. Every endpoint trusts the caller-supplied &lt;code&gt;patient_ref&lt;/code&gt;. That is fine for a local demo over synthetic data and disqualifying for anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;This is the first in a series. The next article covers what actually lives in a FHIR record, written for people who know embeddings but have never opened a patient chart. After that the series gets into the parts that broke: a hybrid search arm that silently matched nothing for weeks, a CTE that hid two indexes, and a re-embed that computed 77,466 vectors and saved none of them.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub. If you want to see the design decisions with their evidence attached, &lt;code&gt;docs/notes/fhir-rag-debugging.md&lt;/code&gt; is the honest version of this article.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on clinical data systems at a research unit, mostly FHIR R4, HL7 v2, and HAPI FHIR. This series documents an open-source grounded RAG system over FHIR records: &lt;a href="https://github.com/budityw23/fhir-rag" rel="noopener noreferrer"&gt;github.com/budityw23/fhir-rag&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>rag</category>
      <category>postgress</category>
      <category>healthcare</category>
    </item>
    <item>
      <title>Testing HL7-to-FHIR Pipelines Without a Hospital: Mocking HAPI FHIR with respx</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Sun, 09 Aug 2026 04:42:58 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/testing-hl7-to-fhir-pipelines-without-a-hospital-mocking-hapi-fhir-with-respx-13le</link>
      <guid>https://dev.to/budiwidhiyanto/testing-hl7-to-fhir-pipelines-without-a-hospital-mocking-hapi-fhir-with-respx-13le</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;You can't spin up a maternity ward in CI. But you can get 274 tests and 90% coverage without one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Some Background
&lt;/h2&gt;

&lt;p&gt;I've been building an open-source &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;Maternity HL7-to-FHIR Pipeline&lt;/a&gt; that converts legacy HL7 v2.5 messages into FHIR R4 resources with Australian and European FHIR profiles. The pipeline has three layers: Mirth Connect for HL7 ingestion, FastAPI for FHIR transformation, and HAPI FHIR Server for persistence.&lt;/p&gt;

&lt;p&gt;If you want the full architecture story, the &lt;a href="https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o"&gt;first article&lt;/a&gt; covers the end-to-end design, and the &lt;a href="https://dev.to/budiwidhiyanto/why-i-put-mirth-connect-in-front-of-fastapi-instead-of-parsing-hl7-in-python-jh3"&gt;second article&lt;/a&gt; explains why I split responsibilities between Mirth and FastAPI.&lt;/p&gt;

&lt;p&gt;This article is about the part that made me confident enough to publish the whole thing: the tests. Specifically, how to build a reliable test suite for a healthcare integration pipeline when you don't have access to a hospital system, a live HAPI FHIR server, or an MLLP connection.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Testing Problem in Healthcare Integration
&lt;/h2&gt;

&lt;p&gt;Healthcare integration pipelines are awkward to test. The input is a decades-old wire protocol (MLLP) carrying pipe-delimited messages with positional field semantics. The output is validated FHIR resources persisted to a server that enforces its own schema rules. Between input and output, there's transformation logic full of edge cases: merging blood pressure readings, mapping HL7 gender codes to FHIR valuesets, handling missing fields gracefully.&lt;/p&gt;

&lt;p&gt;If you test the whole thing end-to-end, you need Mirth Connect, a HAPI FHIR server, and something pretending to be a hospital. That's a &lt;code&gt;docker compose up&lt;/code&gt; before every test run. CI becomes slow. Debugging becomes painful. You get flaky tests because you're now dependent on container startup timing and network behavior.&lt;/p&gt;

&lt;p&gt;The alternative is to separate what you're &lt;em&gt;actually testing&lt;/em&gt; from what you're &lt;em&gt;testing through&lt;/em&gt;. My FastAPI layer doesn't care about MLLP or Mirth. It receives flat JSON payloads, transforms them into FHIR resources, and sends them to HAPI over HTTP. Every one of those steps is testable in isolation if you mock the HTTP boundary to HAPI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Test Architecture: Two Layers, Clear Boundaries
&lt;/h2&gt;

&lt;p&gt;The test suite has 222 unit tests, 31 integration tests, and 21 end-to-end tests, organized like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/
|-- unit/                       # 222 tests, no network, no Docker
|   |-- test_patient_transformer.py
|   |-- test_condition_transformer.py
|   |-- test_encounter_transformer.py
|   |-- test_observation_transformer.py   # 54 tests incl. BP panel merging
|   |-- test_ips_composition.py
|   |-- test_consent.py
|   |-- test_eu_transformers.py
|   |-- test_au_profiles.py
|   |-- test_profile_registry.py
|   |-- test_profile_contamination.py
|   |-- test_mirth_channel_contract.py
|   |-- test_validate_resource.py
|   |-- test_errors.py
|   |-- test_logging.py
|   |-- test_middleware.py
|   `-- test_validation.py
|
|-- integration/                # 31 tests, full HTTP round-trip
|   |-- test_patient_endpoint.py
|   |-- test_encounter_endpoint.py
|   |-- test_observation_endpoint.py
|   |-- test_health_endpoint.py
|   |-- test_consent_endpoint.py
|   |-- test_ips_endpoint.py
|   |-- test_validate_endpoint.py
|   `-- test_eu_pipeline.py
|
`-- e2e/                        # 21 tests, live Docker stack required
    |-- test_au_pipeline.py
    `-- test_eu_pipeline.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Unit tests&lt;/strong&gt; exercise the transformation logic directly. They call transformer functions with Pydantic input models and assert on the FHIR resource output. No HTTP, no server, no Docker. They run in under 2 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests&lt;/strong&gt; hit the FastAPI endpoints through &lt;code&gt;httpx.AsyncClient&lt;/code&gt; with HAPI responses mocked by &lt;code&gt;respx&lt;/code&gt;. They verify the full request lifecycle: payload validation, transformation, HAPI client calls, response formatting, and error handling. Still no Docker needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;End-to-end tests&lt;/strong&gt; run against a live Docker stack (Mirth + FastAPI + HAPI FHIR) and are skipped in CI. They verify the full pipeline from MLLP message to persisted FHIR resource.&lt;/p&gt;

&lt;p&gt;The key insight: unit and integration layers use &lt;code&gt;respx&lt;/code&gt; to mock HAPI FHIR, but they test different things.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why respx (Not unittest.mock)
&lt;/h2&gt;

&lt;p&gt;The FastAPI layer talks to HAPI through &lt;code&gt;httpx&lt;/code&gt;, an async HTTP client. That means the natural mocking tool is &lt;a href="https://github.com/lundberg/respx" rel="noopener noreferrer"&gt;respx&lt;/a&gt;, which intercepts &lt;code&gt;httpx&lt;/code&gt; requests at the transport level.&lt;/p&gt;

&lt;p&gt;Why not &lt;code&gt;unittest.mock.patch&lt;/code&gt;? Because patching &lt;code&gt;httpx.AsyncClient.put&lt;/code&gt; gives you a dumb mock that returns whatever you tell it to. You lose the ability to assert on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The exact URL the client constructed (did it include the right resource type and identifier?)&lt;/li&gt;
&lt;li&gt;The request body (is the FHIR resource actually valid JSON with the right structure?)&lt;/li&gt;
&lt;li&gt;The HTTP method (was it a PUT for upserts vs POST for creates?)&lt;/li&gt;
&lt;li&gt;Whether conditional headers were set correctly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;respx&lt;/code&gt;, you define route patterns and response fixtures. The mock behaves like a real HTTP endpoint. If your code sends a request to an unexpected URL or with an unexpected method, the test fails with a clear message about what was actually called.&lt;/p&gt;

&lt;p&gt;Here's the pattern I use in the integration tests. HAPI routes are mocked with a helper that sets up all the StructureDefinition and resource endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;respx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ASGITransport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_hapi_mock&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;mock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;respx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080/fhir&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assert_all_called&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Mock StructureDefinition endpoints (profile registration on startup)
&lt;/span&gt;    &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/StructureDefinition/au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StructureDefinition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... other StructureDefinition mocks ...
&lt;/span&gt;
    &lt;span class="c1"&gt;# Mock the HAPI FHIR conditional PUT for Patient
&lt;/span&gt;    &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pat-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Patient/pat-1/_history/1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_patient_upsert_sends_conditional_put&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Verify that patient creation uses conditional PUT with identifier query.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;_hapi_mock&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lifespan_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ASGITransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://testserver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/fhir/Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlationId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mrn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;family&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;given&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATIENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;birthDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;19920315&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gender&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;14 SAMPLE ST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SYDNEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NSW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postalCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patientId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pat-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;HapiClient.upsert_resource&lt;/code&gt; method constructs a conditional PUT with &lt;code&gt;If-None-Exist&lt;/code&gt; headers internally. By mocking at the &lt;code&gt;respx&lt;/code&gt; transport level, the test verifies the full chain: payload parsing, FHIR resource construction, HTTP method selection, and response formatting. If the transformer produces a malformed resource, the test catches it at the HTTP boundary, not through a vague assertion error.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing Transformers: Input Model In, FHIR Resource Out
&lt;/h2&gt;

&lt;p&gt;Each transformer function has the same shape: take a Pydantic input model and a &lt;code&gt;ProfileConfig&lt;/code&gt;, return a FHIR resource. This makes them pure functions (aside from configuration), which makes them easy to test.&lt;/p&gt;

&lt;p&gt;Here's a simplified example of testing the patient transformer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.models.adt_payload&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AdtPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NamePayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AddressPayload&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.profiles.au_profile&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.transformers.patient&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;build_patient&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_patient_basic_fields&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;ADT payload maps correctly to FHIR Patient resource.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AdtPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;correlationId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messageType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ADT^A01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;mrn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ihi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8003608166690503&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;NamePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATIENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;middle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MARY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;birthDate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;19920315&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;gender&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AddressPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;14 SAMPLE ST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SYDNEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NSW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;postalCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0412345678&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;patient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_patient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Check identifier mapping
&lt;/span&gt;    &lt;span class="n"&gt;mrn_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;mrn_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;mrn_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hospital.local/mrn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# Check HL7 gender code mapped to FHIR valueset
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gender&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;female&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# HL7 "F" -&amp;gt; FHIR "female"
&lt;/span&gt;
    &lt;span class="c1"&gt;# Check name structure
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATIENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MARY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's not here: no HTTP mocking, no server setup, no async/await. The transformer is a function. The test calls the function. The assertion checks the output. Each test runs in microseconds.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ProfileConfig&lt;/code&gt; parameter is what makes the same transformer work for both AU and EU profiles - it carries profile URLs, terminology systems, and timezone offsets. Tests pass &lt;code&gt;AU_PROFILE&lt;/code&gt; or &lt;code&gt;EU_PROFILE&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;This is where having &lt;code&gt;fhir.resources&lt;/code&gt; as a dependency pays off. The &lt;code&gt;build_patient&lt;/code&gt; function returns a &lt;code&gt;Patient&lt;/code&gt; Pydantic model, not a raw dict. If the function accidentally sets &lt;code&gt;gender&lt;/code&gt; to &lt;code&gt;"F"&lt;/code&gt; instead of &lt;code&gt;"female"&lt;/code&gt;, the Pydantic model raises a validation error &lt;em&gt;inside the transformer&lt;/em&gt;, before the test even gets to the assertions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing Edge Cases: Where Healthcare Gets Interesting
&lt;/h2&gt;

&lt;p&gt;General-purpose APIs have edge cases. Healthcare APIs have &lt;em&gt;clinically significant&lt;/em&gt; edge cases. Here are the ones that taught me the most.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blood Pressure Panel Merging
&lt;/h3&gt;

&lt;p&gt;In HL7, blood pressure comes as two separate OBX segments: one for systolic (LOINC &lt;code&gt;8480-6&lt;/code&gt;) and one for diastolic (LOINC &lt;code&gt;8462-4&lt;/code&gt;). In FHIR, they should be a single Observation with panel code &lt;code&gt;85354-9&lt;/code&gt; and two &lt;code&gt;component[]&lt;/code&gt; entries.&lt;/p&gt;

&lt;p&gt;The tricky part: they're only a panel if they appear as consecutive OBX segments. A systolic reading followed by a body weight followed by a diastolic reading is two separate observations, not a panel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.models.oru_payload&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ObservationPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OruPayload&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.profiles.au_profile&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.transformers.observation&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;build_observations&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ObservationPayload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;defaults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;setId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;29463-7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;68.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unitCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ObservationPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observations&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OruPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlationId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-004&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mrn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;observations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;observations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_bp_codes_merged_into_panel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Systolic + diastolic OBX segments merge into one BP panel.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_payload&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8480-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Systolic BP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mm[Hg]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8462-4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Diastolic BP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mm[Hg]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_observations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient/2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# One panel, not two observations
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coding&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;85354-9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# BP panel code
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_mixed_bp_and_simple&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;BP pair merges; non-BP observations remain individual.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_payload&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8480-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Systolic BP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mm[Hg]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8462-4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Diastolic BP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mm[Hg]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;29463-7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;68.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;55283-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fetal heart rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;145&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_observations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient/2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Encounter/4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;codes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coding&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;85354-9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;  &lt;span class="c1"&gt;# BP panel
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;29463-7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;  &lt;span class="c1"&gt;# Body weight
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;55283-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt;  &lt;span class="c1"&gt;# Fetal heart rate
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_orphan_systolic_built_individually&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Systolic without diastolic -&amp;gt; individual observation.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_payload&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nf"&gt;_obs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8480-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Systolic BP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mm[Hg]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_observations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient/2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coding&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8480-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Not wrapped in panel
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tests, three different behaviors from the same function. The &lt;code&gt;build_observations&lt;/code&gt; function takes a full &lt;code&gt;OruPayload&lt;/code&gt; (not a raw list), a patient reference, an optional encounter reference, and a &lt;code&gt;ProfileConfig&lt;/code&gt;. The BP merging logic scans all &lt;code&gt;ObservationPayload&lt;/code&gt; items for matching systolic + diastolic LOINC codes and merges them into a panel. Orphan readings stay standalone. The test suite covers additional scenarios: orphan diastolic, worst-case status interpretation (one preliminary + one final = panel is preliminary), and abnormal flag propagation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mirth Contract Tests: Catching Drift Without Running Mirth
&lt;/h3&gt;

&lt;p&gt;The Mirth JavaScript transformer parses HL7 messages by field position and produces flat JSON for FastAPI. If the field positions in the Mirth transformer and the Pydantic models in FastAPI ever drift apart, the pipeline silently produces wrong FHIR resources.&lt;/p&gt;

&lt;p&gt;I test the contract without running Mirth. The test file contains a minimal HL7 v2 parser (about 60 lines of Python) that extracts fields by the same positional rules as the Mirth JavaScript. It reads the actual synthetic &lt;code&gt;.hl7&lt;/code&gt; sample files from disk, builds the flat JSON payload, and validates it against the real Pydantic models:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.models.adt_payload&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AdtPayload&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_adt_maps_to_valid_patient_payload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;adt_a01_normal_delivery.hl7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;_message_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ADT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_patient_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-adt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AdtPayload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises if the contract is broken
&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrn&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ihi&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8003608166690503&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gender&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnoses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnoses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;O80&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;build_patient_payload&lt;/code&gt; function mirrors Mirth's field extraction: PID-3.1 for MRN, PID-3.5 for identifier type, PID-5 for name components. If someone changes the Pydantic model to rename a field or make a previously optional field required, this test fails immediately - even though Mirth isn't running.&lt;/p&gt;

&lt;p&gt;The contract tests cover all three message types (ADT, ORM, ORU) in both AU and EU formats, HL7 escape sequence decoding (&lt;code&gt;\T\&lt;/code&gt; → &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;\S\&lt;/code&gt; → &lt;code&gt;^&lt;/code&gt;), and invalid inputs (missing MRN triggers &lt;code&gt;ValidationError&lt;/code&gt;). That's 11 tests total, all running without Docker. What they don't test is Mirth's E4X runtime - its &lt;code&gt;toString()&lt;/code&gt; behavior and subcomponent drilling - which requires a live MLLP smoke test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Profile Cross-Contamination: Zero-Leakage Tests
&lt;/h3&gt;

&lt;p&gt;When the same transformer serves both AU and EU profiles, there's a risk that AU profile URLs leak into EU output, or EU terminology systems appear in AU resources. Per-profile tests won't catch this - each profile passes its own assertions. But the wrong profile URL breaks FHIR validation in the other jurisdiction.&lt;/p&gt;

&lt;p&gt;The contamination tests run the &lt;em&gt;same&lt;/em&gt; payload through both profiles and assert on the serialized JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;AU_MARKERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au-condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au-vitalsigns-bloodpressure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hl7.org.au/fhir/CodeSystem/icd-10-am&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ns.electronichealth.net.au&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;EU_MARKERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patient-eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;condition-eu-core&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fhir/sid/icd-10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fhir.nhs.uk/Id/nhs-number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_au_output_contains_only_au_values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_all_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AU_MARKERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected AU marker missing: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;EU_MARKERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AU output leaked EU marker: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_eu_output_contains_only_eu_values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_all_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_eu_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;EU_MARKERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected EU marker missing: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hl7.org.au&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icd-10-am&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ns.electronichealth.net.au&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EU output leaked AU marker: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;_all_output&lt;/code&gt; helper runs every transformer (patient, conditions, observations) and concatenates the serialized FHIR JSON into one string. Then it checks for marker strings from the &lt;em&gt;wrong&lt;/em&gt; region. If a refactoring accidentally hardcodes an AU profile URL instead of reading from &lt;code&gt;ProfileConfig&lt;/code&gt;, this test catches it.&lt;/p&gt;

&lt;p&gt;There's also a parametrized test for EU national identifier systems. UK uses NHS numbers, Netherlands uses BSN, Germany uses KVID-10, Ireland uses PPSN. Each country code must produce the correct identifier system in the output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gender Code Mapping
&lt;/h3&gt;

&lt;p&gt;HL7 v2 uses single-character gender codes. FHIR uses full words. The mapping isn't one-to-one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.valuesets.hl7_to_fhir_gender&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;map_gender&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hl7_code,fhir_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;female&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;M&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;male&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;O&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;U&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;      &lt;span class="c1"&gt;# Ambiguous -&amp;gt; other
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;    &lt;span class="c1"&gt;# Not applicable -&amp;gt; unknown
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;# Empty -&amp;gt; unknown
&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_gender_mapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hl7_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fhir_code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;HL7 gender codes map correctly to FHIR Administrative Gender.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;map_gender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hl7_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;fhir_code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parametrized tests are perfect for valueset mappings. Seven test cases, one function, zero ambiguity about what the transformer does with each input. If a hospital sends gender code &lt;code&gt;"A"&lt;/code&gt; (Ambiguous), we know it maps to FHIR &lt;code&gt;"other"&lt;/code&gt;, not that it crashes or silently drops the field. The actual codebase tests gender mapping through individual unit tests (&lt;code&gt;test_gender_female&lt;/code&gt;, &lt;code&gt;test_gender_male&lt;/code&gt;, &lt;code&gt;test_gender_unknown&lt;/code&gt;), but &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; would be equally effective for this finite mapping table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing and Empty Fields
&lt;/h3&gt;

&lt;p&gt;Hospital systems are inconsistent about what they include. Some always send a middle name. Some never do. Some send an empty string where others send nothing at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.models.adt_payload&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AdtPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NamePayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AddressPayload&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.profiles.au_profile&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.transformers.patient&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;build_patient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.transformers.condition&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;build_conditions&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sample_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AdtPayload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;defaults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlationId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-uuid-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mrn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NamePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATIENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;birthDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;19920315&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gender&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AddressPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;14 SAMPLE ST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SYDNEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NSW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;postalCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AdtPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_patient_without_middle_name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Patient resource builds correctly when middle name is absent.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;patient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_patient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_sample_payload&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATIENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# List with one entry, not crash
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_patient_with_empty_diagnosis_list&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Patient with no diagnoses produces no Conditions.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_sample_payload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;conditions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_conditions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient/1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;conditions&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_patient_with_no_phone&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Missing phone number doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t set telecom at all.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;patient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_patient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_sample_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;telecom&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# Not an empty list, not a placeholder
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tests are not clever. They are simple and boring, and that is what I want. The &lt;code&gt;build_patient&lt;/code&gt; and &lt;code&gt;build_conditions&lt;/code&gt; are separate functions - the endpoint orchestrates both, but tests exercise them independently. Every "what if this field is empty" question has a documented, tested answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration Tests: The Full HTTP Round-Trip
&lt;/h2&gt;

&lt;p&gt;Unit tests verify the transformation logic. Integration tests verify that the FastAPI endpoints wire everything together correctly: payload validation, transformer calls, HAPI client calls, response formatting.&lt;/p&gt;

&lt;p&gt;The integration tests use &lt;code&gt;httpx.AsyncClient&lt;/code&gt; with FastAPI's &lt;code&gt;app&lt;/code&gt; directly (no server startup needed) and &lt;code&gt;respx&lt;/code&gt; to mock HAPI. The pattern uses a context manager for the mock and FastAPI's lifespan context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;respx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ASGITransport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_hapi_mock&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;mock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;respx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080/fhir&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assert_all_called&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Mock StructureDefinition PUTs (FastAPI registers profiles on startup)
&lt;/span&gt;    &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/StructureDefinition/au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StructureDefinition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... other StructureDefinition mocks ...
&lt;/span&gt;    &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pat-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                              &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Patient/pat-1/_history/1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cond-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestPatientEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_with_diagnosis_returns_condition_ids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;POST /fhir/Patient returns patientId, conditionIds, and correlationId.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;_hapi_mock&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lifespan_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ASGITransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://testserver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/fhir/Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlationId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int-test-002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mrn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;family&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Smith&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;given&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Jane&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;birthDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;19920315&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gender&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1 Test St&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sydney&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NSW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postalCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diagnoses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;O80&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Normal delivery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
                        &lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conditionIds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cond-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test verifies the entire chain: the endpoint accepts the payload, the transformer builds valid FHIR resources, the HAPI client sends them with the right HTTP methods, and the response is formatted correctly. All without Docker. The &lt;code&gt;lifespan_context&lt;/code&gt; ensures FastAPI's startup hooks run (including profile registration with HAPI), which is why the StructureDefinition mocks are needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing Error Paths
&lt;/h3&gt;

&lt;p&gt;The error tests are where integration tests become important. I want to verify that invalid payloads produce RFC 7807 &lt;code&gt;problem+json&lt;/code&gt; responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_empty_mrn_returns_422&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Empty MRN rejected with problem+json response.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lifespan_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ASGITransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://testserver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/fhir/Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlationId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int-test-003&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mrn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;family&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Smith&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;given&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Jane&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;birthDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;19920315&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gender&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1 Test St&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sydney&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NSW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postalCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/problem+json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unit test layer also exercises the error handling directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.errors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;problem_response&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_problem_response_status&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;problem_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Validation Error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing field&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_problem_response_content_type&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;problem_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Validation Error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing field&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;media_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/problem+json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;respx&lt;/code&gt;, testing HAPI failure paths would require either making HAPI actually reject a resource (which means running HAPI) or patching internals so deep that the test tells you nothing about real behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  The CI Pipeline
&lt;/h2&gt;

&lt;p&gt;All of this runs in GitHub Actions on every push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;profile_region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;au&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;eu&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;PROFILE_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ matrix.profile_region }}&lt;/span&gt;
      &lt;span class="na"&gt;PROFILE_COUNTRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ matrix.profile_region == 'eu' &amp;amp;&amp;amp; 'uk' || '' }}&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./fastapi&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install ".[dev]"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lint app&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./fastapi&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruff check app/&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lint tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruff check tests/&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Type check&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./fastapi&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mypy app/ --ignore-missing-imports&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests with coverage&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;python -m pytest tests/ -v \&lt;/span&gt;
            &lt;span class="s"&gt;--cov=fastapi/app \&lt;/span&gt;
            &lt;span class="s"&gt;--cov-report=term-missing \&lt;/span&gt;
            &lt;span class="s"&gt;--cov-fail-under=80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Docker in CI. No Mirth. No HAPI. The entire test suite runs in under 10 seconds. The &lt;code&gt;matrix&lt;/code&gt; strategy runs the full suite twice - once with &lt;code&gt;PROFILE_REGION=au&lt;/code&gt; and once with &lt;code&gt;PROFILE_REGION=eu&lt;/code&gt; - so both AU and EU profile paths are exercised in CI. The coverage threshold is set at 80% (actual coverage is 90%), so if someone adds a new transformer without tests, CI catches it.&lt;/p&gt;

&lt;p&gt;The lint steps run &lt;code&gt;ruff check&lt;/code&gt; on both application code and tests (separately, since they live in different directories). The type check step (&lt;code&gt;mypy&lt;/code&gt;) catches type errors at the function boundary level. Between the three checks, most bugs are caught before the code ever runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Don't Test (and Why)
&lt;/h2&gt;

&lt;p&gt;Being honest about what you don't test matters as much as coverage numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mirth Connect's JavaScript runtime is not tested in CI.&lt;/strong&gt; However, the &lt;em&gt;contract&lt;/em&gt; between Mirth and FastAPI is tested: 11 unit tests in &lt;code&gt;test_mirth_channel_contract.py&lt;/code&gt; parse the actual sample HL7 messages using the same field positions as the Mirth JavaScript transformer, build the flat JSON payload, and validate it against the real FastAPI Pydantic models. This covers AU and EU messages, HL7 escape sequences (&lt;code&gt;\T\&lt;/code&gt; → &lt;code&gt;&amp;amp;&lt;/code&gt;), and invalid inputs (missing MRN → &lt;code&gt;ValidationError&lt;/code&gt;). What's not tested is Mirth's E4X runtime semantics - its &lt;code&gt;toString()&lt;/code&gt; behavior, subcomponent drilling, and actual MLLP framing -&lt;br&gt;
 which would require running Mirth in Docker. The live MLLP smoke test covers that gap manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HAPI FHIR profile validation is not fully tested.&lt;/strong&gt; The &lt;code&gt;validate_before_persist&lt;/code&gt; setting exists in the app config but the unit tests don't exercise HAPI's &lt;code&gt;$validate&lt;/code&gt; endpoint against real profiles. The mock returns whatever I tell it to. This means I'm testing that my code &lt;em&gt;sends&lt;/em&gt; a valid resource, not that HAPI &lt;em&gt;accepts&lt;/em&gt; it. The end-to-end test suite (which runs against a live HAPI instance in Docker) does validate this path, but those tests are skipped in CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MLLP protocol handling is not tested.&lt;/strong&gt; That's Mirth Connect's job, and Mirth has its own test suite. I test the boundary I own: the HTTP interface between Mirth and FastAPI.&lt;/p&gt;

&lt;p&gt;Each of these gaps maps to a deliberate architectural decision. The pipeline's layers have clear boundaries specifically so each layer's tests don't need to boot the other layers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Patterns Worth Stealing
&lt;/h2&gt;

&lt;p&gt;If you're building a healthcare integration and want to set up a similar test suite, here's the minimal recipe:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;respx&lt;/code&gt; for any &lt;code&gt;httpx&lt;/code&gt;-based FHIR client.&lt;/strong&gt; It mocks at the transport level, so your tests exercise the actual URL construction, header setting, and body serialization code. &lt;code&gt;unittest.mock.patch&lt;/code&gt; skips all of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make transformers pure functions.&lt;/strong&gt; If &lt;code&gt;build_patient(payload, profile)&lt;/code&gt; returns a FHIR resource without side effects, it's testable without any mocking at all. Push all IO (HTTP calls, file writes, logging) to the edges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parametrize valueset mappings.&lt;/strong&gt; Gender codes, observation status codes, encounter class mappings, all of these have a finite set of valid inputs. A &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; decorator turns the entire mapping table into test cases in one block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the failure modes, not just the happy path.&lt;/strong&gt; Healthcare data is messy. Missing fields, empty arrays, unexpected codes. Each one deserves a test that documents what the pipeline does rather than crashing with an unhandled &lt;code&gt;KeyError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set a coverage floor, not a ceiling.&lt;/strong&gt; My threshold is 80%. Actual coverage is 90%. The floor catches regressions without encouraging people to write meaningless tests just to hit a number.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/budityw23/maternity-hl7-to-fhir-pipeline.git
&lt;span class="nb"&gt;cd &lt;/span&gt;maternity-hl7-to-fhir-pipeline

&lt;span class="c"&gt;# Install dev dependencies&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;fastapi &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;".[dev]"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ..

&lt;span class="c"&gt;# Run AU profile tests&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; pytest tests/ &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--cov&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;fastapi/app &lt;span class="nt"&gt;--cov-report&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;term-missing

&lt;span class="c"&gt;# Run EU profile tests&lt;/span&gt;
&lt;span class="nv"&gt;PROFILE_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu &lt;span class="nv"&gt;PROFILE_COUNTRY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;uk python &lt;span class="nt"&gt;-m&lt;/span&gt; pytest tests/ &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Docker required. The unit + integration suite runs in seconds. E2E tests (&lt;code&gt;tests/e2e/&lt;/code&gt;) need the Docker stack running - see &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;github.com/budityw23/maternity-hl7-to-fhir-pipeline&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the fourth article in my series on the Maternity HL7-to-FHIR Pipeline. The &lt;a href="https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o"&gt;first article&lt;/a&gt; covers the full architecture, the &lt;a href="https://dev.to/budiwidhiyanto/why-i-put-mirth-connect-in-front-of-fastapi-instead-of-parsing-hl7-in-python-jh3"&gt;second&lt;/a&gt; explains the Mirth + FastAPI split, and the &lt;a href="https://dev.to/budiwidhiyanto/one-pipeline-two-continents-adding-eu-fhir-profiles-to-an-australian-healthcare-integration-4an6"&gt;third&lt;/a&gt; shows how we added EU FHIR profile support to the same codebase.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>architecture</category>
      <category>hl7</category>
    </item>
    <item>
      <title>One Pipeline, Two Continents: Adding EU FHIR Profiles to an Australian Healthcare Integration</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Mon, 03 Aug 2026 16:56:42 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/one-pipeline-two-continents-adding-eu-fhir-profiles-to-an-australian-healthcare-integration-4an6</link>
      <guid>https://dev.to/budiwidhiyanto/one-pipeline-two-continents-adding-eu-fhir-profiles-to-an-australian-healthcare-integration-4an6</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I'd just finished building a &lt;a href="https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o"&gt;Maternity HL7-to-FHIR Pipeline&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;Then I started asking myself: &lt;em&gt;could this same pipeline work for a European hospital?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The HL7 v2 messages coming from the hospital are identical - an &lt;code&gt;ADT^A01&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;budityw23/maternity-hl7-to-fhir-pipeline&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Actually Different Between AU and EU FHIR?
&lt;/h2&gt;

&lt;p&gt;Before touching any code, I needed to understand what "European FHIR" means. The EU ecosystem is more complex than Australia's, and more interesting.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Profiling Layer
&lt;/h3&gt;

&lt;p&gt;Australia has a relatively flat structure: &lt;strong&gt;AU Base profiles&lt;/strong&gt; sit directly on top of base FHIR R4. You declare &lt;code&gt;http://hl7.org.au/fhir/StructureDefinition/au-patient&lt;/code&gt; on your Patient resource and you're done.&lt;/p&gt;

&lt;p&gt;Europe has a layered architecture. HL7 Europe publishes three foundational Implementation Guides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HL7 Europe Base&lt;/strong&gt; - flexible foundation profiles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HL7 Europe Core&lt;/strong&gt; - essential constraints on top of Base&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HL7 Europe Extensions&lt;/strong&gt; - EU-specific extensions (nationality, citizenship, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of that sit &lt;strong&gt;scoped IGs&lt;/strong&gt; - 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).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Identifiers
&lt;/h3&gt;

&lt;p&gt;In Australia, every patient has an &lt;strong&gt;IHI&lt;/strong&gt; (Individual Healthcare Identifier) - a single national identifier system with one URI: &lt;code&gt;http://ns.electronichealth.net.au/id/hi/ihi/1.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Europe doesn't have a single identifier. Each country has its own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Identifier&lt;/th&gt;
&lt;th&gt;System URI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Australia&lt;/td&gt;
&lt;td&gt;IHI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://ns.electronichealth.net.au/id/hi/ihi/1.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK&lt;/td&gt;
&lt;td&gt;NHS Number&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://fhir.nhs.uk/Id/nhs-number&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netherlands&lt;/td&gt;
&lt;td&gt;BSN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://fhir.nl/fhir/NamingSystem/bsn&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;KVNR&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://fhir.de/sid/gkv/kvid-10&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ireland&lt;/td&gt;
&lt;td&gt;PPS Number&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://fhir.ie/sid/ppsn&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(These are the exact system URIs used in &lt;code&gt;fastapi/app/profiles/eu_profile.py&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;The pipeline needs a configurable national identifier, not a hardcoded one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terminology
&lt;/h3&gt;

&lt;p&gt;Australian maternity care uses &lt;strong&gt;ICD-10-AM&lt;/strong&gt; (Australian Modification) for diagnosis coding. The system URI is &lt;code&gt;http://hl7.org.au/fhir/CodeSystem/icd-10-am&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;European systems use standard &lt;strong&gt;ICD-10&lt;/strong&gt; (WHO edition) with system URI &lt;code&gt;http://hl7.org/fhir/sid/icd-10&lt;/code&gt;. Some countries use their own modifications (Germany has ICD-10-GM), but at the EU Base level, the WHO edition is the common ground.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;http://snomed.info/sct&lt;/code&gt;) - the difference is in which value sets and reference sets are expected.&lt;/p&gt;

&lt;p&gt;LOINC and UCUM are universal. Blood pressure is still &lt;code&gt;85354-9&lt;/code&gt;. Weight is still &lt;code&gt;29463-7&lt;/code&gt;. Millimeters of mercury are still &lt;code&gt;mm[Hg]&lt;/code&gt;. Some things, thankfully, don't need localization.&lt;/p&gt;

&lt;h3&gt;
  
  
  International Patient Summary
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;IPS&lt;/strong&gt; (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.&lt;/p&gt;

&lt;p&gt;My pipeline already produces Patient, Condition, Observation, and Encounter resources. Wrapping those into an IPS Composition document is a natural extension.&lt;/p&gt;

&lt;h3&gt;
  
  
  GDPR
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  My First Instinct (And Why It Was Wrong)
&lt;/h2&gt;

&lt;p&gt;My initial approach was the obvious one: fork the repo, create a separate &lt;code&gt;maternity-hl7-to-fhir-pipeline-eu&lt;/code&gt; project, and replace all the AU-specific values with EU ones.&lt;/p&gt;

&lt;p&gt;Find and replace. &lt;code&gt;au-patient&lt;/code&gt; → &lt;code&gt;Patient-eu&lt;/code&gt;. IHI → NHS Number. ICD-10-AM → ICD-10. Ship it.&lt;/p&gt;

&lt;p&gt;This would have worked for a demo. But it's the wrong architecture for several reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicated transformation logic.&lt;/strong&gt; The mapping from &lt;code&gt;PID-5.1&lt;/code&gt; to &lt;code&gt;Patient.name[0].family&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No path to a third region.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It hides the interesting design work.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Profile Configuration Pattern
&lt;/h2&gt;

&lt;p&gt;The core design change was extracting all region-specific values into a &lt;strong&gt;profile configuration module&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ProfileConfig&lt;/code&gt; is a frozen dataclass that captures everything that varies by jurisdiction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastapi/app/profiles/base.py
&lt;/span&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProfileConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;patient_profile_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;condition_profile_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;encounter_profile_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;bp_observation_profile_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;observation_profile_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;national_id_system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;national_id_display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_code_system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;snomed_system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;timezone_offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;default_country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;profile_definitions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AU configuration is a single frozen instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastapi/app/profiles/au_profile.py
&lt;/span&gt;&lt;span class="n"&gt;AU_PROFILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProfileConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;patient_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org.au/fhir/StructureDefinition/au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;condition_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org.au/fhir/StructureDefinition/au-condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;encounter_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org.au/fhir/StructureDefinition/au-encounter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bp_observation_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org.au/fhir/StructureDefinition/au-vitalsigns-bloodpressure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;observation_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org/fhir/StructureDefinition/vitalsigns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;national_id_system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://ns.electronichealth.net.au/id/hi/ihi/1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;national_id_display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IHI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_code_system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org.au/fhir/CodeSystem/icd-10-am&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;snomed_system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://snomed.info/sct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timezone_offset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+10:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;default_country&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;profile_definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org.au/fhir/StructureDefinition/au-patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUPatient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;au-condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org.au/fhir/StructureDefinition/au-condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUCondition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c1"&gt;# ... plus au-encounter, au-vitalsigns-bloodpressure
&lt;/span&gt;    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EU side is a &lt;em&gt;factory&lt;/em&gt; rather than a static instance, because the national identifier&lt;br&gt;
depends on the country. &lt;code&gt;PROFILE_COUNTRY&lt;/code&gt; selects it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastapi/app/profiles/eu_profile.py
&lt;/span&gt;&lt;span class="n"&gt;EU_NATIONAL_ID_SYSTEMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://fhir.nhs.uk/Id/nhs-number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NHS Number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://fhir.nl/fhir/NamingSystem/bsn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BSN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://fhir.de/sid/gkv/kvid-10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KVNR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://fhir.ie/sid/ppsn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PPS Number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;EU_DEFAULT_ID_SYSTEM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.eu/fhir/base/NamingSystem/national-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;EU_DEFAULT_ID_DISPLAY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;National ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_eu_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ProfileConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;country_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;country_lower&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;EU_NATIONAL_ID_SYSTEMS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;national_id_system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;national_id_display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EU_NATIONAL_ID_SYSTEMS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;country_lower&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;national_id_system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EU_DEFAULT_ID_SYSTEM&lt;/span&gt;
        &lt;span class="n"&gt;national_id_display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EU_DEFAULT_ID_DISPLAY&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ProfileConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;patient_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.eu/fhir/base/StructureDefinition/patient-eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;condition_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.eu/fhir/base/StructureDefinition/condition-eu-core&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;encounter_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org/fhir/StructureDefinition/Encounter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# base FHIR
&lt;/span&gt;        &lt;span class="n"&gt;bp_observation_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org/fhir/StructureDefinition/bp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# FHIR core BP
&lt;/span&gt;        &lt;span class="n"&gt;observation_profile_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org/fhir/StructureDefinition/vitalsigns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;national_id_system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;national_id_system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;national_id_display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;national_id_display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;diagnosis_code_system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.org/fhir/sid/icd-10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;snomed_system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://snomed.info/sct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timezone_offset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+01:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;default_country&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;country_lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;country_lower&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;profile_definitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patient-eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.eu/fhir/base/StructureDefinition/patient-eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PatientEU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;condition-eu-core&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://hl7.eu/fhir/base/StructureDefinition/condition-eu-core&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ConditionEUCore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="c1"&gt;# ... plus FHIR core BP
&lt;/span&gt;        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two environment variables control which profile is active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# AU mode (default - backward compatible)&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;

&lt;span class="c"&gt;# EU mode, UK national identifier (NHS Number)&lt;/span&gt;
&lt;span class="nv"&gt;PROFILE_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu &lt;span class="nv"&gt;PROFILE_COUNTRY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;uk docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The registry resolves the active config from those settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastapi/app/profiles/registry.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_profile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ProfileConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile_region&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;build_eu_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile_country&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;AU_PROFILE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How the Transformers Changed
&lt;/h3&gt;

&lt;p&gt;The transformers don't contain if/else logic for regions. Each route resolves the active &lt;code&gt;ProfileConfig&lt;/code&gt; once via &lt;code&gt;get_profile()&lt;/code&gt; (driven by the &lt;code&gt;PROFILE_REGION&lt;/code&gt; / &lt;code&gt;PROFILE_COUNTRY&lt;/code&gt; settings) and passes it straight into the transformer as an argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastapi/app/main.py — inside the /fhir/Patient handler
&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_profile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;patient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_patient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transformer signature takes that config and uses it for every jurisdiction-specific value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fastapi/app/transformers/patient.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_patient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdtPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ProfileConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="c1"&gt;# Before (hardcoded AU):
&lt;/span&gt;    &lt;span class="c1"&gt;#   meta={"profile": ["http://hl7.org.au/fhir/StructureDefinition/au-patient"]}
&lt;/span&gt;    &lt;span class="c1"&gt;# After (profile-configured):
&lt;/span&gt;    &lt;span class="n"&gt;patient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patient_profile_url&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="nc"&gt;Identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;national_id_system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ihi&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_country&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transformation logic (mapping &lt;code&gt;PID&lt;/code&gt; 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 &lt;code&gt;Depends()&lt;/code&gt; injection — the profile is process-wide config, resolved once per request from the environment.)&lt;/p&gt;

&lt;p&gt;Adding a third region is one new file. A US Core profile would need &lt;code&gt;us_profile.py&lt;/code&gt;, one new entry in the registry, and zero changes to the transformers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Same Message, Different FHIR
&lt;/h2&gt;

&lt;p&gt;To see the impact, compare the same &lt;code&gt;ADT^A01&lt;/code&gt; admission message processed in both modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The HL7 v2 message is identical.&lt;/strong&gt; A pregnant woman is admitted for a routine antenatal visit. The message carries her name, date of birth, identifier, and a pregnancy diagnosis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AU mode&lt;/strong&gt; produces (real transformer output, trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"http://hl7.org.au/fhir/StructureDefinition/au-patient"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"coding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://terminology.hl7.org/CodeSystem/v2-0203"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MR"&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://hospital.local/mrn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://ns.electronichealth.net.au/id/hi/ihi/1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8003608166690503"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"use"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"official"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"family"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TEST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"given"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PATIENT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MARY"&lt;/span&gt;&lt;span class="p"&gt;]}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"birthDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1992-03-15"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;EU mode&lt;/strong&gt; (&lt;code&gt;PROFILE_REGION=eu PROFILE_COUNTRY=uk&lt;/code&gt;) produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"http://hl7.eu/fhir/base/StructureDefinition/patient-eu"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"coding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://terminology.hl7.org/CodeSystem/v2-0203"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MR"&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://hospital.local/mrn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://fhir.nhs.uk/Id/nhs-number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9000000009"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"use"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"official"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"family"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TEST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"given"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PATIENT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MARY"&lt;/span&gt;&lt;span class="p"&gt;]}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"birthDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1992-03-15"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same patient, same clinical data, different regulatory metadata. The profile URL and the national identifier system changed (&lt;code&gt;au-patient&lt;/code&gt; → &lt;code&gt;patient-eu&lt;/code&gt;, IHI → NHS Number). Name,gender, birthDate, MRN (including its &lt;code&gt;MR&lt;/code&gt; type coding) are identical.&lt;/p&gt;

&lt;p&gt;The Condition resource follows the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;AU:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://hl7.org.au/fhir/CodeSystem/icd-10-am"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"O80"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;EU:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://hl7.org/fhir/sid/icd-10"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"O80"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same ICD code, different system URI. The clinical meaning is identical; only the regulatory packaging shifts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The IPS Endpoint
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/fhir/IPS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ips-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mrn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint resolves the Patient by MRN (&lt;code&gt;422&lt;/code&gt; if the patient hasn't been admitted yet), queries HAPI FHIR for that patient's resources, then builds an IPS Composition (LOINC &lt;code&gt;60591-5&lt;/code&gt;, "Patient summary Document") with six sections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole thing is wrapped in a FHIR Document &lt;code&gt;Bundle&lt;/code&gt; (&lt;code&gt;Bundle-uv-ips&lt;/code&gt; profile), so the result is a single, self-contained document any EHDS-compliant system can consume.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Allergies&lt;/code&gt; and &lt;code&gt;Medications&lt;/code&gt; sections use &lt;code&gt;emptyReason&lt;/code&gt; (&lt;code&gt;notasked&lt;/code&gt;) 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"&lt;br&gt;
pattern — an empty section with an &lt;code&gt;emptyReason&lt;/code&gt; is more credible than fake data.&lt;/p&gt;


&lt;h2&gt;
  
  
  GDPR Consent
&lt;/h2&gt;

&lt;p&gt;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&lt;br&gt;
this as a FHIR Consent resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/fhir/Consent&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consent-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mrn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policyRule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gdpr-art-9-2-h"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provisionType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permit"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;policyRule&lt;/code&gt; field carries the GDPR basis; the pipeline recognises &lt;code&gt;gdpr-art-6-1-a&lt;/code&gt; (explicit consent, the default), &lt;code&gt;gdpr-art-9-2-h&lt;/code&gt; (health-data processing), and &lt;code&gt;gdpr-art-6-1-e&lt;/code&gt; (public interest), mapping each to a human-readable display in the Consent's &lt;code&gt;policyRule.coding&lt;/code&gt;.&lt;br&gt;
&lt;code&gt;provisionType&lt;/code&gt; is &lt;code&gt;permit&lt;/code&gt; or &lt;code&gt;deny&lt;/code&gt;, and optional &lt;code&gt;periodStart&lt;/code&gt; / &lt;code&gt;periodEnd&lt;/code&gt; set the validity window. On success it returns &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;{"consentId": "...", "correlationId": "..."}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This endpoint is &lt;strong&gt;EU-only&lt;/strong&gt;: it returns &lt;code&gt;404&lt;/code&gt; when &lt;code&gt;PROFILE_REGION=au&lt;/code&gt;, because GDPR doesn't apply in Australia. The profile configuration controls not just metadata values but also which endpoints are available.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;


&lt;h2&gt;
  
  
  Testing Across Profiles
&lt;/h2&gt;

&lt;p&gt;The existing AU tests must not break. The EU extension adds capability without changing existing behavior.&lt;/p&gt;

&lt;p&gt;The CI pipeline now runs tests in a matrix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;profile_region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;au&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;eu&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PROFILE_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ matrix.profile_region }}&lt;/span&gt;
  &lt;span class="na"&gt;PROFILE_COUNTRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ matrix.profile_region == 'eu' &amp;amp;&amp;amp; 'uk' || '' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every test runs twice - once with &lt;code&gt;PROFILE_REGION=au&lt;/code&gt; and once with &lt;code&gt;PROFILE_REGION=eu&lt;/code&gt; (&lt;code&gt;PROFILE_COUNTRY=uk&lt;/code&gt;). The AU test suite is unchanged. The EU tests add EU-specific assertions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;EU Patient resources carry the &lt;code&gt;patient-eu&lt;/code&gt; profile URL, AU ones carry &lt;code&gt;au-patient&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;EU Conditions use ICD-10 (WHO), AU Conditions use ICD-10-AM&lt;/li&gt;
&lt;li&gt;EU identifiers use the configured national system (NHS Number for &lt;code&gt;uk&lt;/code&gt;), not IHI&lt;/li&gt;
&lt;li&gt;The IPS endpoint produces a valid IPS Document Bundle&lt;/li&gt;
&lt;li&gt;The Consent endpoint returns &lt;code&gt;404&lt;/code&gt; in AU mode and &lt;code&gt;200&lt;/code&gt; in EU mode&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The suite is now &lt;strong&gt;277 tests at 92% line coverage&lt;/strong&gt; (unit + integration + a &lt;code&gt;tests/e2e/&lt;/code&gt; suite&lt;br&gt;
that drives real HL7 over MLLP against a live Docker stack and auto-skips when it isn't running).&lt;br&gt;
Adding EU capability did not change a single existing AU assertion.&lt;/p&gt;


&lt;h2&gt;
  
  
  EU Sample Messages
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;samples/&lt;/code&gt; directory now includes EU-specific HL7 messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EU samples use NHS Number identifiers (&lt;code&gt;NHS...^^^NHS^NH&lt;/code&gt;), ICD-10 (WHO) diagnosis codes, and UK-style facility naming (e.g. &lt;code&gt;ST_THOMAS&lt;/code&gt;, &lt;code&gt;LONDON_TRUST&lt;/code&gt;). 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Building the first version taught me about HL7, FHIR, and healthcare integration. Extending it to Europe taught me different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The clinical domain is more universal than the regulatory layer.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The EU FHIR ecosystem is more layered than AU.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're building a FHIR portfolio for Europe, implement IPS.&lt;/strong&gt; EHDS is built around it, MyHealth@EU exchanges it, and European health IT companies are hiring for it. A pipeline that can both &lt;em&gt;ingest&lt;/em&gt; HL7 v2 messages and &lt;em&gt;produce&lt;/em&gt; an IPS document covers both sides of the interoperability problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration-driven architecture pays compound interest.&lt;/strong&gt; The &lt;code&gt;ProfileConfig&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GDPR awareness is a soft skill encoded in code.&lt;/strong&gt; European employers want engineers who understand &lt;em&gt;why&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backward compatibility is a feature.&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The pipeline now supports two regulatory jurisdictions with a common transformation core. Next on the list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;US Core&lt;/strong&gt; - a third &lt;code&gt;PROFILE_REGION=us&lt;/code&gt; option targeting the US market (US Core IG, Argonaut profiles, USCDI)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminology validation&lt;/strong&gt; - integrating with a terminology server to validate SNOMED CT and ICD-10 codes against the correct value sets for each region&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Additional HL7 message types&lt;/strong&gt; - adding allergies (ADT^A60) and medications (RDE^O11) to populate the empty IPS sections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full IG package loading&lt;/strong&gt; - replacing placeholder StructureDefinitions with the complete HL7 EU Base/Core IG package in HAPI for meaningful &lt;code&gt;$validate&lt;/code&gt; results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full source code, documentation, and Docker Compose setup are on GitHub: &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;maternity-hl7-to-fhir-pipeline&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 2 of the Maternity HL7-to-FHIR Pipeline series. Part 1 covered the &lt;a href="https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o"&gt;initial AU pipeline architecture&lt;/a&gt;. 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.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;github.com/budityw23/maternity-hl7-to-fhir-pipeline&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;#fhir&lt;/code&gt; &lt;code&gt;#healthit&lt;/code&gt; &lt;code&gt;#architecture&lt;/code&gt; &lt;code&gt;#showdev&lt;/code&gt;&lt;/p&gt;

</description>
      <category>fhir</category>
      <category>healthit</category>
      <category>architecture</category>
      <category>healthcare</category>
    </item>
    <item>
      <title>Why I Put Mirth Connect in Front of FastAPI Instead of Parsing HL7 in Python</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:42:55 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/why-i-put-mirth-connect-in-front-of-fastapi-instead-of-parsing-hl7-in-python-jh3</link>
      <guid>https://dev.to/budiwidhiyanto/why-i-put-mirth-connect-in-front-of-fastapi-instead-of-parsing-hl7-in-python-jh3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;When I started building my &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;Maternity HL7-to-FHIR Pipeline&lt;/a&gt;, my first instinct was to do everything in Python. Parse the HL7 message, map the fields, validate the FHIR resource, persist it, all in one FastAPI service. It was clean. It was simple. It was wrong.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Some Background
&lt;/h2&gt;

&lt;p&gt;I've been building an open-source pipeline that takes HL7 v2.5 messages, the kind hospital maternity systems still send over raw TCP connections, and turns them into FHIR R4 resources with Australian and European FHIR profiles. Patient admissions, lab orders, vital signs. The sort of data that's been flowing between hospital systems in pipe-delimited text since the 1990s, now mapped into modern healthcare APIs.&lt;/p&gt;

&lt;p&gt;If you're curious about the full project, I wrote a detailed walkthrough in &lt;a href="https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o"&gt;Bridging Legacy Hospital Messages to Modern Healthcare APIs&lt;/a&gt;. That article covers the end-to-end AU pipeline architecture. The second article, &lt;a href="https://dev.to/budiwidhiyanto/one-pipeline-two-continents-adding-eu-fhir-profiles-to-an-australian-healthcare-integration-4an6"&gt;One Pipeline, Two Continents&lt;/a&gt;, covers how I extended the pipeline to support EU FHIR profiles, IPS, and GDPR Consent. Here's the short version of the architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hospital Maternity System
  |
  | HL7 v2.5 messages (ADT, ORM, ORU)
  | over MLLP (raw TCP, port 6661)
  v
┌─────────────────────────┐
│   Mirth Connect 4.5     │  Receives MLLP, parses HL7,
│   (Integration Engine)  │  extracts fields, routes
└───────────┬─────────────┘  by message type
            |
            | Clean JSON over HTTP
            v
┌─────────────────────────┐
│  FastAPI + Python 3.11  │  Pydantic validation, FHIR R4
│  (Transformation Layer) │  mapping, AU Base profiles
└───────────┬─────────────┘
            |
            | FHIR R4 resources
            | (conditional PUT/POST)
            v
┌─────────────────────────┐
│  HAPI FHIR Server 7.0.3 │  Persistence + FHIR API
└─────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three containers, one &lt;code&gt;docker compose up&lt;/code&gt;. Mirth handles the legacy protocol world, FastAPI handles the FHIR world, HAPI stores everything.&lt;/p&gt;

&lt;p&gt;But this article isn't about the what. It's about one particular &lt;em&gt;why&lt;/em&gt;: why the pipeline has two services doing the transformation work instead of one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Just Parse It in Python" Phase
&lt;/h2&gt;

&lt;p&gt;My initial architecture looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hospital System --MLLP--&amp;gt; Python Script --&amp;gt; HAPI FHIR Server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used &lt;code&gt;python-hl7&lt;/code&gt; to split messages on &lt;code&gt;|&lt;/code&gt; and count field positions. For a single &lt;code&gt;ADT^A01&lt;/code&gt; (patient admission) message, it worked fine. I could pull the patient name from &lt;code&gt;PID-5&lt;/code&gt;, the MRN from &lt;code&gt;PID-3&lt;/code&gt;, the gender from &lt;code&gt;PID-8&lt;/code&gt;, and build a FHIR Patient resource from it.&lt;/p&gt;

&lt;p&gt;Then I tried a real-ish maternity workflow (an admission, an order, and a set of vitals) and things fell apart quickly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Five Problems That Changed My Mind
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. MLLP Is Not HTTP
&lt;/h3&gt;

&lt;p&gt;Hospital systems don't send HL7 over HTTP. They send it over MLLP (Minimum Lower Layer Protocol), which is a TCP socket protocol with specific framing bytes (&lt;code&gt;\x0b&lt;/code&gt; at the start, &lt;code&gt;\x1c\x0d&lt;/code&gt; at the end). The sender expects an ACK or NACK response in HL7 format, not an HTTP status code.&lt;/p&gt;

&lt;p&gt;Building an MLLP listener in Python is &lt;em&gt;possible&lt;/em&gt;. Libraries like &lt;code&gt;aiohl7&lt;/code&gt; exist. But you're now maintaining a custom TCP server alongside your HTTP API server, handling connection pooling, timeouts, and HL7 acknowledgment generation. That's a lot of infrastructure code that has nothing to do with your actual transformation logic.&lt;/p&gt;

&lt;p&gt;Mirth Connect handles MLLP natively. You point it at a port, it listens, it parses, it ACKs. Done. One config screen, no custom code.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. HL7 Parsing Is Messier Than It Looks
&lt;/h3&gt;

&lt;p&gt;The pipe-delimited format &lt;em&gt;looks&lt;/em&gt; simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PID|1||1234567^^^RPA^MR||TEST^PATIENT^MARY^^MS||19920315|F|||14 SAMPLE ST^^SYDNEY^NSW^2000^AUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Component separators&lt;/strong&gt;: &lt;code&gt;PID-5&lt;/code&gt; is &lt;code&gt;TEST^PATIENT^MARY^^MS&lt;/code&gt;, which is family, given, middle, suffix (empty), prefix. Miss the empty suffix and your prefix ends up as the suffix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repeating fields&lt;/strong&gt;: &lt;code&gt;PID-3&lt;/code&gt; can contain multiple identifiers separated by &lt;code&gt;~&lt;/code&gt;. One might be the MRN, another the IHI (Individual Healthcare Identifier). You need to iterate and match by identifier type, not just grab the first one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape characters&lt;/strong&gt;: A patient named &lt;code&gt;O'Brien&lt;/code&gt; might appear as &lt;code&gt;O\T\Brien&lt;/code&gt; in HL7 (where &lt;code&gt;\T\&lt;/code&gt; is the subcomponent separator escape). Or it might not, depending on the sending system's configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoding characters&lt;/strong&gt;: The first field of &lt;code&gt;MSH&lt;/code&gt; (&lt;code&gt;MSH-1&lt;/code&gt;) is the field separator itself (&lt;code&gt;|&lt;/code&gt;), and &lt;code&gt;MSH-2&lt;/code&gt; defines the component, repetition, escape, and subcomponent separators. Different hospitals can (and do) use different separators.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my Python script, I was doing &lt;code&gt;segment.split('|')[5].split('^')[0]&lt;/code&gt; to get a family name. One unexpected empty field, one unexpected repeating group, and the whole positional mapping shifted silently. No error, just wrong data in the FHIR resource.&lt;/p&gt;

&lt;p&gt;Mirth Connect's HL7 parser handles all of this natively. In a Mirth transformer, I write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;familyName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PID.5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PID.5.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;givenName&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PID.5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PID.5.2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's an E4X/XML path against a parsed HL7 tree, not string splitting. It handles repeating fields, component separators, and encoding characters correctly because that's what the parser is built for. Years of edge cases baked into a mature parser versus my three-day-old string splitter.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Message Routing Is a Separate Concern
&lt;/h3&gt;

&lt;p&gt;A maternity workflow involves three message types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;What It Creates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADT^A01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Patient admitted&lt;/td&gt;
&lt;td&gt;Patient + Condition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ORM^O01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Order placed&lt;/td&gt;
&lt;td&gt;Encounter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ORU^R01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Results available&lt;/td&gt;
&lt;td&gt;Observation(s)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the "everything in Python" design, my MLLP listener would receive a raw HL7 message, I'd parse &lt;code&gt;MSH-9&lt;/code&gt; to determine the message type, then route to the right handler function. That's a message router, and I'd be building one from scratch.&lt;/p&gt;

&lt;p&gt;Mirth Connect is literally a message router. You can define a channel per message type, or (as I did) a single channel whose transformer routes by message type. Either way, each type is POSTed to a different FastAPI endpoint, and Mirth handles the dispatch. The FastAPI endpoints receive clean, typed JSON payloads. They don't even need to know HL7 exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Mirth Channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Maternity Inbound HL7&lt;/span&gt;
  &lt;span class="s"&gt;Source&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;MLLP listener on port 6661 (HL7 v2.5, auto ACK/NACK)&lt;/span&gt;
  &lt;span class="s"&gt;Preprocessor&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;generate a correlation-id (UUID) per message&lt;/span&gt;
  &lt;span class="s"&gt;Destination&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;HTTP Sender - a JavaScript transformer routes on&lt;/span&gt;
                &lt;span class="s"&gt;msg['MSH']['MSH.9']['MSH.9.1'] and POSTs to&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
                  &lt;span class="s"&gt;ADT -&amp;gt; http://fastapi:8000/fhir/Patient&lt;/span&gt;
                  &lt;span class="s"&gt;ORM -&amp;gt; http://fastapi:8000/fhir/Encounter&lt;/span&gt;
                  &lt;span class="s"&gt;ORU -&amp;gt; http://fastapi:8000/fhir/Observation/bundle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transformer extracts only the fields relevant to each message type, builds a flat JSON payload, and tags every request with an &lt;code&gt;X-Correlation-ID&lt;/code&gt; header. FastAPI receives structured data with Pydantic validation on the input shape. Clean separation.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. ACK/NACK Is Surprisingly Important
&lt;/h3&gt;

&lt;p&gt;When a hospital system sends an HL7 message, it expects an acknowledgment. If it gets an ACK (&lt;code&gt;MSA-1 = AA&lt;/code&gt;), it moves on. If it gets a NACK (&lt;code&gt;MSA-1 = AE&lt;/code&gt; or &lt;code&gt;AR&lt;/code&gt;), it may retry, queue the message, or alert an operator.&lt;/p&gt;

&lt;p&gt;In my Python approach, I'd need to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parse the incoming message&lt;/li&gt;
&lt;li&gt;Process it&lt;/li&gt;
&lt;li&gt;Build an HL7 ACK message with the correct &lt;code&gt;MSH&lt;/code&gt; fields mirrored back&lt;/li&gt;
&lt;li&gt;Send it over the same TCP socket&lt;/li&gt;
&lt;li&gt;Handle the case where processing succeeds but the ACK fails to send&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mirth Connect auto-generates ACK messages. You configure whether to ACK on receipt (before processing) or after successful processing. You can customise the ACK content if needed. Another solved problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. I Was Rebuilding an Integration Engine, Badly
&lt;/h3&gt;

&lt;p&gt;Step back and look at what I was building in Python:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A TCP/MLLP listener&lt;/li&gt;
&lt;li&gt;An HL7 v2.5 parser&lt;/li&gt;
&lt;li&gt;A message router&lt;/li&gt;
&lt;li&gt;An ACK/NACK generator&lt;/li&gt;
&lt;li&gt;Connection management and error handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's an integration engine. Mirth Connect, Rhapsody, InterSystems HealthShare. These are products that teams of engineers have built and maintained for years. I was reimplementing one in a weekend, pretending it was "simpler" because it was in Python.&lt;/p&gt;

&lt;p&gt;The honest assessment: I was spending 60-70% of my effort on plumbing (protocol handling, parsing, routing) and 30-40% on the actual value: the FHIR transformation and validation logic.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Redesigned Architecture
&lt;/h2&gt;

&lt;p&gt;Here's what I landed on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mirth Connect does what it's good at&lt;/strong&gt;: protocol handling, HL7 parsing, message routing, ACK generation. It turns messy, protocol-specific HL7 into clean HTTP + JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FastAPI does what it's good at&lt;/strong&gt;: receiving typed payloads, applying business logic (like merging systolic + diastolic OBX segments into a single FHIR Blood Pressure panel), validating FHIR resources with Pydantic models from &lt;code&gt;fhir.resources&lt;/code&gt;, and persisting to the FHIR server.&lt;/p&gt;

&lt;p&gt;The boundary between them is a simple HTTP POST with a JSON body. FastAPI doesn't know about MLLP, segment separators, or ACK messages. Mirth doesn't know about FHIR profiles, conditional PUT, or Pydantic validation. Each component is testable in isolation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Do Everything in Mirth?
&lt;/h2&gt;

&lt;p&gt;Fair question. Plenty of teams build entire HL7-to-FHIR transformations inside Mirth Connect's JavaScript engine. You can construct FHIR JSON in a Mirth transformer and POST it directly to HAPI. Why add FastAPI at all?&lt;/p&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;h3&gt;
  
  
  Type-Safe FHIR Validation
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;fhir.resources&lt;/code&gt; library gives me Pydantic models for every FHIR R4 resource type. When I build a Patient resource, the model enforces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gender&lt;/code&gt; must be one of &lt;code&gt;male&lt;/code&gt;, &lt;code&gt;female&lt;/code&gt;, &lt;code&gt;other&lt;/code&gt;, &lt;code&gt;unknown&lt;/code&gt;, not &lt;code&gt;F&lt;/code&gt; or &lt;code&gt;M&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;name&lt;/code&gt; is a list of &lt;code&gt;HumanName&lt;/code&gt; objects with specific structure&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;identifier&lt;/code&gt; requires both &lt;code&gt;system&lt;/code&gt; and &lt;code&gt;value&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;meta.profile&lt;/code&gt; must be a list of valid URIs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I typo &lt;code&gt;Observation.status&lt;/code&gt; as &lt;code&gt;"done"&lt;/code&gt; instead of &lt;code&gt;"final"&lt;/code&gt;, Pydantic raises a validation error with the exact field path before the resource ever reaches HAPI. In Mirth's JavaScript, I'd be constructing a plain JSON object with no schema enforcement. Typos become silent bugs that surface as cryptic HAPI 400 errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testability
&lt;/h3&gt;

&lt;p&gt;My FastAPI transformers have 220+ unit tests, 30+ integration tests, and 20+ end-to-end tests at 80%+ coverage. Each transformer function takes a Pydantic input model and returns a FHIR resource. I can test edge cases (empty diagnoses arrays, missing middle names, orphan blood pressure readings) in milliseconds with pytest.&lt;/p&gt;

&lt;p&gt;Testing Mirth channels is harder. You can write unit tests for Mirth's JavaScript functions, but testing the full channel behavior (source -&amp;gt; filter -&amp;gt; transformer -&amp;gt; destination) requires either Mirth's built-in test tools or a running Mirth instance. The feedback loop is slower, and CI integration is less straightforward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python Ecosystem
&lt;/h3&gt;

&lt;p&gt;For the transformation logic, Python gives me things Mirth's JavaScript doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fhir.resources&lt;/code&gt; for FHIR R4 Pydantic models&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;httpx&lt;/code&gt; for async HTTP with connection pooling&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pydantic-settings&lt;/code&gt; for typed configuration from environment variables&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ruff&lt;/code&gt; for linting, &lt;code&gt;mypy --strict&lt;/code&gt; for type checking&lt;/li&gt;
&lt;li&gt;The entire pytest ecosystem for testing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The FHIR mapping is where I add value. I want the best tools for that specific job.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tradeoff I Accepted
&lt;/h2&gt;

&lt;p&gt;This architecture has a cost: &lt;strong&gt;an extra network hop&lt;/strong&gt;. Every message goes from Mirth to FastAPI over HTTP, adding latency. In a high-throughput production system processing thousands of messages per second, this matters.&lt;/p&gt;

&lt;p&gt;For a maternity ward generating maybe 50-100 messages per hour? The latency is invisible. And the benefits (clean separation, type safety, testability, better error handling) far outweigh the cost.&lt;/p&gt;

&lt;p&gt;If throughput became a bottleneck, I'd look at gRPC between Mirth and FastAPI, or batch endpoints that accept multiple messages per request. But I'd still keep the separation. The architectural benefit is worth more than saving a few milliseconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Taught Me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use the right tool for each layer.&lt;/strong&gt; Integration engines exist because protocol handling and message parsing are genuinely hard, well-understood problems. Application frameworks exist because business logic, validation, and API design are different problems. Trying to solve both in one layer means solving neither well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boundary between components should be boring.&lt;/strong&gt; HTTP + JSON between Mirth and FastAPI is boring. That's the point. The interesting work (the FHIR mapping, the BP panel merging, the AU Base profiling) happens inside the components, not at the boundaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Simpler" isn't always fewer components.&lt;/strong&gt; My initial one-component Python approach &lt;em&gt;looked&lt;/em&gt; simpler. In practice, it was a tangled mess of protocol code, parsing code, and transformation code all in one place. Three components with clear responsibilities turned out to be simpler to build, test, and debug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The payoff shows up when you extend it.&lt;/strong&gt; Adding EU FHIR profiles on top of the existing AU pipeline touched about 2,400 lines across 49 files. Roughly 20 of those lines were in the Mirth transformer: widening national ID extraction beyond AU's IHI to also accept &lt;code&gt;NH&lt;/code&gt;, &lt;code&gt;PN&lt;/code&gt;, and &lt;code&gt;SS&lt;/code&gt;, and dropping a hardcoded country default so empty country codes fall through to the FastAPI profile. Everything else — the &lt;code&gt;ProfileConfig&lt;/code&gt; registry, EU profile URLs, IPS Composition, GDPR Consent — landed in FastAPI. The transport layer barely noticed a second jurisdiction arriving.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The separation between Mirth and FastAPI makes each of these extensions a clean addition rather than a risky refactor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;US Core profiles&lt;/strong&gt; — a third &lt;code&gt;PROFILE_REGION=us&lt;/code&gt; option. The profile configuration pattern from the EU extension means this is another &lt;code&gt;ProfileConfig&lt;/code&gt; instance and a set of profile URLs — no transformer rewrites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminology validation&lt;/strong&gt; — integrating with a terminology server (like Ontoserver or the HAPI terminology service) to validate SNOMED CT and ICD-10 codes against region-specific value sets at transformation time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Additional HL7 message types&lt;/strong&gt; — allergies (ADT^A60) and medications (RDE^O11) to populate the currently empty IPS sections. Each one is a new Mirth routing rule and a new FastAPI transformer — the pattern scales without architectural changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full IG package loading&lt;/strong&gt; — replacing the placeholder StructureDefinitions with complete AU Base and EU Core IG packages in HAPI for meaningful &lt;code&gt;$validate&lt;/code&gt; results across both regions.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;If you're evaluating integration engine versus "parse it yourself" for a health IT project, I'd like to hear how you made that call. The &lt;a href="https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o"&gt;first article in this series&lt;/a&gt; covers the full pipeline architecture, and the &lt;a href="https://dev.to/budiwidhiyanto/one-pipeline-two-continents-adding-eu-fhir-profiles-to-an-australian-healthcare-integration-4an6"&gt;second article&lt;/a&gt; shows how the separation kept adding EU FHIR profiles almost entirely inside FastAPI.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;Bring up the three containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with EU profiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PROFILE_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deploy the Mirth channel (the stock Mirth image doesn't auto-load channels, so this imports and deploys it via the REST API):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/import_channels.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now send a test HL7 message via MLLP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python scripts/mllp_send.py samples/adt_a01_normal_delivery.hl7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or skip Mirth entirely and hit FastAPI directly with JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8000/fhir/Patient &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ ... }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both paths produce the same validated, correctly profiled FHIR resources in HAPI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;github.com/budityw23/maternity-hl7-to-fhir-pipeline&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the third article in my series on the Maternity HL7-to-FHIR Pipeline. The first article, &lt;a href="https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o"&gt;Bridging Legacy Hospital Messages to Modern Healthcare APIs&lt;/a&gt;, covers the full AU pipeline architecture. The second article, &lt;a href="https://dev.to/budiwidhiyanto/one-pipeline-two-continents-adding-eu-fhir-profiles-to-an-australian-healthcare-integration-4an6"&gt;One Pipeline, Two Continents&lt;/a&gt;, covers the EU FHIR profile extension.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;#healthit&lt;/code&gt; &lt;code&gt;#architecture&lt;/code&gt; &lt;code&gt;#python&lt;/code&gt; &lt;code&gt;#showdev&lt;/code&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>fastapi</category>
      <category>python</category>
    </item>
    <item>
      <title>Maternity HL7-to-FHIR Pipeline: Bridging Legacy Hospital Messages to Modern Healthcare APIs</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Tue, 21 Jul 2026 02:07:48 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o</link>
      <guid>https://dev.to/budiwidhiyanto/maternity-hl7-to-fhir-pipeline-bridging-legacy-hospital-messages-to-modern-healthcare-apis-m1o</guid>
      <description>&lt;h2&gt;
  
  
  How It Started
&lt;/h2&gt;

&lt;p&gt;A few months ago, I was browsing remote FHIR engineer roles in Australia. Every single job description mentioned the same thing: &lt;em&gt;experience integrating HL7 v2 with FHIR R4&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This makes sense. Australian hospitals - like most hospitals worldwide - still run on HL7 v2. Patient admissions, lab results, orders - they all flow as pipe-delimited messages over MLLP. But the direction is clear: the Australian Digital Health Agency (ADHA) is pushing hard toward FHIR R4 through the Sparked FHIR Accelerator and the AU Core Framework. Somebody has to build the bridge between these two worlds.&lt;/p&gt;

&lt;p&gt;I decided to build one myself: a working pipeline that takes real HL7 v2 maternity messages and transforms them into valid, AU-profiled FHIR R4 resources. Not a toy. Not a tutorial. A real integration that I could show in interviews and say: &lt;em&gt;"I built this. Here's how it works. Here's where it breaks."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;budityw23/maternity-hl7-to-fhir-pipeline&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This article walks through how I designed it - including the mistakes I made along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Initial (Naive) Approach
&lt;/h2&gt;

&lt;p&gt;My first instinct was simple. HL7 v2 comes in, I parse it in Python, I build a FHIR JSON, I POST it to a FHIR server. Done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hospital Maternity System
  |
  |  HL7 v2 messages (MLLP)
  v
Python Script
  |  -&amp;gt; parse HL7
  |  -&amp;gt; build FHIR JSON manually
  |  -&amp;gt; POST to FHIR server
  v
HAPI FHIR Server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote a quick script. It worked for a single &lt;code&gt;ADT^A01&lt;/code&gt; (patient admission) message. I felt good about it. Then the problems started:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No MLLP handling&lt;/strong&gt;: My script was reading messages from a file. Real hospital systems don't send files - they send HL7 over MLLP (a TCP-based protocol with specific framing). My script had no way to receive live messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brittle parsing&lt;/strong&gt;: I was splitting on &lt;code&gt;|&lt;/code&gt; and counting positions. One unexpected empty field and the whole mapping shifted. A message with a maiden name in &lt;code&gt;PID-6&lt;/code&gt; but nothing in &lt;code&gt;PID-5.4&lt;/code&gt; (suffix) would silently put the wrong data in the wrong FHIR field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No validation&lt;/strong&gt;: I was constructing FHIR JSON by hand as Python dicts. Typo in a field name? Missing a required element? The script happily sent invalid resources to the FHIR server, which sometimes accepted them and sometimes threw cryptic errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No multi-resource linking&lt;/strong&gt;: A maternity visit involves a Patient, an Encounter, Observations (blood pressure, weight, fetal heartbeat), and a Condition (pregnancy diagnosis). These resources need to reference each other. My script created them independently with no linking - the Observation had no idea which Encounter it belonged to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No error trail&lt;/strong&gt;: If the Patient was created successfully but the Observation failed, I had no record of what went wrong, no correlation between the original message and the failure, and no way to replay the failed message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was building for the happy path again - the same mistake I described in my &lt;a href="https://dev.to/budiwidhiyanto/national-vaccine-appointment-administration-system-303o"&gt;vaccine appointment system article&lt;/a&gt;. One clean message in, one clean resource out. But healthcare data is never clean.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rethinking the Architecture
&lt;/h2&gt;

&lt;p&gt;The core insight was: &lt;strong&gt;this is not a single transformation. It's a pipeline with distinct responsibilities.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Receiving MLLP messages is a different concern from parsing HL7. Parsing is different from mapping. Mapping is different from FHIR validation. Validation is different from persistence. Each stage can fail independently, and each needs its own error handling.&lt;/p&gt;

&lt;p&gt;I also realized that an integration engine like Mirth Connect already solves the hardest part - MLLP reception, HL7 parsing, and message routing. Fighting that battle in raw Python was wasting time on a solved problem.&lt;/p&gt;

&lt;p&gt;Here's the redesigned architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hospital Maternity System
  |
  |  HL7 v2.5 messages over MLLP:
  |    ADT^A01  -&amp;gt; patient admitted for antenatal care
  |    ORM^O01  -&amp;gt; antenatal checkup ordered
  |    ORU^R01  -&amp;gt; checkup results (BP, weight, fetal heartbeat)
  v
Mirth Connect 4.5 (port 6661)
  |  -&amp;gt; receives MLLP, parses HL7 natively
  |  -&amp;gt; extracts key segments (PID, PV1, OBX, DG1, OBR)
  |  -&amp;gt; builds structured JSON payload
  |  -&amp;gt; routes by message type to correct endpoint
  v
Python 3.11 + FastAPI (port 8000)
  |  -&amp;gt; receives typed JSON from Mirth
  |  -&amp;gt; maps fields to FHIR R4 with AU Base profiles
  |  -&amp;gt; validates via fhir.resources (Pydantic models)
  |  -&amp;gt; conditional PUT/POST to HAPI FHIR (idempotent)
  |  -&amp;gt; structured JSON logging with correlation ID
  v
HAPI FHIR Server v7.0.3 (port 8080)
  |  -&amp;gt; stores Patient, Condition, Observation, Encounter
  |  -&amp;gt; supports $validate for profile conformance
  |  -&amp;gt; serves FHIR REST API
  v
Accessible via standard FHIR queries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key difference: &lt;strong&gt;every component does one thing well&lt;/strong&gt;, and failure at any stage doesn't corrupt the others.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Improved Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Mirth Connect: The HL7 Gatekeeper
&lt;/h3&gt;

&lt;p&gt;Mirth Connect listens on MLLP port 6661 and handles the protocol-level complexity that I was trying to reinvent in Python. It natively understands HL7 v2.5 segment structure, so I can reference &lt;code&gt;msg['PID']['PID.5']['PID.5.1']&lt;/code&gt; directly in a JavaScript transformer - no string splitting, no position counting.&lt;/p&gt;

&lt;p&gt;Mirth does three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Receives and parses&lt;/strong&gt;: Handles MLLP framing (the &lt;code&gt;\x0b&lt;/code&gt; header and &lt;code&gt;\x1c\x0d&lt;/code&gt; trailer), parses segments, and sends ACK/NACK responses back to the sender.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extracts and restructures&lt;/strong&gt;: A JavaScript transformer pulls the fields I need and builds a clean JSON payload. This is where I handle HL7 &lt;em&gt;structural&lt;/em&gt; quirks - pulling the MRN and IHI out of the repeating &lt;code&gt;PID-3&lt;/code&gt; field by identifier type (&lt;code&gt;MR&lt;/code&gt; vs &lt;code&gt;NI&lt;/code&gt;), taking the first &lt;code&gt;PID-13&lt;/code&gt; repetition for the phone number, and normalising the country code (&lt;code&gt;AUS&lt;/code&gt; -&amp;gt; &lt;code&gt;AU&lt;/code&gt;). One thing that tripped me up here: Mirth parses each field into components, so a field node's &lt;code&gt;toString()&lt;/code&gt; returns XML markup, not the value - you have to drill to the &lt;code&gt;.1&lt;/code&gt; subcomponent (&lt;code&gt;msg['PID']['PID.8']['PID.8.1']&lt;/code&gt;) to get &lt;code&gt;F&lt;/code&gt;. &lt;em&gt;Terminology&lt;/em&gt; mapping - &lt;code&gt;F&lt;/code&gt;/&lt;code&gt;M&lt;/code&gt;/&lt;code&gt;U&lt;/code&gt; sex codes to &lt;code&gt;female&lt;/code&gt;/&lt;code&gt;male&lt;/code&gt;/&lt;code&gt;unknown&lt;/code&gt;, ICD-10-AM to SNOMED - is deliberately left to the FastAPI layer, where the Pydantic models validate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routes by message type&lt;/strong&gt;: &lt;code&gt;ADT^A01&lt;/code&gt; goes to &lt;code&gt;/fhir/Patient&lt;/code&gt;, &lt;code&gt;ORM^O01&lt;/code&gt; goes to &lt;code&gt;/fhir/Encounter&lt;/code&gt;, &lt;code&gt;ORU^R01&lt;/code&gt; goes to &lt;code&gt;/fhir/Observation/bundle&lt;/code&gt;. Each endpoint in FastAPI knows exactly what shape of data to expect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why not do everything in Mirth?&lt;/strong&gt; You can - plenty of teams build entire FHIR transformations in Mirth's JavaScript engine. But I wanted the FHIR validation layer in Python using &lt;code&gt;fhir.resources&lt;/code&gt;, which gives me Pydantic-based validation against the full FHIR R4 spec. If I accidentally set &lt;code&gt;Observation.status&lt;/code&gt; to &lt;code&gt;"done"&lt;/code&gt; instead of &lt;code&gt;"final"&lt;/code&gt;, Pydantic catches it before it ever reaches the FHIR server. You don't get that level of type safety in Mirth's JavaScript.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Mapping Layer: Where the Real Work Happens
&lt;/h3&gt;

&lt;p&gt;This is the heart of the pipeline. Each HL7 message type maps to one or more FHIR resources:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ADT^A01 (Patient Admission)&lt;/strong&gt; produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Patient&lt;/strong&gt; - from &lt;code&gt;PID&lt;/code&gt; segment (name, DOB, sex, address, IHI identifier)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Condition&lt;/strong&gt; - from &lt;code&gt;DG1&lt;/code&gt; segment (pregnancy diagnosis, ICD-10-AM coded)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;ORM^O01 (Order)&lt;/strong&gt; produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encounter&lt;/strong&gt; - from &lt;code&gt;PV1&lt;/code&gt; segment (visit number, class, admission date, location, attending doctor)&lt;/li&gt;
&lt;li&gt;References back to existing Patient (looked up by MRN)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;ORU^R01 (Observation Result)&lt;/strong&gt; produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Observation&lt;/strong&gt; - from &lt;code&gt;OBX&lt;/code&gt; segments (vital signs: blood pressure, weight, fetal heartbeat)&lt;/li&gt;
&lt;li&gt;References back to existing Patient (by MRN) and Encounter (by visit number)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's where the complexity lives. A simple mapping like &lt;code&gt;PID-5.1&lt;/code&gt; -&amp;gt; &lt;code&gt;Patient.name[0].family&lt;/code&gt; is straightforward. But consider blood pressure. In HL7 v2, systolic and diastolic come as two separate &lt;code&gt;OBX&lt;/code&gt; segments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OBX|1|NM|8480-6^Systolic BP^LN||120|mm[Hg]|90-120|N|||F
OBX|2|NM|8462-4^Diastolic BP^LN||80|mm[Hg]|60-80|N|||F
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In FHIR R4, blood pressure is a single &lt;code&gt;Observation&lt;/code&gt; resource with LOINC code &lt;code&gt;85354-9&lt;/code&gt; (Blood pressure panel) containing two &lt;code&gt;component[]&lt;/code&gt; entries - one for systolic, one for diastolic. The transformer detects consecutive BP-related OBX segments (codes &lt;code&gt;8480-6&lt;/code&gt; and &lt;code&gt;8462-4&lt;/code&gt;), merges them into a single panel Observation with the AU Base blood pressure profile (&lt;code&gt;au-vitalsigns-bloodpressure&lt;/code&gt;), and applies the most conservative status and worst-case interpretation across both readings. Orphan systolic or diastolic readings (without their pair) fall back to individual Observations. This is the kind of domain logic that makes healthcare integration genuinely hard - it's not just field-to-field mapping.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Australian Localisation
&lt;/h3&gt;

&lt;p&gt;Since this targets the AU market, the FHIR resources conform to &lt;strong&gt;AU Base profiles&lt;/strong&gt; on all resource types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Patient&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://hl7.org.au/fhir/StructureDefinition/au-patient&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Condition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://hl7.org.au/fhir/StructureDefinition/au-condition&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encounter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://hl7.org.au/fhir/StructureDefinition/au-encounter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observation (BP)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://hl7.org.au/fhir/StructureDefinition/au-vitalsigns-bloodpressure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Additional AU-specific details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Patient identifiers include the &lt;strong&gt;IHI&lt;/strong&gt; (Individual Healthcare Identifier) with system URI &lt;code&gt;http://ns.electronichealth.net.au/id/hi/ihi/1.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Diagnosis coding uses &lt;strong&gt;ICD-10-AM&lt;/strong&gt; (Australian Modification) rather than plain ICD-10&lt;/li&gt;
&lt;li&gt;Clinical terminology uses &lt;strong&gt;SNOMED CT-AU&lt;/strong&gt; where applicable&lt;/li&gt;
&lt;li&gt;Addresses use 4-digit Australian postcodes and state codes (NSW, VIC, QLD, etc.)&lt;/li&gt;
&lt;li&gt;All FHIR datetimes include the &lt;code&gt;+10:00&lt;/code&gt; AEST timezone offset&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't just cosmetic. AU Base profiles have specific cardinality and terminology constraints. An &lt;code&gt;au-patient&lt;/code&gt; resource without a valid identifier type code will fail validation against the profile. Getting this right demonstrates that I understand the difference between &lt;em&gt;generic FHIR&lt;/em&gt; and &lt;em&gt;FHIR as it's actually used in Australian healthcare&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Idempotency: Handling Duplicate Messages
&lt;/h3&gt;

&lt;p&gt;Hospital systems sometimes send the same message twice - network retries, interface engine restarts, manual resends. The pipeline handles this through conditional operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Patient&lt;/strong&gt;: Conditional PUT using &lt;code&gt;?identifier=http://hospital.local/mrn|{MRN}&lt;/code&gt; - re-sending the same ADT^A01 updates the existing Patient rather than creating a duplicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encounter&lt;/strong&gt;: Conditional PUT using the visit number identifier - same ORM^O01 twice produces one Encounter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Condition and Observation&lt;/strong&gt;: Created via POST (new resource each time), but because they reference the idempotent Patient/Encounter, the referential links remain consistent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This approach trades off perfect idempotency on Conditions/Observations for simplicity. In a production system, you might add conditional logic based on a combination of patient reference, code, and effective date. For a portfolio project, the Patient/Encounter idempotency demonstrates the pattern clearly.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Failure Scenarios and Observability
&lt;/h3&gt;

&lt;p&gt;Just like with the vaccine system, I forced myself to think about what breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Malformed HL7 message&lt;/strong&gt;: Mirth rejects it at the protocol level and sends a NACK. The message never reaches FastAPI. Logged for review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing required fields&lt;/strong&gt;: FastAPI's Pydantic models catch empty MRNs, invalid gender codes, and missing observation codes at the payload boundary. Returns RFC 7807 &lt;code&gt;application/problem+json&lt;/code&gt; with a 422 status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FHIR validation failure&lt;/strong&gt;: The &lt;code&gt;fhir.resources&lt;/code&gt; Pydantic model rejects the resource - maybe &lt;code&gt;Observation.status&lt;/code&gt; has an invalid value, or a required CodeableConcept is missing. The error includes exactly which field failed and why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patient not found for downstream messages&lt;/strong&gt;: An ORM^O01 or ORU^R01 arrives before the ADT^A01. The pipeline looks up the Patient by MRN, fails to find it, and returns a 422: "Patient not found for MRN=X. Send ADT^A01 first."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HAPI FHIR rejects the resource&lt;/strong&gt;: The FastAPI layer catches the HTTP error, writes the original payload to a dead-letter file in &lt;code&gt;./deadletter/&lt;/code&gt; (with the correlation ID as filename prefix), and returns a 502 with the HAPI error details.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Correlation ID&lt;/strong&gt; ties everything together. Every message gets a UUID (either from Mirth via &lt;code&gt;X-Correlation-ID&lt;/code&gt; header, or auto-generated by middleware). The correlation ID appears in every structured JSON log line, in error responses, in dead-letter filenames, and in response headers. When something fails at 3am, you grep one ID and see the entire message lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured JSON logging&lt;/strong&gt; ensures every log line is machine-parseable - timestamp, level, logger name, message, and correlation ID in every entry. Noisy libraries (uvicorn access logs, httpx, httpcore) are suppressed. No PHI (names, dates of birth) in logs - only identifiers like MRN and correlation ID at INFO level.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Server-Side Validation
&lt;/h3&gt;

&lt;p&gt;Client-side validation via &lt;code&gt;fhir.resources&lt;/code&gt; catches structural errors - wrong field types, missing required elements, invalid enum values. But it can't validate against AU Base profile constraints. For that, the pipeline integrates with HAPI's &lt;code&gt;$validate&lt;/code&gt; operation.&lt;/p&gt;

&lt;p&gt;A standalone endpoint (&lt;code&gt;POST /fhir/validate/{resource_type}&lt;/code&gt;) accepts any FHIR resource and returns the raw &lt;code&gt;OperationOutcome&lt;/code&gt; from HAPI - useful for testing profile conformance during development.&lt;/p&gt;

&lt;p&gt;For production use, a &lt;code&gt;VALIDATE_BEFORE_PERSIST&lt;/code&gt; config flag enables pre-persist validation: every resource is validated via &lt;code&gt;$validate&lt;/code&gt; before being written to HAPI. If validation finds errors or fatal issues, the persist is blocked and a 422 is returned with diagnostic messages. This is off by default (the demo seeds placeholder StructureDefinitions, not the full AU Base IG), but the plumbing is in place for when a real terminology server and IG package are loaded.&lt;/p&gt;




&lt;h2&gt;
  
  
  System Components
&lt;/h2&gt;

&lt;p&gt;Here's the full component view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----------------------------------------------------------+
|                    Docker Compose                        |
|                    (maternity-net)                       |
|                                                          |
|  +-------------+    +--------------+    +------------+  |
|  |   Mirth      |    |   FastAPI     |    |  HAPI FHIR |  |
|  |  Connect     |---&amp;gt;|   Python      |---&amp;gt;|   Server   |  |
|  |  4.5         |    |   3.11+       |    |  v7.0.3    |  |
|  |              |    |              |    |            |  |
|  |  Port: 6661  |    |  Port: 8000  |    | Port: 8080 |  |
|  |  (MLLP)      |    |  (HTTP)      |    | (FHIR REST)|  |
|  +-------------+    +--------------+    +------------+  |
|                                                          |
|  Services:                                               |
|  - mirth       (nextgenhealthcare/connect:4.5)          |
|  - fastapi     (python:3.11-slim + FastAPI + uvicorn)   |
|  - hapi        (hapiproject/hapi:v7.0.3, H2 embedded)  |
+----------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mirth Connect 4.5&lt;/strong&gt; - The integration engine. Handles MLLP, parses HL7, routes by message type, sends structured JSON downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python FastAPI&lt;/strong&gt; - The transformation and validation layer. Endpoints for Patient (&lt;code&gt;/fhir/Patient&lt;/code&gt;), Encounter (&lt;code&gt;/fhir/Encounter&lt;/code&gt;), Observations (&lt;code&gt;/fhir/Observation/bundle&lt;/code&gt;), validation (&lt;code&gt;/fhir/validate/{resource_type}&lt;/code&gt;), and health (&lt;code&gt;/health&lt;/code&gt;). Dedicated transformer modules (&lt;code&gt;patient.py&lt;/code&gt;, &lt;code&gt;condition.py&lt;/code&gt;, &lt;code&gt;observation.py&lt;/code&gt;, &lt;code&gt;encounter.py&lt;/code&gt;). Uses &lt;code&gt;fhir.resources&lt;/code&gt; for Pydantic-based FHIR R4 validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HAPI FHIR Server v7.0.3&lt;/strong&gt; - The FHIR repository. Stores resources, serves the REST API, supports &lt;code&gt;$validate&lt;/code&gt; for profile conformance. Uses embedded H2 database for the demo (Postgres swap documented for production).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs with &lt;code&gt;docker compose up&lt;/code&gt;. One command, full pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Repository Structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;maternity-hl7-to-fhir/
|-- docker-compose.yml
|-- fastapi/
|   |-- Dockerfile
|   |-- pyproject.toml
|   `-- app/
|       |-- main.py                   # FastAPI routes + lifespan
|       |-- config.py                 # pydantic-settings (env vars)
|       |-- errors.py                 # RFC 7807 problem+json
|       |-- logging_setup.py          # Structured JSON logging
|       |-- middleware.py             # X-Correlation-ID middleware
|       |-- models/                   # Pydantic payload models
|       |   |-- adt_payload.py        #   ADT^A01 input
|       |   |-- orm_payload.py        #   ORM^O01 input
|       |   `-- oru_payload.py        #   ORU^R01 input
|       |-- transformers/             # HL7 -&amp;gt; FHIR mapping
|       |   |-- patient.py            #   -&amp;gt; Patient resource
|       |   |-- condition.py          #   -&amp;gt; Condition resource
|       |   |-- encounter.py          #   -&amp;gt; Encounter resource
|       |   `-- observation.py        #   -&amp;gt; Observation (+ BP panel)
|       |-- clients/                  # HAPI FHIR client
|       |   |-- hapi_client.py        #   Upsert/create + $validate + profile seeding
|       |   |-- patient_resolver.py   #   MRN -&amp;gt; Patient ID lookup
|       |   `-- encounter_resolver.py #   Visit number -&amp;gt; Encounter ID lookup
|       `-- valuesets/                # Terminology mappings
|           |-- hl7_to_fhir_gender.py
|           |-- hl7_to_fhir_encounter.py
|           `-- hl7_to_fhir_observation.py
|-- hapi/
|   |-- Dockerfile
|   |-- application.yaml
|   `-- HealthCheck.java
|-- mirth/
|   |-- channels/
|   `-- code_templates/
|-- samples/                          # Synthetic HL7 messages
|-- scripts/
|   |-- mllp_send.py                  # MLLP test client
|   |-- demo.sh                       # Interactive walkthrough
|   `-- reset.sh                      # Full reset (down -v + clean)
|-- tests/
|   |-- unit/                         # 157 unit tests
|   `-- integration/                  # 20 integration tests
|-- deadletter/                       # Failed message store (gitignored)
|-- logs/                             # Runtime logs (gitignored)
`-- .github/workflows/ci.yml          # CI: lint + type check + test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Building this project taught me things I couldn't have learned from reading the FHIR spec alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HL7 v2 is deceptively simple.&lt;/strong&gt; The pipe-delimited format looks easy to parse until you encounter repeating fields, component separators, escape characters, and the fact that different hospitals implement the same message type differently. An integration engine like Mirth saves enormous time here - it's purpose-built for this chaos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FHIR validation is your safety net, not your enemy.&lt;/strong&gt; My naive approach skipped validation because it felt like extra work. In practice, the Pydantic models from &lt;code&gt;fhir.resources&lt;/code&gt; caught errors that would have taken hours to debug at the FHIR server level. A missing &lt;code&gt;Observation.code.coding[0].system&lt;/code&gt;? Pydantic tells you immediately. HAPI FHIR gives you a generic 400. Adding server-side &lt;code&gt;$validate&lt;/code&gt; on top catches profile conformance issues that client-side validation can't - like missing AU Base constraints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency is not optional in healthcare integration.&lt;/strong&gt; Hospital systems send duplicate messages routinely. Conditional PUT with identifier queries means the second submission of the same ADT^A01 updates the existing Patient instead of creating a duplicate. This is a simple pattern that eliminates an entire class of production incidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correlation IDs are the single most useful observability investment.&lt;/strong&gt; One UUID, generated at the entry point, propagated through every log line, every error response, every dead-letter file. When a message fails at 3am, you grep one string and see everything that happened. Without it, you're correlating timestamps across multiple services and hoping the clocks are synchronized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AU localisation is what separates a tutorial from a portfolio project.&lt;/strong&gt; Anyone can map &lt;code&gt;PID-5&lt;/code&gt; to &lt;code&gt;Patient.name&lt;/code&gt;. Knowing that Australian systems use IHI identifiers, ICD-10-AM coding, AU Base profile URLs, and SNOMED CT-AU - and encoding all of that correctly in the FHIR resources - shows real domain expertise. In interviews, this is what gets follow-up questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start with failure scenarios, not the happy path.&lt;/strong&gt; This is the same lesson from my vaccine appointment system design, and it applies everywhere. The first question to ask about any integration is not "how does it work?" but "what happens when it breaks?" RFC 7807 error responses, dead-letter queues, and structured logging aren't glamorous features - but they're the difference between "it works in a demo" and "I'd trust this in production."&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing and Quality
&lt;/h2&gt;

&lt;p&gt;The pipeline has 177 automated tests (157 unit + 20 integration) at 90% line coverage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit tests&lt;/strong&gt; cover every transformer, valueset mapping, payload validator, error handler, logging formatter, and middleware component in isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration tests&lt;/strong&gt; exercise the full FastAPI ASGI stack (routes, middleware, error handlers) with mocked HAPI responses using &lt;code&gt;respx&lt;/code&gt;. Tests cover happy paths, validation failures, missing upstream resources, and correlation ID propagation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI pipeline&lt;/strong&gt; runs ruff (linting), mypy --strict (type checking), and pytest with a coverage gate of 80% on every push.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;This pipeline covers 4 FHIR resources, 3 HL7 message types, and AU Base profile conformance - deliberately scoped to be buildable as a portfolio project while demonstrating real integration patterns. Extensions I'm considering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full AU Core IG loading&lt;/strong&gt; - replacing the placeholder StructureDefinitions with the complete AU Base Implementation Guide package for meaningful &lt;code&gt;$validate&lt;/code&gt; results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminology validation&lt;/strong&gt; - integrating with a FHIR terminology server (like Ontoserver) to validate SNOMED CT-AU and LOINC codes at transformation time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring dashboard&lt;/strong&gt; - a simple frontend showing message throughput, transformation success/failure rates, and recent errors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Additional message types&lt;/strong&gt; - ORU^R30 (unsolicited lab), ADT^A08 (update patient), ADT^A03 (discharge)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full source code, documentation, and Docker Compose setup are on GitHub: &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;maternity-hl7-to-fhir-pipeline&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're working on HL7-to-FHIR integrations or preparing for health IT interviews, I'd like to hear what challenges you've run into. You might also find my earlier article on the &lt;a href="https://dev.to/budiwidhiyanto/national-vaccine-appointment-administration-system-303o"&gt;National Vaccine Appointment &amp;amp; Administration System&lt;/a&gt; useful - it covers similar design thinking around failure handling and rollback patterns.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/budityw23/maternity-hl7-to-fhir-pipeline" rel="noopener noreferrer"&gt;github.com/budityw23/maternity-hl7-to-fhir-pipeline&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;#fhir&lt;/code&gt; &lt;code&gt;#healthit&lt;/code&gt; &lt;code&gt;#hl7&lt;/code&gt; &lt;code&gt;#architecture&lt;/code&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>dataengineering</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Our GCP daily cost dropped 80% in six weeks, here is every change we made</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Wed, 08 Jul 2026 14:09:43 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/our-gcp-daily-cost-dropped-80-in-six-weeks-here-is-every-change-we-made-3p20</link>
      <guid>https://dev.to/budiwidhiyanto/our-gcp-daily-cost-dropped-80-in-six-weeks-here-is-every-change-we-made-3p20</guid>
      <description>&lt;p&gt;When our project manager forwarded the May 2026 invoice, the number had tripled from where it was two months earlier. That was the monthly GCP bill for two regional deployments of the same platform. We had seen it climb through April, but this was the first time the daily cost chart showed no sign of flattening. The peak day had already hit 3x our normal baseline.&lt;/p&gt;

&lt;p&gt;Nothing was broken. The converter was processing records and the dashboards were up. The system was doing exactly what it was built to do. Together with Mas &lt;a class="mentioned-user" href="https://dev.to/chiqors"&gt;@chiqors&lt;/a&gt; , our devops teammate, we pulled up the billing console and started figuring out whether the system actually needed to cost that much.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we run
&lt;/h2&gt;

&lt;p&gt;We operate a federated health data platform deployed in two districts in Indonesia. The platform collects data from twelve different source systems: electronic medical records from hospitals, nutrition tracking from community health workers, TB surveillance from the national system, immunization registries, midwife apps, and several others. All of that gets converted into FHIR R4 resources and stored in a central FHIR datastore per district.&lt;/p&gt;

&lt;p&gt;Each district runs its own GCP project. The converter runs on Cloud Run alongside about 30 other microservices per project. BigQuery handles analytics and dashboard queries. Cloud Healthcare API is the FHIR datastore. Cloud Scheduler triggers the converter and about 50 scheduled queries across both projects. Then there is Secret Manager for credentials and checkpoints, Cloud SQL for application databases, a handful of Compute Engine VMs for data science and tooling, and Cloud Logging collecting output from everything.&lt;/p&gt;

&lt;p&gt;At peak, we were processing over 500,000 records per day across 80 source tables. Two projects, two districts, running 24/7. That is a lot of moving parts generating cost, and before the spike, we had never looked carefully at which parts were generating how much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the data to find the cost drivers
&lt;/h2&gt;

&lt;p&gt;We did not start by guessing. We set up the billing export queries, pulled Cloud Run utilization data, and looked at &lt;code&gt;INFORMATION_SCHEMA.JOBS_BY_PROJECT&lt;/code&gt; to understand where the money was actually going:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creation_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_bytes_billed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;POW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tib_billed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`region-asia-southeast2.INFORMATION_SCHEMA.JOBS_BY_PROJECT`&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;creation_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMP_SUB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This query gave us daily scan volume in TiB, which we could plot against the billing export to see exactly when costs started climbing and why. BigQuery was roughly 36% of the total bill. Cloud Run was another 21%. But the percentages alone did not explain the spike.&lt;/p&gt;

&lt;p&gt;What the data showed was a compounding pattern. Several upstream data providers had pushed large historical backfills, which is a normal part of operating a health data platform. Migrations happen. New data sources come online. One upstream migration brought in 427,000 rows in one week (43x the normal volume). Another pushed 2.27 million rows in a single week.&lt;/p&gt;

&lt;p&gt;The system handled it correctly. The converter processed the records, and the FHIR store grew. The main resource table went from 150 GiB to 201 GiB. But a deduplication scheduler was scanning the full FHIR store three times per hour. At 150 GiB, each scan cost very little. At 201 GiB and growing, the same scheduler became the single most expensive query in the system. The scan volume data on one project tells the story clearly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;Daily BQ scan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Week 1&lt;/td&gt;
&lt;td&gt;2.36 TiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Week 4&lt;/td&gt;
&lt;td&gt;4.0 TiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Week 5&lt;/td&gt;
&lt;td&gt;7.37 TiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Week 6&lt;/td&gt;
&lt;td&gt;9.26 TiB (peak)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After scheduler pauses&lt;/td&gt;
&lt;td&gt;1.34 TiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After all fixes&lt;/td&gt;
&lt;td&gt;0.47 TiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From 2.36 TiB to 9.26 TiB in five weeks, without anyone changing a single scheduler configuration. The data volume changed, and the cost followed. That same pattern was happening on the other project too, going from 1.1 TiB to 2.85 TiB daily, though the absolute numbers were smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data told us to do
&lt;/h2&gt;

&lt;p&gt;Once we had the billing and utilization data in front of us, we split the work across BigQuery, Cloud Run, Compute Engine, and the smaller services. Each fix started with a specific metric.&lt;/p&gt;

&lt;h3&gt;
  
  
  BigQuery: query analysis pointed to the converter polling pattern
&lt;/h3&gt;

&lt;p&gt;We used &lt;code&gt;INFORMATION_SCHEMA.JOBS_BY_PROJECT&lt;/code&gt; to rank queries by bytes billed. The top consumer was the converter's polling query, which checks for unprocessed records using an anti-join against a multi-million-row ledger table. Every poll for every table scanned the entire ledger.&lt;/p&gt;

&lt;p&gt;The scan data told us a &lt;code&gt;CLUSTER BY table_name&lt;/code&gt; on the ledger, combined with adding a &lt;code&gt;table_name&lt;/code&gt; filter to the JOIN clause, would let BigQuery prune 90% of the scan. The change was small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Before: scans entire ledger on every poll&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;processing_report&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="c1"&gt;-- After: BigQuery prunes to just this table's slice (~94% fewer bytes)&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;processing_report&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'source_table_name'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After deploying that change, per-query scan dropped from 531 MiB to 53 MiB.&lt;/p&gt;

&lt;p&gt;We then looked at the pattern more carefully. The data showed that on a typical hourly run, most tables have zero new rows. We built a high-water mark routing layer: if a table has no backlog, use a cheap timestamp query instead of the full anti-join.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_unprocessed_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Routes to HWM or anti-join based on backlog check.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_HWM_READY_TABLES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;backlog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_check_backlog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;backlog&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;_HWM_READY_TABLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_get_unprocessed_antijoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_get_unprocessed_hwm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HWM path runs a simple &lt;code&gt;WHERE timestamp &amp;gt; last_processed&lt;/code&gt; instead of joining millions of rows. This brought the converter's BigQuery cost down by over 95%.&lt;/p&gt;

&lt;h3&gt;
  
  
  BigQuery: evaluating Enterprise Edition after reducing query volume
&lt;/h3&gt;

&lt;p&gt;Both projects were running BigQuery Enterprise Edition with autoscaling slots. Enterprise Edition pricing is based on slot usage, and when scan volume was high, the autoscaler was spinning up slots constantly. This was the single largest line item on the bill.&lt;/p&gt;

&lt;p&gt;After deploying the clustering fix, the high-water mark routing, and pausing the expensive schedulers, our actual BigQuery compute usage dropped by over 90%. We looked at the numbers and asked whether Enterprise Edition still made sense. The autoscaling slots were now mostly idle, but we were still paying for the baseline. We deactivated Enterprise Edition on both projects and fell back to on-demand pricing.&lt;/p&gt;

&lt;p&gt;The result was immediate. The projected BigQuery cost on on-demand is about 4% of what we were paying with Enterprise Edition. The query volume reductions we had already made meant that on-demand pricing was now far cheaper than even the minimum Enterprise Edition commitment. This single change, deactivating a pricing model that no longer matched our usage, was the largest cost reduction in the entire effort. There is a risk: if scan volume spikes again (another large backfill, or re-enabling the dedup scheduler), on-demand could become more expensive than Enterprise Edition was. We are monitoring on-demand costs weekly to catch that before it happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scheduled query audit: frequency, dead joins, and dead code
&lt;/h3&gt;

&lt;p&gt;We listed every scheduled query and its run frequency, then compared it against when the output data was actually consumed. The gaps were wide.&lt;/p&gt;

&lt;p&gt;One dedup scheduler ran a UNION ALL full-scan of every FHIR resource table three times per hour. One scheduler, hundreds of dollars per month. We checked its downstream consumers and found it could safely be paused while we work on a better dedup approach.&lt;/p&gt;

&lt;p&gt;We found schedulers with a &lt;code&gt;dev-&lt;/code&gt; prefix running in the production project. One ran every 7 minutes, another ran every hour. They did not produce errors, so they never surfaced in monitoring. They only showed up when we audited the scheduler list against actual usage.&lt;/p&gt;

&lt;p&gt;Export queries ran every 12 hours but were consumed once a day. Dashboard refresh queries ran every 5 hours but updated a table used in a daily report. We matched frequency to actual consumption patterns across both projects and paused or reduced 26+ schedulers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rewriting scheduled query SQL: the dead JOIN that ate 96% of slot-time
&lt;/h3&gt;

&lt;p&gt;Frequency was not the only problem. Some scheduled queries were expensive because the SQL itself was inefficient.&lt;/p&gt;

&lt;p&gt;Two scheduled queries that build a maternal health dashboard table started timing out at 6 hours. The SQL had not changed. The data had. Both queries contained a &lt;code&gt;LEFT JOIN&lt;/code&gt; against a clinical orders table. The joined alias was referenced only in the ON clause, never in any SELECT or WHERE. It did nothing except multiply rows. The orders table stores one row per ordered lab test, so each visit record (full screening panel) fanned out 113 times. With 362,000 visit records, the intermediate result was 41 million rows before &lt;code&gt;SELECT DISTINCT&lt;/code&gt; collapsed them back down.&lt;/p&gt;

&lt;p&gt;This was harmless when the orders table was small. Then bulk conversion of two data sources landed 8 million new rows in one week. The fan-out blew past the 6-hour query timeout. When we queried &lt;code&gt;INFORMATION_SCHEMA.JOBS_BY_PROJECT&lt;/code&gt; for slot usage, these two queries had consumed 96.1% of all project slot-time over the previous three days (62.6% and 33.5% respectively), leaving 3.9% for the 35,582 other queries.&lt;/p&gt;

&lt;p&gt;The fix was one line: comment out the dead JOIN. Run time dropped from 6 hours (timeout) to 25 minutes. We also rewrote the partitioned update query to read from a 52 MB computed dashboard table instead of scanning 1 TB of raw FHIR tables directly. That cut its monthly cost from about USD 130 to USD 3.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 4-billion-row table that should have been 12,000
&lt;/h3&gt;

&lt;p&gt;One table had 4,040,397,903 rows. The actual data was 12,033 records.&lt;/p&gt;

&lt;p&gt;A scheduled query had been running with WRITE_APPEND since February, duplicating every row on each run. The table grew to 764 GiB. This showed up when we sorted scheduled query outputs by storage size. We rebuilt the table from source, switching to WRITE_TRUNCATE. If we had not looked at storage sizes, this table would still be growing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud Run: utilization metrics vs. allocated resources
&lt;/h3&gt;

&lt;p&gt;We pulled 30-day p99 CPU and memory utilization for every Cloud Run service across both projects. The gap between allocated and actual was large. One service had 4 CPU and 12 GiB allocated, with peak utilization at 12.3% CPU and 1.0% memory. Several services had similar profiles: provisioned for a load that never came, or provisioned for a peak that happened once and never returned.&lt;/p&gt;

&lt;p&gt;Based on the utilization data, we downsized 44 services in one session, deleted 4 with zero traffic in 30 days, and switched 17 idle always-on services to request-based billing. The Cloud Run revision history shows it all happened in about 13 minutes. One project's daily compute cost dropped by 57% the next day.&lt;/p&gt;

&lt;p&gt;We also right-sized the converter itself from 4 CPU / 16 GiB to 2 CPU / 4 GiB, and reduced the trigger frequency from every 30 minutes to hourly. During the backfill peak, the higher spec made sense. The converter was processing 571,000 records per day across 80 tables, running 20 worker threads with batches of 25 records. After the backfill cleared, utilization data showed the converter sitting well below capacity. We lowered workers and batch sizes to match the new steady state: 2 table workers, batches of 5, query limit down from 100,000 to 15,000.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secret Manager: storage billing we were not tracking
&lt;/h3&gt;

&lt;p&gt;We also went through every line item in the billing export, including services we assumed were cheap. Secret Manager was not cheap. Our patient deduplication service stores its checkpoint as a secret version, creating a new version on every run. Since late 2025, old versions were never destroyed. We found over 30,000 accumulated versions. The "Secret version replica storage" SKU was a top-10 line item on the bill. After cleanup, each checkpoint secret has 1 version. Cost dropped significantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compute Engine: the GPU VM and the static IPs
&lt;/h3&gt;

&lt;p&gt;Compute Engine was 6.6% of the bill. Most of that was expected: 10 VMs running various services (data science, form collection, OCR, migration tools). But one line item stood out.&lt;/p&gt;

&lt;p&gt;A GPU VM with an NVIDIA L4 and 1 TB disk had been provisioned for OCR model fine-tuning. The fine-tuning work had finished weeks earlier. The VM was still running. It was the single most expensive Compute Engine resource. After confirming the fine-tuning was complete, we deleted it.&lt;/p&gt;

&lt;p&gt;We also found unused static IP addresses being billed across both projects, some attached to services that no longer needed them. Small amounts individually, but they add up when nobody is watching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud SQL: fewer resources for the same workload
&lt;/h3&gt;

&lt;p&gt;Cloud SQL was running multiple PostgreSQL instances 24/7. After reviewing actual query loads, we right-sized the instances. Total Cloud SQL savings: -36%.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud Logging: paying for logs nobody reads
&lt;/h3&gt;

&lt;p&gt;Cloud Logging on one project dropped 45% after reducing log levels and removing redundant logging. On the other project, logging actually increased 50% because the converter was processing more records after the backfill work. We still need to add exclusion filters there.&lt;/p&gt;

&lt;h3&gt;
  
  
  External API costs
&lt;/h3&gt;

&lt;p&gt;We use LLM API calls for several features in the platform. One service makes an API call per record to validate field mappings during conversion. Another runs nightly to generate data quality summaries. A few others were experimental tools from earlier prototyping that were still hitting the API on a schedule. These API calls were costing more than Cloud Logging across both projects.&lt;/p&gt;

&lt;p&gt;We reviewed each caller: how often it ran, how many API calls per run, and whether anyone was actually using the output. Two experimental tools had not had their output checked in over a month. The field validation service was running on every converter cycle, but we could batch the calls and reduce frequency without affecting quality. After removing the unused callers and batching the rest, the cost dropped 75%. The lesson here is the same as with schedulers: things that run automatically tend to keep running long after the reason for running them has passed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Billing visibility: the foundation for everything else
&lt;/h3&gt;

&lt;p&gt;We set up the GCP billing export to BigQuery in late April 2026. Before that, our only visibility was the monthly invoice and the GCP console billing dashboard. We could see the total was going up, but we could not see why. Once the billing export was in place, we could break down costs by SKU, by day, by service account. We could compare any two dates for a specific service and see exactly what changed. Every fix in this article started with a query against that billing export table. Without it, we would still be guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;All numbers are from the GCP billing export. The daily cost is what matters, because it shows when each optimization took effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily cost trend (combined, both projects):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;Daily cost&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;td&gt;Normal operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data volume growth&lt;/td&gt;
&lt;td&gt;1.5x - 2x&lt;/td&gt;
&lt;td&gt;Backfill volume hits BQ costs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Peak day&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Backfill volume + dedup cascade&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BQ scans climbing&lt;/td&gt;
&lt;td&gt;2x - 2.8x&lt;/td&gt;
&lt;td&gt;Scheduler scans compounding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BQ fixes deployed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.8x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clustering + scheduler pauses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloud Run downsized&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Utilization-based downsizing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HWM deployed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.6x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Converter BQ near-zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Current&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.6x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lowest point so far&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From peak to current: an 80% reduction in daily cost, in six weeks.&lt;/p&gt;

&lt;p&gt;Three optimization waves are visible in the data. First was BigQuery (clustering and scheduler pauses). Second was Cloud Run (utilization-based downsizing). Third was the high-water mark deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service-level changes (monthly):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BigQuery (Enterprise Edition)&lt;/td&gt;
&lt;td&gt;-41%, then deactivated (Jul: ~0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Run&lt;/td&gt;
&lt;td&gt;-26%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud SQL&lt;/td&gt;
&lt;td&gt;-21%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret Manager&lt;/td&gt;
&lt;td&gt;-26%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Healthcare API&lt;/td&gt;
&lt;td&gt;+24% (data ingestion spike)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Logging (one project)&lt;/td&gt;
&lt;td&gt;-45%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External API&lt;/td&gt;
&lt;td&gt;-75%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not everything went down, and that is worth understanding. Cloud Healthcare API increased 24%. The billing data explains why: upstream data providers were pushing large historical backfills during this period. One migration drove Healthcare API cost up 25x per day during the last two weeks of May. Another pushed daily Healthcare cost up 6x starting in early June. More records coming in means more FHIR writes, and that cost is proportional to actual data volume. It went up because the platform was doing what it was built to do.&lt;/p&gt;

&lt;p&gt;Cloud Logging on one project increased 50% for a similar reason: more incoming data means more converter runs, more logs. We have not added exclusion filters there yet, so that is still on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned about the process
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with the billing data.&lt;/strong&gt; We did not guess which services were expensive. We exported billing data, queried &lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt; for scan volumes, and pulled utilization metrics from Cloud Run. Every optimization started with a number that told us where to look. The billing export showed us Secret Manager costs we would never have guessed. The utilization data showed us Cloud Run services at 1% capacity that had been running for months. Without the data, we would have started with the wrong services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schedulers are the silent cost multiplier.&lt;/strong&gt; They get set up once and forgotten. We found scheduled queries running every 5 hours for dashboards refreshed once a day, and export queries running every 12 hours for data consumed once a week. The cost of each individual scheduler was small, but we had over 50 of them across both projects. Listing every scheduler alongside its actual consumer was the single most productive exercise we did.&lt;/p&gt;

&lt;p&gt;The Cloud Run utilization review is a good example of how little effort some of these wins take. We spent about 30 minutes pulling p99 CPU and memory numbers for every service, sorted by gap between allocated and actual. That 30-minute exercise identified a large portion of total savings. The gap between "provisioned for the worst case" and "what actually happens" grows silently. We now plan to run this review monthly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;We still have optimization targets identified from the data. The FHIR export tables need partitioning by &lt;code&gt;meta.lastUpdated&lt;/code&gt; so we can re-enable the dedup scheduler without recreating the scan cost problem. Cloud Logging on one project needs exclusion filters. Several Compute Engine VMs are running at low utilization. We also need to keep monitoring BigQuery on-demand costs to make sure they stay below what Enterprise Edition would have cost.&lt;/p&gt;

&lt;p&gt;The daily cost is down 80% from peak. We are aiming to stabilize at the current level. The data will tell us when we get there.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>costoptimization</category>
      <category>devops</category>
      <category>bigquery</category>
    </item>
    <item>
      <title>Fixing 168K Failed FHIR Conversions with Parallel AI Agents and Git Worktrees</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Fri, 17 Apr 2026 16:30:19 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/i-ignored-git-worktrees-for-years-ai-agents-made-me-finally-use-them-5gf</link>
      <guid>https://dev.to/budiwidhiyanto/i-ignored-git-worktrees-for-years-ai-agents-made-me-finally-use-them-5gf</guid>
      <description>&lt;p&gt;One afternoon we ran a simple BigQuery query just to check how our FHIR converter was doing. Nothing special, we had been busy with new features for weeks and someone on the team just wanted to see the numbers.&lt;/p&gt;

&lt;p&gt;The result was worse than we thought.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100.0% | tb_tracker_table_a          |  7,753 failures
 99.4% | nutrition_app_table_a       | 23,602 failures
 77.2% | hepatitis_app_table_a       | 22,437 failures
 43.2% | health_service_table_c      |  8,096 failures
 28.1% | health_service_table_a      | 168,713 failures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;168,713 failed conversions on one table. Thirteen tables above 25% failure rate. We had a backlog that had been quietly growing while we were looking the other way.&lt;/p&gt;

&lt;p&gt;If you have ever maintained a data pipeline, you know how this feels. The normal way to fix it is one table at a time. Pull some failed records, find the error, patch the template, run the tests, move on. Two or three hours per table when you know the codebase. Thirteen tables means weeks of focused work.&lt;/p&gt;

&lt;p&gt;We had been experimenting with AI agents for this kind of maintenance work, and they were actually pretty good at it. The problem was that we could only run one agent at a time. Two agents on the same branch would step on each other almost immediately.&lt;/p&gt;

&lt;p&gt;So we were running them one after another, which mostly defeated the point.&lt;/p&gt;

&lt;p&gt;What finally unblocked us was a Git feature that had been sitting there for years and we had not really used: &lt;code&gt;git worktree&lt;/code&gt;. This is the story of how we went from "we will fix this when we have time" to clearing the whole backlog in a few days.&lt;/p&gt;




&lt;h2&gt;
  
  
  a bit of context
&lt;/h2&gt;

&lt;p&gt;Our team maintains a FHIR R4 converter for a national health platform. It pulls health data from local government systems and converts it into FHIR resources that flow into the central platform serving more than 2,000 healthcare facilities across two districts, from small community health posts to hospitals. Immunization records, maternal care, TB treatment, nutrition monitoring, community health screening. The data sources cover around 30 different table types between the two districts.&lt;/p&gt;

&lt;p&gt;If you have not worked with FHIR before, the short version is this. FHIR R4 is the international standard for exchanging healthcare data. Every record has a strict structure, required fields, and value sets that the validator checks. A patient's gender cannot just be any string. It has to be one of the allowed codes. A date cannot be empty if the resource needs it. A coded value has to come from the right terminology system. If anything is wrong, the FHIR server rejects the resource and the record never enters the platform.&lt;/p&gt;

&lt;p&gt;So when we say "conversion failure", we are not just saying the script crashed. We are saying clinical data from a local health facility never made it into the national system. Lab results, immunization shots, maternal visits. Gone, until we fix it.&lt;/p&gt;

&lt;p&gt;Each data source has its own structure and its own quirks. One district had been getting less attention for a while. We did not ignore it on purpose. We always had to fix the most urgent problems first, and the rest kept piling up. You probably know how that goes.&lt;/p&gt;

&lt;p&gt;So when we saw those failure numbers, the first reaction was honestly just tired. We knew this work was waiting. We just had not had the bandwidth to face it.&lt;/p&gt;




&lt;h2&gt;
  
  
  why one agent at a time was not enough
&lt;/h2&gt;

&lt;p&gt;Before we get to worktrees, here is what the agent workflow actually looks like, because that is where the bottleneck became clear.&lt;/p&gt;

&lt;p&gt;For every failing table, the steps are roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull 100 failed UUIDs from BigQuery&lt;/li&gt;
&lt;li&gt;Check data quality, fill rates, date format issues&lt;/li&gt;
&lt;li&gt;Test UUIDs one at a time to find the actual error&lt;/li&gt;
&lt;li&gt;Fix the template or the shared utility function&lt;/li&gt;
&lt;li&gt;Run all 100, make sure at least 90% pass&lt;/li&gt;
&lt;li&gt;Delete the failed rows from the report table so the system picks them up again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A note on the 90% threshold in step 5. We accept that the last few percent often come from genuinely bad source data that we cannot fix on our side. Records with corrupted fields, encoding issues from old systems, or data that should not have been entered in the first place. Chasing 100% on every batch means spending hours on records that are not really fixable. 90% is the threshold where we stop and move on.&lt;/p&gt;

&lt;p&gt;This is the kind of work where an agent shines. It is not creative work. It is patient detective work that follows a clear pattern.&lt;/p&gt;

&lt;p&gt;A concrete example. One table was failing because a date field was coming in as &lt;code&gt;"2024-01-15 00:00:00"&lt;/code&gt; instead of &lt;code&gt;"2024-01-15"&lt;/code&gt;. Our &lt;code&gt;convertStringToDate&lt;/code&gt; function expected &lt;code&gt;%Y-%m-%d&lt;/code&gt; and returned empty when given the datetime format. Empty date meant a required FHIR field was missing. The validator rejected the resource. Record gone.&lt;/p&gt;

&lt;p&gt;The agent found this in about 20 minutes. The third UUID hit the error, the agent walked back to the utility function, added a fallback for the extra time part, then ran the full test batch again to confirm. The actual code change was 4 lines, with a clear explanation of what was wrong.&lt;/p&gt;

&lt;p&gt;Another table had a similar issue but in a different shape. A boolean field was arriving as the string &lt;code&gt;"true"&lt;/code&gt;. FHIR boolean fields expect an actual boolean primitive, so the validator rejected it with &lt;code&gt;expected boolean: found "true"&lt;/code&gt;. Same workflow. The agent found a function returning &lt;code&gt;["true"]&lt;/code&gt; instead of &lt;code&gt;[True]&lt;/code&gt;, fixed it, and checked that no other templates depended on the old behavior before merging.&lt;/p&gt;

&lt;p&gt;So the per-table work was fine. The bottleneck was scale.&lt;/p&gt;

&lt;p&gt;If we started one agent on &lt;code&gt;nutrition_app_table_a&lt;/code&gt; and another on &lt;code&gt;tb_tracker_table_a&lt;/code&gt;, they would conflict almost immediately. Both agents change &lt;code&gt;tests/test_specific_uuids.py&lt;/code&gt; to set up their tests. Both might also touch shared functions in &lt;code&gt;extra_logics/general.py&lt;/code&gt; if they find a common bug. On the same branch, in the same directory, they overwrite each other within minutes.&lt;/p&gt;

&lt;p&gt;We tried the obvious workaround first. Separate clones of the repo, one per agent. It worked, but it was slow to set up, ate disk space, and made it annoying to share git history. Each clone had its own &lt;code&gt;.git&lt;/code&gt; folder, its own remotes to configure, its own everything. We also forgot once to pull the latest main into one of the clones, and the agent fixed a bug that was already fixed on main. Wasted half an hour on that one.&lt;/p&gt;

&lt;p&gt;Then we read the worktree docs properly and realized this was the tool we had been needing all along.&lt;/p&gt;




&lt;h2&gt;
  
  
  what worktrees actually do
&lt;/h2&gt;

&lt;p&gt;If you are like us and have been using Git for years without touching worktrees, here is the short version.&lt;/p&gt;

&lt;p&gt;A git worktree lets you check out multiple branches in separate folders at the same time. Each folder has its own files and its own changes. They all share the same &lt;code&gt;.git&lt;/code&gt; folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../converter-fix-1 &lt;span class="nt"&gt;-b&lt;/span&gt; fix/nutrition-app-a
git worktree add ../converter-fix-2 &lt;span class="nt"&gt;-b&lt;/span&gt; fix/tb-tracker-a
git worktree add ../converter-fix-3 &lt;span class="nt"&gt;-b&lt;/span&gt; fix/hepatitis-app-a
git worktree add ../converter-fix-4 &lt;span class="nt"&gt;-b&lt;/span&gt; fix/health-service-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four folders, four branches, one repository. The extra storage is small because we are not copying the whole &lt;code&gt;.git&lt;/code&gt; folder, only the working files.&lt;/p&gt;

&lt;p&gt;We knew roughly what worktrees do. What we did not realize until recently is that this is exactly what we needed for parallel agents. Each agent gets its own folder. They never see each other's files. Agent 1 can be halfway through testing &lt;code&gt;nutrition_app_table_a&lt;/code&gt; while Agent 2 is just starting on &lt;code&gt;tb_tracker_table_a&lt;/code&gt;. No conflicts, no waiting for each other.&lt;/p&gt;

&lt;p&gt;This is the kind of feature that is great to know about even if you do not need it today. The day you do need it, you will remember it is there.&lt;/p&gt;




&lt;h2&gt;
  
  
  how we organized the actual run
&lt;/h2&gt;

&lt;p&gt;With 13 failing tables, we sorted them by how many failed conversions they had. We also checked which tables share the same template file, because fixing one template sometimes fixes several tables at once. Worth doing this step. We saved a lot of duplicate work this way.&lt;/p&gt;

&lt;p&gt;The first batch had four worktrees running at the same time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;worktree&lt;/th&gt;
&lt;th&gt;table&lt;/th&gt;
&lt;th&gt;failed conversions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;health_service_table_a&lt;/td&gt;
&lt;td&gt;168,713&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;health_service_table_b&lt;/td&gt;
&lt;td&gt;45,707&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;nutrition_app_table_b&lt;/td&gt;
&lt;td&gt;28,977&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;hepatitis_app_table_a&lt;/td&gt;
&lt;td&gt;22,437&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each agent got a prompt like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;please create a new worktree for fixing health_service_table_a,
then run the fix-converter-failures workflow.

TABLE_NAME=health_service_table_a
CODE=health_service_code_a
BQ project=your-project

After the process is READY TO DEPLOY, merge back to the main branch,
commit and push, then delete the worktree.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt looks short because &lt;code&gt;fix-converter-failures&lt;/code&gt; is a workflow file we maintain in the repo. It defines the 6 steps above and the conventions the agent should follow, so the prompt itself only needs to say which table to run it on. Building this workflow file took us a few iterations, but once it was stable, kicking off a new fix became a one-line task.&lt;/p&gt;

&lt;p&gt;The agents ran independently. While worktree 1 was checking fill rates on the patient service table, worktree 2 was already on its third UUID. When one agent found a bug in a shared utility function, it fixed the bug in its own branch, and the other agents kept going.&lt;/p&gt;

&lt;p&gt;The first batch did not go perfectly. We hit a small problem on the second day. Two agents found the same bug in &lt;code&gt;convertStringToDate&lt;/code&gt; at almost the same time, and both fixed it slightly differently. One added a fallback, the other rewrote the function to use &lt;code&gt;dateutil&lt;/code&gt;. Not a real conflict, since each fix was on its own branch, but during merge we had to pick one and revert the other. We added a rule for the next batch: when an agent touches shared code, it has to flag that in the PR description so we know to check for parallel fixes.&lt;/p&gt;

&lt;p&gt;After the first batch finished and merged, we started the next four. The whole experience felt more like reviewing work than doing it. Honestly, this took a moment to get used to. After a few rounds, the rhythm became natural. Open four terminals, kick off four agents, let them run, come back to review.&lt;/p&gt;




&lt;h2&gt;
  
  
  the other half: template coverage
&lt;/h2&gt;

&lt;p&gt;Fixing failures was one problem. The other quality issue was something we did not even have a name for at first. We were calling it "silent data loss".&lt;/p&gt;

&lt;p&gt;We have a coverage check that compares each template against its data dictionary and counts what percentage of the source fields are actually mapped to FHIR. If it is below 90%, the template needs more work.&lt;/p&gt;

&lt;p&gt;With 15 templates to check, we used the same approach. Four worktrees, grouped by data source type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;worktree 1: maternal death reporting templates&lt;/li&gt;
&lt;li&gt;worktree 2: nutrition monitoring (three templates)&lt;/li&gt;
&lt;li&gt;worktree 3: community health screening (four templates)&lt;/li&gt;
&lt;li&gt;worktree 4: immunization and TB treatment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agents ran coverage analysis on each template. If it passed 90%, move on. If not, add the missing fields, test, validate against the FHIR server, merge, delete the worktree.&lt;/p&gt;

&lt;p&gt;This step found a different kind of problem than the failure fixes, and honestly the more important one. A template can have a 0% failure rate and still be missing half the fields it should be mapping. The records were converting successfully and reaching the FHIR server clean. But blood glucose, cholesterol, abdominal circumference, lab results, risk factor flags, procedure details, all of it was being silently dropped because the template never mapped those fields.&lt;/p&gt;

&lt;p&gt;The failure rate query does not show this. The FHIR server does not show this. From every monitoring view we had, the system looked healthy. But the clinical data we were supposed to be capturing was not actually arriving.&lt;/p&gt;

&lt;p&gt;This part was uncomfortable to look at. We had been measuring success by "no errors" without checking if we were actually capturing the data correctly. Some templates were already above 90% and needed no changes. Others were well below.&lt;/p&gt;

&lt;p&gt;One community health screening template was correctly mapping vital signs but was missing several fields that the screening program actually collects. Blood glucose, cholesterol, abdominal circumference. All present in the source data, all listed in the data dictionary, just never connected to FHIR Observation resources. The agent added them, ran the tests, validated against the FHIR server. That kind of work would normally sit in a backlog for weeks.&lt;/p&gt;

&lt;p&gt;A small thing we did not expect. Some data dictionaries were split into multiple CSV files. One screening app reference came in four separate files with around 1,400 rows total. The agent had to combine them before running the analysis. Worth knowing before you start.&lt;/p&gt;

&lt;p&gt;We also had one false positive. The agent reported 78% coverage on a template that was actually fine. The data dictionary listed fields that were deprecated and no longer used in production. We had to manually verify the missing fields before adding code for them. Coverage numbers are useful, but the data dictionary itself can be stale, and the agent cannot know that without help.&lt;/p&gt;




&lt;h2&gt;
  
  
  a few things worth knowing if you try this
&lt;/h2&gt;

&lt;p&gt;Sharing what tripped us up so you do not have to learn the same way.&lt;/p&gt;

&lt;p&gt;Worktrees share your git history and remotes. A commit you make in &lt;code&gt;../converter-fix-2&lt;/code&gt; will show up in &lt;code&gt;git log&lt;/code&gt; from your main folder. &lt;code&gt;git push&lt;/code&gt; works normally from any worktree. This is convenient once you are used to it, but it can feel weird at first.&lt;/p&gt;

&lt;p&gt;Config files that are not committed need to exist in each worktree folder separately. Credential files, &lt;code&gt;.env&lt;/code&gt;, anything in &lt;code&gt;.gitignore&lt;/code&gt;, each worktree needs its own copy. We learned this the hard way when the first agent could not connect to BigQuery and we spent 15 minutes thinking the credentials were wrong. We keep a short setup note for this now.&lt;/p&gt;

&lt;p&gt;Name your worktrees after what they are doing, not just numbers. &lt;code&gt;../fix-nutrition-app&lt;/code&gt; is much easier to work with than &lt;code&gt;../worktree-2&lt;/code&gt; when you have four terminals open at the same time. Trust us on this.&lt;/p&gt;

&lt;p&gt;Cleanup is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree remove ../converter-fix-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The folder is removed and the worktree reference is cleaned up. The branch and its commits stay in the repository, in case you need to revisit them later.&lt;/p&gt;




&lt;p&gt;For us the real lesson from this work was not about worktrees, and not even really about agents. It was about what we were measuring.&lt;/p&gt;

&lt;p&gt;For months we had a dashboard that said the converter was healthy. Failure rates were low on most tables, the FHIR server was accepting resources, alerts were quiet. The dashboard was not lying. It was just answering a different question from the one that mattered for healthcare data. "Is the conversion working" is not the same as "is the clinical data arriving correctly". We were tracking the first one and assuming it answered the second.&lt;/p&gt;

&lt;p&gt;Worktrees and agents did not solve that. They just made it cheap enough to fix once we noticed.&lt;/p&gt;

&lt;p&gt;If you are working with agents on tasks that are similar and do not depend on each other, the parallel worktree pattern is worth trying. The setup takes a few minutes. The harder part is changing how you think about the work. From one task at a time to groups of tasks running side by side. That feels strange at first and gets natural quickly.&lt;/p&gt;

&lt;p&gt;For us, the result was clearing months of backlog in a few days, plus the coverage findings we did not know we were missing. The agents are not magic. We still review every change carefully, especially anything that touches shared code or terminology mappings. But letting them work in parallel changed what was actually possible for our team, on a system that more than 2,000 healthcare facilities depend on.&lt;/p&gt;

</description>
      <category>healthinformatics</category>
      <category>ai</category>
      <category>dataengineering</category>
      <category>fhir</category>
    </item>
    <item>
      <title>How I Built an AI Agent That Writes FHIR Templates in Hours Instead of Days</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Sat, 11 Apr 2026 15:49:03 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/how-i-built-an-ai-agent-that-writes-fhir-templates-in-hours-instead-of-days-32b3</link>
      <guid>https://dev.to/budiwidhiyanto/how-i-built-an-ai-agent-that-writes-fhir-templates-in-hours-instead-of-days-32b3</guid>
      <description>&lt;p&gt;3-4 days to write one JSON template. Sometimes a full week.&lt;/p&gt;

&lt;p&gt;It was a Tuesday night and I had the FHIR specification open in twelve browser tabs. On my left monitor, a CSV data dictionary with 150 fields from a Kobo maternal health form, postnatal care, the kind that tracks five KF checkups for the mother and four KN visits for the newborn. On my right, a half-finished JSON template already 200 lines deep. I wasn't even past the Encounter resource yet.&lt;/p&gt;

&lt;p&gt;Each visit block needed its own set of vital signs: blood pressure (systolic and diastolic, split from a combined "120/80" string), pulse rate, respiratory rate, temperature. Each needed clinical observations like uterine fundal height, lochia status, breastfeeding assessment. Each needed the right LOINC code, the right data type handler, the right reference chain connecting it back to the Patient and Encounter it belonged to. Five mother visits times eight to twelve observations each, plus four newborn visits with their own observation sets, plus the baby Patient resource with mother-baby linkage via NIK identifiers.&lt;/p&gt;

&lt;p&gt;I did the math. This template would land somewhere around 320 resources and several thousand lines of JSON.&lt;/p&gt;

&lt;p&gt;I'd done this before. Many times. Four days minimum, probably five. And somewhere around day two I'd lose my mental model of the reference graph, come back the next morning staring at field mappings I no longer remembered the reasoning behind, spend an hour just re-orienting before I could write another line.&lt;/p&gt;

&lt;p&gt;That night I closed the FHIR spec tabs and started building the agent instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old World
&lt;/h2&gt;

&lt;p&gt;Some context. I'm a software engineer working on health data interoperability for district health systems in Indonesia, specifically Purbalingga and Lombok Barat. We pull data from over a dozen source systems: ePuskesmas (community health center records), Kobo Toolbox (mobile data collection forms), SiGizi (nutrition surveillance), SITB (tuberculosis registry), SIHEPI (hepatitis tracking), SIMRS (hospital information systems), and more. Each system has its own data model, its own field names, its own quirks. Our job is to convert all of it into FHIR (Fast Healthcare Interoperability Resources), the international standard for healthcare data exchange.&lt;/p&gt;

&lt;p&gt;FHIR, if you haven't worked with it, models healthcare data as discrete "resources." A patient is a &lt;code&gt;Patient&lt;/code&gt; resource. A doctor visit is an &lt;code&gt;Encounter&lt;/code&gt;. A blood pressure reading is an &lt;code&gt;Observation&lt;/code&gt; with a specific LOINC code (&lt;code&gt;85354-9&lt;/code&gt;), split into systolic and diastolic components, each coded separately. A diagnosis is a &lt;code&gt;Condition&lt;/code&gt;. A vaccination is an &lt;code&gt;Immunization&lt;/code&gt; with a CVX vaccine code and dose protocol. Every resource links to others through references: an Observation points to the Patient it belongs to, the Encounter it was recorded during, the Organization that performed it.&lt;/p&gt;

&lt;p&gt;The conversion pipeline itself isn't the hard part. We have a Python engine that reads a JSON template, maps incoming data fields to FHIR resource structures, runs transformation functions, and POSTs the result as a FHIR Bundle to Indonesia's national FHIR server (SatuSehat). The hard part is writing the template.&lt;/p&gt;

&lt;p&gt;A template in our system is a JSON file that describes every FHIR resource the converter should produce from a given data source. Here's what even a simple resource definition looks like. This is a real Patient resource from a pregnancy tracking form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fullUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:uuid:patient"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keyField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[nik_ibu]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mergeWithExistingData"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiredFields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/patient_res_id]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identifier.0.system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://fhir.kemkes.go.id/id/nik"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identifier.0.value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/nik_adjusted]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name.0.text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[nama_ibu]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"birthDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/birth_date]"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extraLogics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"getPatientIdWithFuzzyLogic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/nik_adjusted]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="s2"&gt;"[nama_ibu]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/birth_date]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="s2"&gt;"female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[patient_existing_id]"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[square_bracket]&lt;/code&gt; notation maps to incoming data fields. The &lt;code&gt;[generated/...]&lt;/code&gt; notation refers to values produced by transformation functions, called &lt;code&gt;extraLogics&lt;/code&gt;, that run before the FHIR resource is assembled. In this example, &lt;code&gt;getPatientIdWithFuzzyLogic&lt;/code&gt; queries the FHIR server to find if this patient already exists (matching on national ID, name, birth date, and gender).&lt;/p&gt;

&lt;p&gt;That Patient resource is one of two resources in the simplest template in our system: 279 lines of JSON total. Our hospital templates run to 300+ resources and nearly 500KB. The postnatal care form that broke me that Tuesday night had 320 resources across about 5,000 lines.&lt;/p&gt;

&lt;p&gt;Manual template creation took 3-4 days of wall-clock time. Sometimes a full week. And that's with the hardest intellectual work already done for me.&lt;/p&gt;

&lt;p&gt;Before I write a single line of JSON, our health informatics team has already spent their own days (sometimes weeks) building the CSV data dictionary. They're the ones who decide that &lt;code&gt;berat_badan&lt;/code&gt; maps to LOINC &lt;code&gt;29463-7&lt;/code&gt; "Body weight," that &lt;code&gt;tekanan_darah&lt;/code&gt; should become a blood pressure panel with code &lt;code&gt;85354-9&lt;/code&gt;, that this particular field is a SNOMED-coded Condition and not just a free-text Observation. That work requires clinical domain knowledge I don't have. They understand what these data points mean in a maternal health context, which FHIR resource types are clinically appropriate, which code systems are correct. I turn their decisions into working JSON. Without that CSV, I'd be guessing at clinical semantics, and guessing wrong.&lt;/p&gt;

&lt;p&gt;So even with a carefully prepared data dictionary in hand, the template creation itself still took 3-4 days.&lt;/p&gt;

&lt;p&gt;The first day was mapping. Read the CSV line by line. For each field (&lt;code&gt;berat_badan&lt;/code&gt;, &lt;code&gt;tekanan_darah&lt;/code&gt;, &lt;code&gt;tinggi_fundus&lt;/code&gt;, &lt;code&gt;denyut_jantung_janin&lt;/code&gt;) decide which FHIR resource type it becomes. Observation for vitals, Condition for diagnoses, Procedure for clinical actions. Pick the clinical code: LOINC &lt;code&gt;29463-7&lt;/code&gt; for body weight, SNOMED &lt;code&gt;364589006&lt;/code&gt; for fundal height measurement. Choose the data type handler. Numeric fields need &lt;code&gt;handleNumericValueOrDataAbsentReason&lt;/code&gt;, which returns four outputs: the value (or null), plus the data-absent-reason system, code, and display for when the field is empty. String fields, dates, booleans, coded values all have their own handlers.&lt;/p&gt;

&lt;p&gt;Day two was wiring. Connect everything. Every clinical resource needs a &lt;code&gt;subject&lt;/code&gt; reference pointing to the Patient, an &lt;code&gt;encounter&lt;/code&gt; reference pointing to the Encounter, NIK (national ID) traceability in &lt;code&gt;subject.identifier&lt;/code&gt;, and a &lt;code&gt;meta.source&lt;/code&gt; field for audit provenance. Every numeric Observation needs &lt;code&gt;addValueIfNotEmpty&lt;/code&gt; guards on the unit triple (&lt;code&gt;valueQuantity.unit&lt;/code&gt;, &lt;code&gt;valueQuantity.code&lt;/code&gt;, &lt;code&gt;valueQuantity.system&lt;/code&gt;) because if the value is absent, emitting an empty &lt;code&gt;valueQuantity&lt;/code&gt; object alongside a &lt;code&gt;dataAbsentReason&lt;/code&gt; violates FHIR's XOR constraint. I'd forgotten this guard at least twice in earlier templates and spent hours debugging why the FHIR server rejected my bundles.&lt;/p&gt;

&lt;p&gt;Day three was debugging. Run the converter against real sample data. Watch it crash. Trace the error to a missing &lt;code&gt;generateUUID&lt;/code&gt; extraLogic, or an output array that doesn't match the function's return count, or a reference to a resource that was conditionally skipped because its &lt;code&gt;keyField&lt;/code&gt; was empty in the sample. Fix, re-run, fix, re-run. Each cycle takes a few minutes but there are dozens of them.&lt;/p&gt;

&lt;p&gt;If it stretched to a week, day four was review and edge cases. Check that every field in the data dictionary actually made it into the template. Verify code systems (is it &lt;code&gt;http://loinc.org&lt;/code&gt; or &lt;code&gt;http://loinc.org/&lt;/code&gt;? The trailing slash matters). Handle the weird cases: blood pressure stored as a single "120/80" string that needs to be parsed into two separate Observations with component coding. Baby-mother linkage where the newborn's Patient resource needs a &lt;code&gt;nik-ibu&lt;/code&gt; identifier pointing back to the mother.&lt;/p&gt;

&lt;p&gt;The worst part wasn't any single step. It was the context loss. Building a 200-resource template across multiple days means losing your mental model every night. You come back the next morning, open the half-finished JSON, and spend the first hour asking yourself: "Why did I use &lt;code&gt;handleCodeableConceptOrDataAbsentReason&lt;/code&gt; here instead of &lt;code&gt;handleValueOrDataAbsentReason&lt;/code&gt;? Was that intentional or a mistake?" The template is too large to hold in your head. You're constantly re-deriving conclusions you already reached yesterday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an Agent, Not a Script
&lt;/h2&gt;

&lt;p&gt;I didn't set out to "automate FHIR mapping with AI." I set out to make this work fit in a single focused session.&lt;/p&gt;

&lt;p&gt;A Python script was my first thought. But template creation isn't a mechanical transformation; it requires judgment. Is &lt;code&gt;letak_janin&lt;/code&gt; (fetal position) a coded Observation or free-text? Depends on whether we have a SNOMED mapper for the values. Should &lt;code&gt;komplikasi_persalinan&lt;/code&gt; be a Condition resource or an Observation? Depends on whether the field represents a diagnosis or just a documentation note. These decisions vary across data sources and there's no algorithm for them.&lt;/p&gt;

&lt;p&gt;My second thought was a one-shot LLM prompt. "Here's a CSV, here's a sample, generate a FHIR template." I tried it. The output looked plausible but failed in subtle ways: wrong function signatures for extraLogics, missing &lt;code&gt;addValueIfNotEmpty&lt;/code&gt; guards, LOINC codes that didn't exist, references to resources that were never generated. A FHIR template is a program that runs through our converter engine, and it has to be mechanically correct, not just structurally plausible.&lt;/p&gt;

&lt;p&gt;An agent was the right shape. FHIR mapping requires judgment (which resource type? which code?) interleaved with lookup (does this LOINC code exist? does this extraLogic function exist in our codebase?) followed by validation against real data. That loop, think-look up-try-validate-fix, is exactly the kind of work agents handle well.&lt;/p&gt;

&lt;p&gt;My honest motivation wasn't full automation. I wanted to compress the tedious parts, choosing codes and wiring references, so my judgment could focus on the edge cases and domain-specific decisions that require understanding what the data actually means in a clinical context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of the Agent
&lt;/h2&gt;

&lt;p&gt;The agent is built as a set of Claude Code custom slash commands: markdown files that define prompts, tool access, and autonomy rules. They chain together into a pipeline. I went through two major iterations, an original 7-step pipeline and a streamlined 3-step version I use today.&lt;/p&gt;

&lt;p&gt;The agent receives three files as input:&lt;/p&gt;

&lt;p&gt;First, a reference CSV, which is a data dictionary mapping field names to FHIR resource types, clinical codes, and paths. Our health informatics team builds these, and they're the foundation everything else depends on. Each row represents a clinical judgment call: this field is a vital sign Observation, that one is a Condition, this code is LOINC, that one is SNOMED. A typical row: &lt;code&gt;berat_badan | Body weight in kg | Observation | valueQuantity.value | LOINC | 29463-7 | Body weight | numeric&lt;/code&gt;. The agent can wire JSON and pick handlers, but it can't decide whether a field is clinically meaningful or which code system is appropriate. That's what the CSV encodes.&lt;/p&gt;

&lt;p&gt;Second, a sample JSON record, a real data row from BigQuery showing actual field values and types. This tells the agent what the data looks like in practice, not just in theory. It reveals things the CSV doesn't: that &lt;code&gt;tekanan_darah&lt;/code&gt; is actually a combined "120/80" string, that &lt;code&gt;usia_kehamilan&lt;/code&gt; is an integer, that some fields are null in real data.&lt;/p&gt;

&lt;p&gt;Third, a reference template, an existing &lt;code&gt;structure/*.json&lt;/code&gt; template for a similar data source. The agent uses this to extract project-specific patterns: how we do patient fuzzy matching, which regional helper functions exist (like &lt;code&gt;getPurbalinggaVillageId&lt;/code&gt;), what the EpisodeOfCare strategy looks like for this program type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step A: Plan and Resolve
&lt;/h2&gt;

&lt;p&gt;The first step is 80% deterministic Python, 20% LLM for genuinely ambiguous cases. It parses the CSV and sample data, cross-references them, resolves code systems from friendly labels to canonical URLs (&lt;code&gt;"LOINC"&lt;/code&gt; becomes &lt;code&gt;"http://loinc.org"&lt;/code&gt;), detects the appropriate data-absent-reason handler per field based on its type, and identifies the unit of measure from the CSV description.&lt;/p&gt;

&lt;p&gt;The thing that makes this step work is a defaults file, twelve pre-resolved design decisions that the agent must follow without asking me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Coded Fields Without an Existing Mapper&lt;/span&gt;

When a field has coded values but no mapper function exists:
Default: use handleValueOrDataAbsentReason, store as valueString.
Do NOT stop to ask. Do NOT invent a mapper.

&lt;span class="gu"&gt;## Baby / Neonate Patient Linking&lt;/span&gt;

When the template has a second Patient for the baby:
Default: always add mother-baby linkage without asking.

&lt;span class="gu"&gt;## Blood Pressure from Combined String&lt;/span&gt;

When tekanan_darah is a combined "120/80" string:
Default: two separate Observations (systolic + diastolic),
each with parseSystolicBP / parseDiastolicBP.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote this file after running the first version of the agent and noticing it kept stopping to ask me the same twelve questions across different templates. "Should I use &lt;code&gt;valueString&lt;/code&gt; or &lt;code&gt;valueCodeableConcept&lt;/code&gt; for this field?" &lt;code&gt;valueString&lt;/code&gt;, every time, unless we have a mapper. "Should I add baby-mother linking?" Yes, always. "What should &lt;code&gt;period.end&lt;/code&gt; be when there's no discharge field?" Same as &lt;code&gt;period.start&lt;/code&gt;. These aren't judgment calls. They're conventions. Encoding them as defaults lets the agent run with far less hand-holding.&lt;/p&gt;

&lt;p&gt;The output is an &lt;code&gt;enriched_mapping.json&lt;/code&gt; with every field fully typed, every code resolved, every handler assigned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step B: Generate
&lt;/h2&gt;

&lt;p&gt;This is where the template gets written. One continuous pass, following a strict resource ordering: Patient first, then EpisodeOfCare, Encounter, Conditions, vital sign Observations, clinical assessments, numeric measurements, datetime Observations, obstetric history, boolean/coded Observations, Procedures, baby Patient with mother linking, baby Observations, QuestionnaireResponse for risk factors, and any remaining resources. Seventeen groups in total, generated without pausing.&lt;/p&gt;

&lt;p&gt;The agent assembles each resource from validated skeleton patterns. Here's a simplified skeleton for a numeric Observation, the most common resource type in a typical template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Observation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keyField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/observation_datetime]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extraLogics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"generateUUID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[obs_id]"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"returnPatientReference"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[generated/patient_res_id]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[nama]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[nik]"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[patient_ref]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[patient_display]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[patient_nik]"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"handleNumericValueOrDataAbsentReason"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[berat_badan]"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[weight_value]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[weight_absent_system]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="s2"&gt;"[weight_absent_code]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[weight_absent_display]"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"addValueIfNotEmpty"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[generated/weight_value]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kg"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"[weight_unit_code]"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiredFields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code.coding.0.system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://loinc.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code.coding.0.code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"29463-7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code.coding.0.display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Body weight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"subject.reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/patient_ref]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"subject.identifier.value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/patient_nik]"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"optionalFields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"valueQuantity.value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/weight_value]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"valueQuantity.unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/weight_unit_code]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dataAbsentReason.coding.0.code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[generated/weight_absent_code]"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;addValueIfNotEmpty&lt;/code&gt; guard on the unit field. Without it, when the numeric value is absent, the handler correctly sets &lt;code&gt;dataAbsentReason&lt;/code&gt;, but the unit field still emits an empty &lt;code&gt;valueQuantity&lt;/code&gt; object, violating FHIR's constraint that a resource cannot have both &lt;code&gt;value[x]&lt;/code&gt; and &lt;code&gt;dataAbsentReason&lt;/code&gt;. This bug bit me three times in manual templates before I documented it in &lt;code&gt;failure-modes.md&lt;/code&gt; and baked the guard into every pattern.&lt;/p&gt;

&lt;p&gt;The strongest autonomy rule, repeated in every step:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never ask "ready to continue?" or "shall I proceed?". Just proceed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before finishing, the agent runs a coverage check, verifying that every field in the sample data is mapped somewhere in the template. Target: zero uncovered fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step C: Validate and Fix
&lt;/h2&gt;

&lt;p&gt;The final step runs the actual converter against real sample data, then passes the output through a 7-layer validator that checks: FHIR R4 schema compliance, reference closure (every referenced resource exists in the bundle), mandatory fields per resource type, NIK traceability and &lt;code&gt;meta.source&lt;/code&gt; format, code system terminology (LOINC/SNOMED/ICD-10/CVX existence), value consistency against the sample data, and helper/handler coverage. The first six layers flag errors; the last one flags warnings.&lt;/p&gt;

&lt;p&gt;If errors exist, the agent reads only the error report (not the full bundle, not the full template), applies surgical fixes to the specific failing resources, and re-runs. Maximum two retry cycles. If errors persist after two passes, it stops and surfaces the remaining issues for me to fix manually.&lt;/p&gt;

&lt;p&gt;A completed run produces four things: &lt;code&gt;structure/&amp;lt;name&amp;gt;.json&lt;/code&gt; (the production-ready template), updated &lt;code&gt;settings.py&lt;/code&gt; and &lt;code&gt;settings_local.py&lt;/code&gt; (registering the new data source), &lt;code&gt;bundle.json&lt;/code&gt; (a test FHIR Bundle from the sample data), and &lt;code&gt;validation_report.json&lt;/code&gt; (showing zero errors across all layers).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Template Lives
&lt;/h2&gt;

&lt;p&gt;The agent's output, a JSON template, plugs into a larger system.&lt;/p&gt;

&lt;p&gt;BigQuery holds raw health data across 50+ tables, ingested from source systems across Purbalingga and Lombok Barat districts. The orchestrator queries for unprocessed records and farms them out in parallel batches (15 workers, batches of 25 records). For each record, the converter loads the matching template from &lt;code&gt;structure/&lt;/code&gt;, runs the &lt;code&gt;extraLogics&lt;/code&gt; transformation chain, maps fields to FHIR paths, resolves references between resources, and assembles a FHIR Bundle. The bundle goes to Indonesia's SatuSehat FHIR server via POST. Success and failure get logged back to BigQuery.&lt;/p&gt;

&lt;p&gt;The core of the converter, where the template becomes a FHIR resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;merge_resources_and_build_references&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fullUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                         &lt;span class="n"&gt;references_data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;existing_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mergeWithExistingData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;existing_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_existing_data_with_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;resource_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge_nested_dicts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;references&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepcopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;references&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamicReferences&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamicReferences&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;references_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="c1"&gt;# ... build conditional reference
&lt;/span&gt;
    &lt;span class="n"&gt;resource_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge_dicts_with_list_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;resource_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;references&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fullUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fullUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resourceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resource_json&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent unblocks this pipeline. Everything upstream (data extraction) and downstream (conversion, FHIR submission, reporting) is mechanical. Before the agent, adding a new data source meant I couldn't take on other work for three to four days. Now it means an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payoff
&lt;/h2&gt;

&lt;p&gt;3-4 days of wall-clock time down to 3-6 hours.&lt;/p&gt;

&lt;p&gt;About 30 minutes goes to setup: extracting a sample record from BigQuery, prepping the CSV data dictionary (our health informatics team builds these, but I sometimes need to clean up column names or add missing code system labels), picking a reference template close to the new data source.&lt;/p&gt;

&lt;p&gt;1-3 hours for the agent pipeline and review. Run the three steps, then read through the generated template. Check code selections ("did it pick the right LOINC code for fundal height?"), verify the extraLogics chains make sense ("is it using the right date parser for this data source?"), spot-check the reference graph ("do all Observations point to the right Encounter?").&lt;/p&gt;

&lt;p&gt;Then 2-3 hours of testing and manual fixes. Run the converter against real sample data. Fix the things the agent got wrong, usually a transformation function with the wrong number of arguments, or a reference to a field name that doesn't match the sample. Re-run until the validation report comes back clean.&lt;/p&gt;

&lt;p&gt;I still read every template. But the review is different from building from scratch. When I built manually, I was making decisions and implementing them simultaneously: the cognitive load of "what should this be?" stacked on top of "how do I express that in our template format?" Now I'm only doing the first part. The agent handles the expression.&lt;/p&gt;

&lt;p&gt;One moment that sticks with me: the agent generated a template for a nutrition surveillance form (&lt;code&gt;sigizi_balita_dipantau_pmt&lt;/code&gt;) with about 60 Observations covering growth monitoring, nutrition status, and supplementary feeding data. In review, I noticed it had correctly applied &lt;code&gt;addValueIfNotEmpty&lt;/code&gt; guards on the unit fields for every single numeric Observation. All sixty of them. That's a pattern I'd missed in at least two earlier manual templates, which led to hours of debugging FHIR constraint violations when the server rejected bundles where a patient had no weight recorded but the template still emitted an empty &lt;code&gt;valueQuantity&lt;/code&gt; alongside the &lt;code&gt;dataAbsentReason&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The agent got this right because I'd documented the bug in &lt;code&gt;failure-modes.md&lt;/code&gt; after debugging it the hard way, and baked the fix into the skeleton patterns. My past pain became the agent's default behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Evolved
&lt;/h2&gt;

&lt;p&gt;The first version of the agent had seven steps: analyze inputs, generate a base template at ~40% coverage, evaluate coverage, implement missing fields, test structural integrity, validate quality, check data consistency. Each step was its own Claude Code command with its own system prompt.&lt;/p&gt;

&lt;p&gt;It worked. Templates came out correct. I used it for weeks, and it was a real improvement over manual work. But after running it on maybe a dozen templates, I started noticing where the friction was.&lt;/p&gt;

&lt;p&gt;Token cost added up. Seven LLM invocations, each re-reading the template, reference files, and sample data. The pipeline consumed roughly 107,000 tokens per run, about 4,773 lines of markdown across the seven command files. The generate-then-measure-then-patch loop (steps 2, 3, 4) felt especially redundant after a while: why generate an incomplete template at 40% coverage just to measure what's missing and patch it? I had enough experience by then to know the agent could do it in one pass.&lt;/p&gt;

&lt;p&gt;Confirmation prompts wore me down. Despite instructions not to, the agent kept pausing between resource groups. "I've generated the vital signs Observations. Ready to continue with clinical assessments?" The first few times I didn't mind. After the twentieth template, pressing enter seventeen times per run felt like exactly the kind of tedious interruption I'd built the agent to eliminate.&lt;/p&gt;

&lt;p&gt;Small quality issues compounded. The old data consistency validator (step 7) checked whether sample values appeared in the generated bundle using Python's &lt;code&gt;in&lt;/code&gt; operator on the serialized JSON string: &lt;code&gt;value in json.dumps(bundle)&lt;/code&gt;. The value &lt;code&gt;"1"&lt;/code&gt; matched every UUID containing the digit 1. Not a dealbreaker, but it meant an extra fix-and-retry cycle on most runs, and after a few weeks those extra cycles added up.&lt;/p&gt;

&lt;p&gt;None of these were really wrong. The 7-step pipeline was a huge improvement over manual work. But once you've lived with a tool long enough, you start seeing the next version. I refactored it over a weekend, less out of frustration than the natural instinct to tighten something you use every day.&lt;/p&gt;

&lt;p&gt;The 3-step version changed three things. One-pass generation replaced the generate-measure-patch loop: Step B produces the complete template in a single continuous pass. Pre-resolved defaults reduced confirmation prompts: the &lt;code&gt;defaults.md&lt;/code&gt; file encodes every recurring design decision, and Claude still asks about some things (tool calls, file writes, ambiguous decisions outside the defaults) but the constant back-and-forth between resource groups is gone. Typed path-aware validation replaced substring matching: the new validator does type-aware comparison (&lt;code&gt;float()&lt;/code&gt; cast for numerics, ISO normalization for dates, case-insensitive for strings) instead of &lt;code&gt;value in json.dumps(bundle)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Token usage dropped by roughly 70%. Fix-and-retry cycles went from 2-3 typical to 0-1. It's not fully hands-off; I still sit with the agent, approve tool calls, and occasionally steer it when it goes down a wrong path. But the interruptions are meaningful now. Not mechanical.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Agent Gets It Wrong
&lt;/h2&gt;

&lt;p&gt;The agent still makes mistakes. The most common: picking a transformation function that's incompatible with the actual data shape. Using &lt;code&gt;handleNumericValueOrDataAbsentReason&lt;/code&gt; on a field that the CSV says is numeric but the sample data reveals is actually a string like &lt;code&gt;"Normal"&lt;/code&gt; or &lt;code&gt;"Tidak ada"&lt;/code&gt;. The handler tries to cast it to a float, throws an exception, the converter crashes.&lt;/p&gt;

&lt;p&gt;Another recurring issue is wrong argument counts in extraLogics. Our transformation functions have strict input/output contracts. &lt;code&gt;getPatientIdWithFuzzyLogic&lt;/code&gt; takes exactly 6 inputs and returns 1 output. If the agent wires 5 inputs, the function throws an &lt;code&gt;unexpected argument count&lt;/code&gt; error at runtime. Step C's test run always catches this, but it requires a manual fix to figure out which argument was omitted.&lt;/p&gt;

&lt;p&gt;Sometimes the agent chooses a structure that looks right but doesn't fit how the converter actually processes the data. A field that should be a &lt;code&gt;requiredField&lt;/code&gt; ends up in &lt;code&gt;optionalFields&lt;/code&gt;, or vice versa, and the missing-value behavior changes in ways that only show up with certain input records. These are the subtlest bugs. They pass validation against the sample data but fail on edge cases in production.&lt;/p&gt;

&lt;p&gt;These bugs are fast to fix because the error messages are specific and the template structure is already complete. Patching a single resource definition, not rebuilding from scratch. A 10-minute fix versus a 4-hour reconstruction.&lt;/p&gt;

&lt;p&gt;The lesson from building two versions: the first agent you build optimizes for correctness. The second optimizes for cost and autonomy. Both matter, but you don't know which autonomy rules you need until you've run the first version enough times to feel the friction. Every line in &lt;code&gt;defaults.md&lt;/code&gt; represents a specific moment where the agent's behavior cost me time, either by asking an unnecessary question or failing in a way I'd already seen before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed
&lt;/h2&gt;

&lt;p&gt;I now support 60 template configurations across ten Indonesian health programs: hospital systems, community health centers, mobile data collection, nutrition surveillance, tuberculosis tracking, hepatitis monitoring. The templates range from a 2-resource pregnancy tracker (279 lines) to a 332-resource hospital ANC integration (490KB of JSON). Every one of them was built using the agent pipeline.&lt;/p&gt;

&lt;p&gt;Before the agent, I had to defer new integrations. Each new data source meant committing to a multi-day block of focused work, and there was always something more urgent. A new nutrition monitoring form would sit in the backlog for weeks while I finished a hospital template. Now, when the health informatics team hands me a new data dictionary, I can have a working template the same day. That changed what integrations I was willing to take on, and how quickly new health programs could start reporting data through the national FHIR infrastructure.&lt;/p&gt;

&lt;p&gt;My job shifted from writing thousands of lines of JSON by hand to designing the system that writes it and then reviewing the output with the kind of attention I couldn't sustain across a four-day manual effort.&lt;/p&gt;

&lt;p&gt;The most valuable thing I built was &lt;code&gt;defaults.md&lt;/code&gt;, twelve pre-resolved design decisions that let the agent run without constantly asking me questions. Writing that file forced me to articulate judgment calls I'd been making intuitively for months. Which EpisodeOfCare strategy for which program type. How to handle coded fields without an existing mapper. When to split blood pressure into components versus a single observation. These decisions were in my head, applied inconsistently, never written down.&lt;/p&gt;

&lt;p&gt;But I want to be clear about something. The agent automates my part of the work: turning clinical mappings into working FHIR templates. It does not automate the clinical thinking that comes before it. Every template starts with a CSV that our health informatics team built, row by row, mapping raw field names to the right FHIR resource types and clinical codes. They're the ones who know that &lt;code&gt;denyut_jantung_janin&lt;/code&gt; is LOINC &lt;code&gt;55283-6&lt;/code&gt; and not some other fetal assessment code. They're the ones who decide when a field warrants its own Condition resource versus being an Observation. Without that upstream work, I'd have nothing to automate. The agent compressed my 3-4 days into an afternoon, but it didn't touch the clinical expertise that makes the output meaningful.&lt;/p&gt;

&lt;p&gt;It turns out the hard part of building an AI agent isn't the AI. It's figuring out what you actually know, and writing it down clearly enough that something else can apply it. And sometimes, it's recognizing that the knowledge you depend on most isn't yours at all.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>software</category>
      <category>automation</category>
    </item>
    <item>
      <title>National Vaccine Appointment &amp; Administration System</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Sat, 28 Feb 2026 09:30:45 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/national-vaccine-appointment-administration-system-303o</link>
      <guid>https://dev.to/budiwidhiyanto/national-vaccine-appointment-administration-system-303o</guid>
      <description>&lt;h2&gt;
  
  
  🌱 How It Started
&lt;/h2&gt;

&lt;p&gt;Few Years ago, I had a system design interview. The interviewer gave me this scenario:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Design a national vaccine appointment booking system. Millions of citizens need to register and book slots. Clinics must administer the doses. The government needs audit logs and fraud prevention."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My first thought was simple just let people book a slot, check the stock, and confirm. I drew a basic flow on the whiteboard and felt pretty good about it. Then the interviewer started asking harder questions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"What if two people try to book the last slot at the same time?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"What if the clinic runs out of doses after the booking is already confirmed?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"How do you undo things if eligibility check fails in the middle?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I didn't have good answers. I only designed for the happy path.&lt;/p&gt;

&lt;p&gt;That interview stuck in my mind. Months later, I was doing research on &lt;a href="https://dev.to/budiwidhiyanto/designing-an-internet-credit-purchase-system-1175"&gt;inventory reservation patterns for an internet credit purchase system&lt;/a&gt;, and I realized the same ideas could have helped me in that interview. So I went back to the problem and redesigned it. This is what I came up with.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ My Initial (Naïve) Solution
&lt;/h2&gt;

&lt;p&gt;Here's what I proposed during the interview:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fowllg4g1kpyz9799mo0e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fowllg4g1kpyz9799mo0e.png" alt="Initial (Naïve) Solution" width="791" height="861"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Simple, right? But the problems come fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Race conditions&lt;/strong&gt;: Two people click "Book" at the same time for the last slot. Both get confirmed. Now one citizen has no seat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stock mismatch&lt;/strong&gt;: Slot is confirmed, but the clinic ran out of vaccine doses between booking day and appointment day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late eligibility failure&lt;/strong&gt;: System confirms appointment first, then finds out the citizen doesn't meet age or insurance requirement. Now you need to undo everything, but stock is already allocated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rollback&lt;/strong&gt;: If something fails in the middle, there's no way to release the slot or dose back to the pool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the same problems I found later when designing the &lt;a href="https://dev.to/budiwidhiyanto/designing-an-internet-credit-purchase-system-1175"&gt;internet credit purchase system&lt;/a&gt; the happy path is not enough when you deal with limited resources and many users at the same time.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔍 Rethinking the Flow
&lt;/h2&gt;

&lt;p&gt;The main idea, which I learned from inventory reservation strategies in e-commerce, is: &lt;strong&gt;don't confirm anything until everything is verified&lt;/strong&gt;. Use a multi-stage process temporary hold first, then verify, then confirm. If anything fails, rollback.&lt;/p&gt;

&lt;p&gt;It's like buying concert tickets. When you select a seat, it's held for you while you pay. If you don't finish in time, the seat goes back. Same concept here.&lt;/p&gt;

&lt;p&gt;Here's the full flow of the improved design:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft6kfgwg4akc1spqzq2pg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft6kfgwg4akc1spqzq2pg.png" alt="improved flow design" width="666" height="1259"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 The Improved Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Reserve First (Temporary Hold)
&lt;/h3&gt;

&lt;p&gt;When a citizen selects a clinic, time slot, and vaccine type, the system does not confirm right away. Instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It creates a &lt;strong&gt;temporary reservation in Redis&lt;/strong&gt; with a TTL (time-to-live), for example 5 minutes.&lt;/li&gt;
&lt;li&gt;Appointment status is set to &lt;code&gt;PENDING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Slot capacity and vaccine dose count are decreased &lt;em&gt;temporarily&lt;/em&gt; other users will see less availability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why Redis?&lt;/strong&gt; Because we need something fast and temporary. A relational database could work too, but you would need a separate scheduled job to clean up expired reservations. Redis handles this automatically with TTL when 5 minutes pass, the key just disappears. For a system that handles millions of bookings during a national vaccine campaign, this performance difference is important.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to handle race condition on Redis?&lt;/strong&gt; We use Redis &lt;code&gt;DECR&lt;/code&gt; command on the slot counter. This is atomic meaning if two requests come at the same time, Redis processes them one by one. If the counter reaches zero, the next request is rejected. For extra safety, you can use a Lua script to make the check-and-decrement happen in one step.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Eligibility Verification
&lt;/h3&gt;

&lt;p&gt;While the slot is held, the system runs eligibility checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Age requirement (e.g., some vaccines only for 60+).&lt;/li&gt;
&lt;li&gt;Insurance verification through external API.&lt;/li&gt;
&lt;li&gt;Medical history (allergies, previous doses).&lt;/li&gt;
&lt;li&gt;Geographic check (is this citizen in the right region?).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any check fails, the reservation is released Redis key is deleted, slot goes back to the pool. The citizen gets a clear message explaining &lt;em&gt;why&lt;/em&gt; they are not eligible, not just "something went wrong."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Confirm Appointment
&lt;/h3&gt;

&lt;p&gt;If all checks pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Slot capacity and vaccine stock are decreased &lt;strong&gt;permanently&lt;/strong&gt; in the main database.&lt;/li&gt;
&lt;li&gt;Appointment status changes from &lt;code&gt;PENDING&lt;/code&gt; to &lt;code&gt;CONFIRMED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redis reservation is cleared (not needed anymore).&lt;/li&gt;
&lt;li&gt;Confirmation is sent to the citizen (SMS, email, or push notification).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the point of no return. Before this step, everything can be undone.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Administration (Vaccination Day)
&lt;/h3&gt;

&lt;p&gt;When the citizen arrives at the clinic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clinic staff scans the citizen's &lt;strong&gt;QR code&lt;/strong&gt;. The QR code contains the appointment ID and a verification hash. The hash is generated on the server using appointment ID + citizen ID + a secret key, so it cannot be faked.&lt;/li&gt;
&lt;li&gt;System verifies the QR code against the appointment record.&lt;/li&gt;
&lt;li&gt;Staff records the &lt;strong&gt;vaccine batch number&lt;/strong&gt; and time of administration.&lt;/li&gt;
&lt;li&gt;Appointment status changes to &lt;code&gt;ADMINISTERED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An event is sent to other systems analytics, government reporting, audit logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Failure &amp;amp; Rollback Scenarios
&lt;/h3&gt;

&lt;p&gt;This is the part I completely missed in my interview. Here's how each failure is handled:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr3cjkoeg7zlwheyxb56x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr3cjkoeg7zlwheyxb56x.png" alt="Failure and Rollback" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No-show&lt;/strong&gt;: A scheduled job checks for &lt;code&gt;CONFIRMED&lt;/code&gt; appointments that passed their time window. Status becomes &lt;code&gt;NO_SHOW&lt;/code&gt;, stock is released back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Citizen cancels&lt;/strong&gt;: They can cancel through the portal. Stock is released right away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clinic cancels a slot&lt;/strong&gt; (e.g., not enough staff): All affected appointments are flagged. Citizens get notified and can rebook with priority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External API is down&lt;/strong&gt; (e.g., insurance service): The system uses a &lt;strong&gt;circuit breaker&lt;/strong&gt; pattern. After several failures in a row, the system stops calling that API temporarily. Meanwhile, the booking is either queued for retry (with increasing wait time between retries) or allowed provisionally with a flag for manual review later. The important thing is: one broken dependency should not block the whole flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis goes down&lt;/strong&gt;: The system falls back to database-level reservations with a cleanup job. It's slower, but the booking still works.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🏗️ System Components
&lt;/h2&gt;

&lt;p&gt;Here's the high-level architecture:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4m9qco7wxsv7x65f6gap.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4m9qco7wxsv7x65f6gap.png" alt="high level architecture" width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: Booking portal for citizens + Dashboard for clinic staff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Gateway&lt;/strong&gt;: Authentication, rate limiting (very important during mass booking), and routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core Services&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth Service&lt;/strong&gt; Login, national ID verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patient Service&lt;/strong&gt; Medical records, vaccination history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clinic Service&lt;/strong&gt; Slot management, staff schedules, capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory Service&lt;/strong&gt; Vaccine stock per clinic, batch tracking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appointment Service&lt;/strong&gt; The main service. Manages reservations, confirmations, and status changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eligibility Service&lt;/strong&gt; Rules engine + external API calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notification Service&lt;/strong&gt; SMS, email, push. Retries if delivery fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit Service&lt;/strong&gt; Append-only logs for every status change. Required for government compliance.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Data Layer&lt;/strong&gt;: PostgreSQL for permanent data, Redis for temporary reservations and caching.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Async Messaging&lt;/strong&gt;: Kafka for events &lt;code&gt;AppointmentReserved&lt;/code&gt;, &lt;code&gt;AppointmentConfirmed&lt;/code&gt;, &lt;code&gt;AppointmentAdministered&lt;/code&gt;, &lt;code&gt;AppointmentCancelled&lt;/code&gt;. This keeps services separated and makes the system auditable by default.&lt;/li&gt;

&lt;/ul&gt;




&lt;h2&gt;
  
  
  🎯 What I Would Do Differently Now
&lt;/h2&gt;

&lt;p&gt;Looking back at that interview, the biggest thing I missed was not about technology it was about &lt;strong&gt;mindset&lt;/strong&gt;. I jumped to the happy path because it felt complete. But the interviewer was not testing if I can design a booking form. They were testing if I can think about what happens when things go wrong.&lt;/p&gt;

&lt;p&gt;Here's what I learned from this experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with failure scenarios&lt;/strong&gt;, not the happy path. Ask yourself "what can go wrong at each step?" before finalizing any design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporary reservation is a pattern, not a hack&lt;/strong&gt;. Whether it's concert tickets, flash sales, or vaccine slots if you have limited stock and many users, you need hold-then-confirm flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't be vague about rollbacks&lt;/strong&gt;. "We'll handle errors" is not a design. Be specific what happens to the data, the stock, and the user when something fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External services will go down&lt;/strong&gt;. Always have a plan for when the insurance API or notification service is not available. Circuit breakers and retry queues are not optional they are necessary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're preparing for system design interviews, I recommend studying inventory reservation patterns. My earlier post on &lt;a href="https://dev.to/budiwidhiyanto/designing-an-internet-credit-purchase-system-1175"&gt;designing an internet credit purchase system&lt;/a&gt; covers these patterns with more detail and code examples. The core idea reserve first, verify, then commit appears in many systems once you start looking.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks for reading. If you faced similar interview questions or have ideas to improve this design, I would like to hear about it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>interview</category>
      <category>career</category>
    </item>
    <item>
      <title>Data Fetching Patterns Every Developer Should Know (And When to Actually Use Them)</title>
      <dc:creator>Budi Widhiyanto</dc:creator>
      <pubDate>Sat, 28 Feb 2026 08:56:54 +0000</pubDate>
      <link>https://dev.to/budiwidhiyanto/data-fetching-patterns-every-developer-should-know-and-when-to-actually-use-them-16j3</link>
      <guid>https://dev.to/budiwidhiyanto/data-fetching-patterns-every-developer-should-know-and-when-to-actually-use-them-16j3</guid>
      <description>&lt;p&gt;About a year ago, I was working on a payment app. Solid architecture, clean API design, decent frontend on paper, everything looked good. But a few months after launch, the ratings started tanking. Users were complaining about slow loads, failed transactions, and the whole thing falling apart on spotty connections.&lt;/p&gt;

&lt;p&gt;I spent three months debugging those performance issues, and the fix wasn't some clever algorithm or a server upgrade. It was rethinking how we fetched data. That's it. Same features, same infrastructure, same design just smarter data fetching patterns. The app went from 3.2 stars to 4.7, and transaction volume jumped 30% within two months.&lt;/p&gt;

&lt;p&gt;That experience a year ago changed how I think about data flow end-to-end. Most apps don't have a "feature" problem they have a "how we get data to the screen" problem. And the difference between a mediocre app and a great one often comes down to picking the right data fetching pattern for the right situation.&lt;/p&gt;

&lt;p&gt;Here's everything I learned and wish I'd known sooner.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Basics: Request-Response
&lt;/h2&gt;

&lt;p&gt;This is where everyone starts, and for good reason. You ask the server for something, you wait, you get it back. It's the foundation of HTTP, and it handles the majority of use cases just fine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Think of it like ordering at a counter you place your order, you wait, you get your food. Simple and predictable.&lt;/p&gt;

&lt;p&gt;This works great for standard CRUD operations: loading a user profile, submitting a form, fetching account details on page load. Where it falls apart is when you start chaining multiple requests together. If your page needs data from five endpoints and each one takes 300ms, your user is staring at a spinner for 1.5 seconds. That adds up fast.&lt;/p&gt;

&lt;p&gt;The key is recognizing when request-response &lt;em&gt;stops&lt;/em&gt; being enough which brings us to everything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Polling: The "Are We There Yet?" Approach
&lt;/h2&gt;

&lt;p&gt;Polling is exactly what it sounds like. You ask the server for updates on a regular interval. Every 5 seconds, every 30 seconds, whatever makes sense for your use case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pollForUpdates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;updateUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Polling failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Don't forget cleanup&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've seen polling get a bad reputation, and honestly, sometimes it deserves it. Naive polling hammers your server with requests even when nothing has changed. On mobile, it eats battery life. And you'll always have that gap between intervals where updates get missed.&lt;/p&gt;

&lt;p&gt;But here's the thing polling is dead simple to implement, works everywhere, and for many use cases (dashboards refreshing every 30 seconds, checking job status on a build pipeline, order tracking) it's perfectly fine. Not everything needs to be real-time. Sometimes "close enough" is the right engineering decision.&lt;/p&gt;

&lt;p&gt;The smarter version is &lt;strong&gt;long polling&lt;/strong&gt;, where the server holds the connection open until it actually has something to send back. It's a nice middle ground before committing to WebSockets.&lt;/p&gt;




&lt;h2&gt;
  
  
  WebSockets: When You Need Actual Real-Time
&lt;/h2&gt;

&lt;p&gt;WebSockets maintain a persistent, two-way connection between the client and server. Unlike polling, neither side has to ask data flows both directions whenever either side has something to say.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wss://example.com/socket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onopen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Connected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;updateUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onclose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// You'll want reconnection logic here connections drop&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Disconnected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what powers chat apps, multiplayer games, collaborative editors like Google Docs, and trading platforms where milliseconds matter. If your users need to see changes the moment they happen, and especially if they need to send data back frequently, WebSockets are the right call.&lt;/p&gt;

&lt;p&gt;The tradeoff is complexity. You need to handle reconnections (connections &lt;em&gt;will&lt;/em&gt; drop). You need to think about scaling every connected user holds an open connection on your server. You need to deal with authentication differently than with regular HTTP. It's not hard, but it's more surface area than a simple fetch call.&lt;/p&gt;

&lt;p&gt;My rule of thumb: if you're polling more than once every 5 seconds, it's probably time to consider WebSockets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Server-Sent Events: Real-Time's Simpler Cousin
&lt;/h2&gt;

&lt;p&gt;SSE is the pattern I wish more developers knew about. It's a one-way channel the server pushes updates to the client over a long-lived HTTP connection. No polling, no WebSocket complexity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;eventSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;updateUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;eventSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;eventSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See how much simpler that is compared to WebSockets? And you get automatic reconnection for free the browser handles it.&lt;/p&gt;

&lt;p&gt;SSE is perfect for notifications, live sports scores, progress bars for long-running tasks (think file processing or deployment pipelines), newsfeeds, and anything where the server is doing the talking and the client is just listening.&lt;/p&gt;

&lt;p&gt;The limitation is right there in the name: &lt;em&gt;server-sent&lt;/em&gt;. If your client needs to send data back frequently, SSE isn't enough. But for a surprising number of "real-time" features, one-way is all you need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caching: Making Your App Feel Instant
&lt;/h2&gt;

&lt;p&gt;Caching is less of a fetching pattern and more of a fetching &lt;em&gt;strategy&lt;/em&gt; that you layer on top of other patterns. The idea is simple: store data you've already fetched so you don't have to fetch it again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cacheTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Libraries like React Query and SWR have made caching dramatically easier. They handle stale-while-revalidate (show cached data immediately, then refresh in the background), cache invalidation, and deduplication of simultaneous requests.&lt;/p&gt;

&lt;p&gt;The impact is hard to overstate. When a user navigates to a page they've already visited and the content appears &lt;em&gt;immediately&lt;/em&gt; while a background refresh happens silently that's the kind of thing that makes an app feel native-quality.&lt;/p&gt;

&lt;p&gt;The classic challenge is cache invalidation (there's a reason Phil Karlton called it one of the two hard things in computer science). You have to decide: how long is cached data acceptable? What events should invalidate the cache? What happens when two tabs have different cached versions? These are solvable problems, but they require deliberate thinking.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lazy Loading: Don't Fetch What You Don't Need Yet
&lt;/h2&gt;

&lt;p&gt;The fastest network request is the one you never make. Lazy loading defers fetching until the user actually needs the data typically triggered by scrolling, clicking a tab, or navigating to a new section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadMore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNearBottom&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/items?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextPage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;newItems&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nf"&gt;setNextPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loadMore&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You see this everywhere: infinite scroll on social feeds, images loading as you scroll past them, tabs that only fetch their content when clicked. It makes initial page loads fast because you're only loading what's visible.&lt;/p&gt;

&lt;p&gt;The gotchas are UX-related. Infinite scroll can make it impossible for users to reach the footer. Loading new content can cause layout shifts that make users lose their place. And for accessibility, you need to make sure screen readers can navigate lazy-loaded content properly.&lt;/p&gt;

&lt;p&gt;For very large lists (thousands of items), pair lazy loading with virtualization only render the DOM elements that are visible in the viewport. Libraries like &lt;code&gt;react-window&lt;/code&gt; or &lt;code&gt;tanstack-virtual&lt;/code&gt; make this manageable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Background Sync: Building for the Real World
&lt;/h2&gt;

&lt;p&gt;This one is close to my heart because it solved the biggest pain point in that payment app I worked on last year. Background sync lets users take actions (send a message, submit a form, record a transaction) even when they're offline. The operations get queued and processed automatically when connectivity returns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Service Worker&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync-transactions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;processQueuedTransactions&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Application code&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveToLocalQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Show the transaction in the UI immediately&lt;/span&gt;
  &lt;span class="nf"&gt;updateUIOptimistically&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;registration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync-transactions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern is essential for mobile apps used in areas with unreliable connections field service apps, delivery tracking, healthcare in rural areas, anything where you can't assume a stable connection.&lt;/p&gt;

&lt;p&gt;The complexity lives in conflict resolution. What happens if two offline users edit the same record? What if the server rejects a queued operation? You need clear strategies for these cases, and they're not always straightforward. But the user experience improvement is massive. Going from "you can't do anything without internet" to "everything just works, and syncs when it can" is a night-and-day difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Batch Fetching: One Trip Instead of Ten
&lt;/h2&gt;

&lt;p&gt;If your page makes 8 separate API calls to render, something is probably wrong. Batch fetching combines multiple requests into a single network call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Instead of this:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/users/1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/users/1/posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notifications&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/users/1/notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Do this:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dashboard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/dashboard?userId=1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Returns user, posts, and notifications in one response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The savings come from reducing HTTP overhead connection setup, headers, TLS handshakes. On mobile networks with high latency, the difference between one request and ten is very noticeable.&lt;/p&gt;

&lt;p&gt;The downside is coupling. When you batch things together, you can't cache or invalidate them independently. If the notification data changes every 30 seconds but user profile data changes once a month, batching them means either over-fetching profile data or under-fetching notifications. You have to think about which data actually belongs together.&lt;/p&gt;




&lt;h2&gt;
  
  
  GraphQL: Ask for Exactly What You Need
&lt;/h2&gt;

&lt;p&gt;GraphQL flips the traditional REST model. Instead of the server deciding what data each endpoint returns, the client specifies exactly what it needs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
      posts(last: 5) {
        title
        preview
      }
    }
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/graphql&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With REST, a mobile app and a desktop app hitting the same &lt;code&gt;/api/user&lt;/code&gt; endpoint get the same response even if the mobile app only needs the name and avatar while the desktop app needs the full profile. GraphQL eliminates that mismatch. Each client asks for exactly what it needs.&lt;/p&gt;

&lt;p&gt;This matters most when you have multiple clients with different data requirements, deeply nested data relationships, or you're tired of creating one-off REST endpoints for every new screen design.&lt;/p&gt;

&lt;p&gt;The investment is real, though. You need a GraphQL server, a schema, resolvers, and your team needs to learn a new paradigm. Caching is trickier than REST because everything goes through a single endpoint. And poorly written queries can cause serious performance issues on the backend (the N+1 query problem is very real with GraphQL).&lt;/p&gt;

&lt;p&gt;For smaller apps with a single client, REST with good API design is usually simpler and sufficient.&lt;/p&gt;




&lt;h2&gt;
  
  
  Federated Fetching: Unifying Microservices
&lt;/h2&gt;

&lt;p&gt;In microservice architectures, the data a single page needs might live across five different services. Federated fetching usually through a BFF (Backend-For-Frontend) layer or API gateway aggregates that data so the client makes one clean request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BFF endpoint&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/dashboard/:userId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://user-service/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://account-service/accounts?userId=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://activity-service/recent?userId=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;recentActivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The BFF pattern is a lifesaver in complex systems. Instead of the frontend knowing about every microservice and making separate calls to each, it talks to one unified API that handles the orchestration. The frontend stays clean, and you can tailor responses to what each client actually needs.&lt;/p&gt;

&lt;p&gt;The downside is obvious you're adding another service to build, deploy, and maintain. And if your BFF goes down, everything goes down. It's a pattern that makes sense at a certain scale, but overkill for smaller applications.&lt;/p&gt;




&lt;h2&gt;
  
  
  Combining Patterns: Where It Gets Interesting
&lt;/h2&gt;

&lt;p&gt;No real application uses just one pattern. The interesting decisions happen when you combine them. Here's what that looks like in practice:&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;messaging app&lt;/strong&gt; might use WebSockets for incoming messages, background sync for sending messages in poor connectivity, caching for conversation history, and lazy loading for scrolling through older messages.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;e-commerce app&lt;/strong&gt; might use request-response for search, caching for product pages, SSE for inventory availability, and batch fetching for the cart summary.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;trading platform&lt;/strong&gt; might use WebSockets for live prices, polling as a fallback, GraphQL for portfolio data, and caching for historical charts.&lt;/p&gt;

&lt;p&gt;The point is to match each data need to the pattern that best serves it. Not every piece of data on a screen has the same freshness requirements, the same access patterns, or the same tolerance for latency.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Use When&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;th&gt;Offline Support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request-Response&lt;/td&gt;
&lt;td&gt;Standard CRUD, simple pages&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polling&lt;/td&gt;
&lt;td&gt;Periodic updates, status checks&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSockets&lt;/td&gt;
&lt;td&gt;Two-way real-time (chat, collaboration)&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-Sent Events&lt;/td&gt;
&lt;td&gt;One-way real-time (notifications, feeds)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Repeated data access, speed matters&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lazy Loading&lt;/td&gt;
&lt;td&gt;Large lists, heavy initial loads&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background Sync&lt;/td&gt;
&lt;td&gt;Offline-first, unreliable connections&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch Fetching&lt;/td&gt;
&lt;td&gt;Multiple related data needs&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GraphQL&lt;/td&gt;
&lt;td&gt;Complex/varied data requirements&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Federated Fetching&lt;/td&gt;
&lt;td&gt;Microservices, unified APIs&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;A year ago, when that payment app went from 3.2 stars to 4.7, we didn't add a single new feature. We just changed &lt;em&gt;how&lt;/em&gt; existing features got their data. Caching made it feel instant. Background sync made it work offline. WebSockets made payments confirm in real-time. Batch fetching cut load times by 80%.&lt;/p&gt;

&lt;p&gt;Looking back, that project taught me something I keep coming back to: users don't care about your architecture. They care that things are fast, reliable, and don't waste their time. Data fetching patterns on both the backend and the frontend are how you deliver on that promise.&lt;/p&gt;

&lt;p&gt;Pick the right pattern for each situation. Combine them thoughtfully. And when your app ratings start climbing, you'll know why.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>fintech</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
