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,birthDateArray access:
name[0],identifier[1]Nested fields:
name[0].family,identifier[0].valueArray 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:
Reads value from database (e.g.,
TINYINT 1)Checks if FHIR expects a specific type
Converts:
1→true,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_nameintoname[0].givenDate formatting:
DD/MM/YYYY→YYYY-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!

