Hook: “Why can’t my EMR just see the TB test result already in DHIS2?” Because DHIS2 isn’t natively FHIR and nothing was translating.

Problem

AspectDHIS2 StrengthGap for FHIR Interop
AggregationStrong analytics / reportingFHIR expects resource-level semantics
Tracker DataRich program eventsNot exposed as FHIR resources
CodingLocal option setsNeeds SNOMED / LOINC mapping
TimeOften local timesFHIR prefers normalized (UTC) timestamps
ExchangePull/report centricOther systems want push / subscription style

Challenges:

  • Inconsistent codes (local vs SNOMED/LOINC)
  • Incremental sync (lastUpdated) vs bulk bootstrap
  • Time zone normalization (store / emit UTC)
  • Idempotent upserts (avoid duplicates)

Architecture Sketch

[DHIS2 API] --> [Adapter Service (.NET)] --> [FHIR Resources JSON] --> [National / EMR FHIR Server]
                    |                        \
                    |                         +--> Metrics / Logs / Dead-letter
                    +--> Code Mapping Store

Roles:

  • Adapter Service: Periodically pulls tracker/events (paged + lastUpdated filter), maps to FHIR, upserts.
  • Code Mapping Store: Local config (JSON / DB) or terminology service for code translation.
  • Observability: Metrics (mapped %, unmapped codes), structured logs, retry queue (optional).

Mapping Strategy

  1. Define explicit mapping contracts per DHIS2 program → FHIR resource(s).
  2. Normalize attributes (dates → UTC, gender enumerations, identifier formats).
  3. Translate option set codes to standard terminologies (LOINC / SNOMED) via dictionary.
  4. Construct FHIR resources using a typed SDK (Firely .NET SDK or HAPI FHIR for other stacks).
  5. Upsert (PUT) using stable IDs to ensure idempotency.

Mapper Interface

public interface IFhirMapper<TSource>
{
    Resource Map(TSource source);
}

Patient Mapper (Tracked Entity → Patient)

public class PatientMapper : IFhirMapper<Dhis2TrackedEntity>
{
    public Resource Map(Dhis2TrackedEntity e) => new Patient
    {
        Id = e.Id,
        Identifier = new List<Identifier>
        {
            new Identifier("http://mohp.gov.np/patient-id", e.Attributes["nationalId"])
        },
        Name = new List<HumanName>
        {
            new HumanName { Family = e.Attributes["lastName"], Given = new[] { e.Attributes["firstName"] } }
        },
        Gender = e.Attributes["gender"].Equals("male", StringComparison.OrdinalIgnoreCase)
            ? AdministrativeGender.Male : AdministrativeGender.Female,
        BirthDate = e.Attributes["dob"]
    };
}

Observation Mapper (Lab Event → Observation)

public class ObservationMapper : IFhirMapper<Dhis2Event>
{
    public Resource Map(Dhis2Event ev) => new Observation
    {
        Id = ev.EventId,
        Status = ObservationStatus.Final,
        Subject = new ResourceReference($"Patient/{ev.TrackedEntityId}"),
        Code = new CodeableConcept("http://loinc.org", "94531-1", "SARS-CoV-2 RNA Pnl"),
        Effective = new FhirDateTime(ev.EventDate.ToUniversalTime()),
        Value = new CodeableConcept(
            "http://snomed.info/sct",
            ev.DataValues["resultCode"],
            ev.DataValues["resultText"])
    };
}

Upload (Idempotent Upsert)

public class FhirClientService
{
    private readonly FhirClient _client;
    public FhirClientService(string baseUrl) => _client = new FhirClient(baseUrl);

    public async Task UpsertAsync(Resource resource)
        => await _client.UpdateAsync(resource); // PUT ensures idempotency
}

Failure Modes & Mitigations

FailureCauseMitigationMetric
Code mismatchLocal option not mappedMapping dictionary + alert on unknownUnmapped code count
Time driftLocal timezonesAlways convert to UTC at ingestion% events w/ UTC normalized
Partial syncAPI timeout / paging stopLastUpdated checkpoint & resume tokensGap minutes since last sync
Duplicate resourcesRe-sent eventsStable FHIR IDs (PUT)Duplicate reject count
Invalid FHIRSchema / required fields missingPre-upload validation (Validator.Validate)Validation failure count

Observability additions:

  • Metrics: mapped_ratio, unmapped_codes, sync_latency_seconds, validation_failures.
  • Logs: correlation id per sync batch, counts (fetched/processed/failed), first failing event id.
  • Alerts: unmapped_codes > threshold; validation_failures spike; sync latency SLA breach.

What I’d Do Differently

  • Build a small mapping DSL (YAML/JSON) to externalize transformations.
  • Integrate a terminology service (SNOMED CT / LOINC) instead of static dictionaries.
  • Add FHIR validation gate early (fail fast before sending invalid resources).
  • Support streaming / subscription (e.g., DHIS2 change notifications) to cut latency.

Checklist (Apply Tomorrow)

  • Identify DHIS2 programs → target FHIR resource types.
  • Create mapping dictionaries (local → SNOMED / LOINC).
  • Implement Patient / Observation mappers.
  • Normalize all timestamps to UTC.
  • Idempotent upsert via stable IDs (PUT) + retry policy.
  • Pre-upload validation & metrics instrumentation.
  • Dashboards: mapped %, unmapped codes, sync latency, failures.
  • Alert policies (unmapped surge, validation failures, latency breach).

Resources

👉 Start small: map one tracker program to one FHIR resource, measure, then expand.