Skip to main content

Command Palette

Search for a command to run...

Dynamic Mapping Engine: Bridging Legacy and Modern Healthcare

Updated
7 min read
Dynamic Mapping Engine: Bridging Legacy and Modern Healthcare

← Part 2: Multi-Tenant Architecture

The Legacy Database Problem

Here's a real example from one of our tenants:

Hospital A stores patients like this:

CREATE TABLE pacientes (
    id_paciente INT PRIMARY KEY,
    rut_pac VARCHAR(12),
    nom_pac VARCHAR(100),
    ap_pat_pac VARCHAR(50),
    ap_mat_pac VARCHAR(50),
    fec_nac_pac DATE,
    sexo_pac CHAR(1)  -- 'M' or 'F'
);

Hospital B stores patients like this:

CREATE TABLE usuarios (
    id_usr INT PRIMARY KEY,
    rut_usr VARCHAR(15),
    nombre_usr VARCHAR(150),
    fecha_nacimiento DATE,
    usr_activo TINYINT  -- 0 or 1
);

Both need to map to the same FHIR Patient resource:

{
  "resourceType": "Patient",
  "id": "12345",
  "identifier": [{"value": "12345678-9"}],
  "name": [{"family": "Garcia", "given": ["Juan"]}],
  "birthDate": "1985-03-15",
  "gender": "male",
  "active": true
}

The challenge: How do we map arbitrary database schemas to FHIR without writing custom code for each tenant?

Solution: Dynamic Mapping Configuration

Instead of code generation or hardcoded mappings, I built a configuration-driven mapping engine that translates between databases and FHIR at runtime.

The Mapping Model

Each tenant defines table mappings and field mappings:

pub struct TableMapping {
    pub fhir_resource_type: String,      // "Patient"
    pub database_table_name: String,     // "pacientes" or "usuarios"
    pub database_schema: Option<String>, // Some tenants use schemas
}

pub struct FieldMapping {
    pub fhir_path: String,          // "name[0].family"
    pub database_column: String,    // "ap_pat_pac"
    pub data_type: DataType,        // String, Integer, Boolean, Date
    pub transformation_id: Option<i32>,  // Optional data transformation
    pub is_required: bool,
    pub is_primary_key: bool,
}

Tenants configure these via an admin panel (more on that in Part 5).

FHIR Path Parser: Navigating Nested Structures

FHIR is deeply nested. A patient's family name isn't just name—it's name[0].family.

I built a FHIR path parser that handles:

  • Simple fields: id, gender, birthDate

  • Array access: name[0], identifier[1]

  • Nested fields: name[0].family, identifier[0].value

  • Array filters: identifier[use='official'].value (future)

pub enum PathSegment {
    Field(String),           // "name"
    ArrayIndex(usize),       // [0]
    ArrayFilter {            // [use='official']
        key: String,
        value: String,
    },
}

pub struct FhirPath {
    segments: Vec<PathSegment>,
}

impl FhirPath {
    // Parse "name[0].family" into segments
    pub fn parse(path: &str) -> Result<Self> {
        let mut segments = Vec::new();
        let mut current = String::new();

        for ch in path.chars() {
            match ch {
                '.' => {
                    if !current.is_empty() {
                        segments.push(PathSegment::Field(current.clone()));
                        current.clear();
                    }
                }
                '[' => {
                    if !current.is_empty() {
                        segments.push(PathSegment::Field(current.clone()));
                        current.clear();
                    }
                    // Parse array index or filter...
                }
                _ => current.push(ch),
            }
        }

        if !current.is_empty() {
            segments.push(PathSegment::Field(current));
        }

        Ok(FhirPath { segments })
    }

