# Dynamic Mapping Engine: Bridging Legacy and Modern Healthcare

---

[← Part 2: Multi-Tenant Architecture](./02-multi-tenant-architecture.md)

## The Legacy Database Problem

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

**Hospital A** stores patients like this:

```sql
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:

```sql
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**:

```json
{
  "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?**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768322800244/4a27b149-e8f6-4561-a0a2-0cfd5ad7a88b.png align="center")

## 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**:

```rust
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)
    

```rust
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(())
    }
}
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768322856010/c37ed5ac-42ad-4a9c-8d61-fb9838223694.png align="center")

## 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:

```rust
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: `1` → `true`, `42` → `"42"`, etc.
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768322904859/06e87a5e-3578-4465-96f7-8c87ba14565b.png align="center")

## 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/YYYY` → `YYYY-MM-DD`
    

I built a **transformation system**:

```rust
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:

```json
{
  "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:

```rust
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`:

```rust
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:**

```json
{
  "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
}
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768322958443/0442d118-9d3d-4e48-a380-a1b48f14e684.png align="center")

## Edge Cases and Gotchas

### 1\. Sparse Array Indices

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

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

**Solution:** Clean empty objects after mapping:

```rust
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:

```rust
// 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**:

```rust
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!