    // Set a value in FHIR JSON using this path
    pub fn set(&self, resource: &mut JsonValue, value: JsonValue) -> Result<()> {
        let mut current = resource;

        for (i, segment) in self.segments.iter().enumerate() {
            let is_last = i == self.segments.len() - 1;

            match segment {
                PathSegment::Field(name) => {
                    if is_last {
                        current[name] = value.clone();
                        return Ok(());
                    } else {
                        // Navigate deeper, creating structure if needed
                        if !current.get(name).is_some() {
                            // Check what the next segment needs
                            if matches!(self.segments.get(i + 1), Some(PathSegment::ArrayIndex(_))) {
                                current[name] = json!([]);  // Create array
                            } else {
                                current[name] = json!({});  // Create object
                            }
                        }
                        current = &mut current[name];
                    }
                }
                PathSegment::ArrayIndex(idx) => {
                    if let JsonValue::Array(arr) = current {
                        // Ensure array is large enough
                        while arr.len() <= *idx {
                            arr.push(json!({}));
                        }

                        if is_last {
                            arr[*idx] = value.clone();
                            return Ok(());
                        } else {
                            current = &mut arr[*idx];
                        }
                    }
                }
                _ => {}
            }
        }

        Ok(())
    }
}

Type Inference: When the Database Lies

Here's a nasty surprise: MySQL stores booleans as TINYINT (0 or 1). But FHIR expects true or false.

Similarly, many legacy systems store IDs as INT, but FHIR requires them as strings.

I built a type inference system that knows the FHIR spec:

fn infer_fhir_type_from_path(fhir_path: &str) -> Option<DataType> {
    let path_lower = fhir_path.to_lowercase();

    // Boolean fields
    if path_lower == "active"
        || path_lower.ends_with(".active")
        || path_lower == "deceased" {
        return Some(DataType::Boolean);
    }

    // String fields (even if DB stores as INT)
    if fhir_path == "id"
        || path_lower.ends_with(".id")
        || path_lower.contains("identifier") {
        return Some(DataType::String);
    }

    // Date fields
    if path_lower == "birthdate"
        || path_lower.ends_with(".date") {
        return Some(DataType::Date);
    }

    None  // Use DB type
}

During mapping, the engine:

  1. Reads value from database (e.g., TINYINT 1)

  2. Checks if FHIR expects a specific type

  3. Converts: 1true, 42"42", etc.

Transformations: Beyond Simple Type Conversion

Sometimes you need more than type conversion. Examples:

  • Enum mapping: 'M'"male", 'F'"female"

  • String combination: Join first_name + last_name into name[0].given

  • Date formatting: DD/MM/YYYYYYYY-MM-DD

I built a transformation system:

pub enum TransformationType {
    EnumMapping,    // Map values (M → male)
    Combine,        // Join array elements
    Split,          // Split string into array
    Format,         // Date/time formatting
    Conditional,    // If-then-else logic
}

pub struct Transformation {
    pub name: String,
    pub transformation_type: TransformationType,
    pub configuration: JsonValue,  // Type-specific config
}

Example transformation for gender:

{
  "name": "Gender M/F to FHIR",
  "transformation_type": "enum_mapping",
  "configuration": {
    "M": "male",
    "F": "female",
    "I": "other"
  }
}

The transformation engine applies these before setting the FHIR value:

pub fn transform_to_fhir(
    &self,
    value: &DbValue,
    transformation_id: Option<i32>,
    target_type: Option<DataType>,
) -> Result<JsonValue> {
    // 1. Convert DB value to JSON with type awareness
    let mut json_value = dbvalue_to_json_with_type(value, target_type);

    // 2. Apply transformation if specified
    if let Some(id) = transformation_id {
        if let Some(transformation) = self.transformations.get(&id) {
            json_value = self.apply_transformation(&json_value, transformation)?;
        }
    }

    Ok(json_value)
}

The Mapping Process: Putting It All Together

When a request comes in for GET /fhir/Patient/123:

pub async fn to_fhir(
    &self,
    resource_type: &str,
    db_row: &HashMap<String, DbValue>,
) -> Result<JsonValue> {
    // 1. Get field mappings for this resource type
    let table_mapping = self.config.get_table_mapping(resource_type)?;
    let field_mappings = &table_mapping.field_mappings;

    // 2. Start with base FHIR resource
    let mut fhir_resource = json!({
        "resourceType": resource_type
    });

    // 3. Map each field
    for field_mapping in field_mappings {
        // Get database value
        let db_value = match db_row.get(&field_mapping.database_column) {
            Some(val) if !matches!(val, DbValue::Null) => val,
            _ => continue,  // Skip missing/null values
        };

        // Infer expected FHIR type
        let expected_type = infer_fhir_type_from_path(&field_mapping.fhir_path);

        // Transform value
        let fhir_value = self.transform_engine.transform_to_fhir(
            db_value,
            field_mapping.transformation_id,
            expected_type,
        )?;

        // Set value using FHIR path
        let fhir_path = FhirPath::parse(&field_mapping.fhir_path)?;
        fhir_path.set(&mut fhir_resource, fhir_value)?;
    }

    // 4. Clean up empty objects in arrays
    clean_empty_objects(&mut fhir_resource);

    Ok(fhir_resource)
}

Result:

{
  "resourceType": "Patient",
  "id": "123",
  "active": true,              // Converted from TINYINT 1
  "identifier": [
    {"value": "12345678-9"}    // From rut_pac column
  ],
  "name": [{
    "family": "Garcia",        // From ap_pat_pac
    "given": ["Juan"]          // From nom_pac
  }],
  "gender": "male",            // Transformed from 'M'
  "birthDate": "1985-03-15"    // From fec_nac_pac
}

Edge Cases and Gotchas

1. Sparse Array Indices

When a mapping uses identifier[1].value, the path parser creates empty objects at [0]:

{
  "identifier": [{}, {"value": "X"}]  // ❌ Breaks deserialization
}

Solution: Clean empty objects after mapping:

fn clean_empty_objects(value: &mut JsonValue) {
    if let JsonValue::Array(arr) = value {
        arr.retain(|item| {
            if let JsonValue::Object(map) = item {
                !map.is_empty()  // Keep only non-empty objects
            } else {
                true
            }
        });
    }
    // Recurse into nested structures...
}

2. NULL Values

Database NULL ≠ JSON null. Omit the field entirely:

// Skip NULL values unless required
if matches!(db_value, DbValue::Null) {
    if field_mapping.is_required {
        return Err("Required field is NULL");
    }
    continue;  // Skip this field
}

3. Bidirectional Mapping

Creating a Patient means going FHIR → DB:

pub fn to_db(
    &self,
    resource_type: &str,
    fhir_resource: &JsonValue,
) -> Result<HashMap<String, DbValue>> {
    let mut db_row = HashMap::new();

    for field_mapping in field_mappings {
        let fhir_path = FhirPath::parse(&field_mapping.fhir_path)?;
        let fhir_value = fhir_path.get(fhir_resource)?;

        // Reverse transformation
        let db_value = self.transform_engine.transform_to_db(
            &fhir_value,
            field_mapping.transformation_id,
        )?;

        db_row.insert(field_mapping.database_column.clone(), db_value);
    }

    Ok(db_row)
}

Performance Considerations

The Good:

  • Type inference is O(1) (string comparison)

  • Path parsing is cached per-mapping

  • Transformations are pure functions (no I/O)

The Tradeoff:

  • Dynamic mapping is ~2x slower than hand-coded (15ms vs 8ms)

  • But it means zero code for new tenants

  • Worth it for our use case

Coming Up: Security and Observability

The mapping engine handles what data gets transformed. But we also need to know who accessed what data and when.

In Part 4, we'll cover:

  • Authentication and authorization with Keycloak

  • Audit logging for HIPAA compliance

  • Rate limiting and circuit breakers

  • Monitoring what matters


Discussion: How do you handle schema evolution in your systems? Code generation vs runtime configuration? Let me know your approach!