Case Study · Clínica Alemana · 2025–2026

Medical Templates Framework —
From PL/SQL Sprawl to Metadata.

320+ medical templates, one ingestion engine. The bottleneck was never the code — it was understanding what the variables meant, how the template flowed, and how the data travelled. Standardize the comprehension and the rest collapses from one month per template to one hour.

320+ Templates ingested
1 month → 1 hour Time per new template
−99% Time-to-insight cut
0 Code deploys per change

2025 — 2026 · Production framework · Role: Data Engineer & Platform Architect

BigQuery Python Oracle JSON-native Standard SQL CDC / Snapshot Looker Studio GCP Labels PARSE_JSON JSON_KEYS Scheduled Queries

A 320-template hospital doesn't have a templates problem. It has a schema-drift problem.

Clínica Alemana de Santiago is one of Chile's largest private hospitals. Inside its EHR, doctors use more than 320 medical templates — forms for hospitalization, surgery, oncology follow-up, home care, every clinical workflow has its own. Each template has dozens of variables that change as protocols evolve.

The legacy system was a constellation of PL/SQL stored procedures — one per template, each one a PRC_PLANT_* with thousands of hardcoded IF-ELSIF branches mapping vertical key-value rows to relational columns. When a doctor added a new variable in production, the procedure either failed silently or dropped the data on the floor.

Every change required engineering intervention. The data team became a bottleneck for clinical operations.

Before the framework

The cost wasn't typing — it was understanding. A new template took ≈1 month per developer, not because they wrote code daily, but because they had to decode what each variable meant, how the template rendered, how the data travelled from Oracle to the report. Domain knowledge lived in the heads of senior staff, not in code.

After the framework

The mock template (the form layout) is now the only artefact a developer needs. The framework infers the rest from metadata. Onboarding a template: ≈1 hour. Same engine, same SQL, same DDL — only the metadata changes. The understanding is encoded once, in the data, not re-decoded per template.

One engine, N templates. The schema is data, not code.

The bet was simple: treat the template structure as metadata in BigQuery, not as columns in physical tables. A single ingestion engine reads the metadata, normalizes names to snake_case, and emits idempotent SQL deltas — the same engine handles 320 templates without a single line of template-specific code.

Variable values live as json_general objects per module/group, leveraging BigQuery's native PARSE_JSON, JSON_KEYS and JSON_TYPE. This means one physical table supports all templates without ever altering the schema. Adding a new template is a data event, not a deploy.

Weekly cycle · Mon → Fri
MON
Extract

01_global_variables_v2.sql
JSON per template

TUE
Reconcile

run_template.py
3-level matching

WED
Apply

delta.sql + ddl.sql
BigQuery merge

THU
Close CDC

close_cdc.sql
PROCESSED_INGESTION

FRI
Snapshot

Scheduled Query
auto-detect drift

Catalog tables · single source of truth
CREATE TABLE template_field_catalog -- field dictionary, soft-delete CREATE TABLE template_value_catalog -- variable_id ↔ field name mapping CREATE TABLE template_module_order_catalog -- module ordering per template CREATE TABLE template_registry_snapshot -- weekly catalog photo, partitioned CREATE TABLE template_registry_cdc_log -- detected changes, clustered CREATE VIEW v_template_{id} -- per-template flat view, auto-rebuilt

From a doctor's click to a Looker dashboard — six hops, zero manual mappings.

Plantilla 329 has 67 fields spread across 5 clinical modules. The doctor fills the form in the EHR, Oracle stores it vertically, the framework normalizes it to JSON, BigQuery flattens it to a view, and Looker renders it. At no point does a human write IF variable_id = 41892 THEN nombre_antibiotico_3. The metadata drives everything.

Doctor fills
template 329
Oracle stores
vertical rows
JSON export
via 01_variables
Framework
reconciles
BQ catalog
+ json_general
View flattens
w/ JSON_VALUE
Looker
dashboard

Template 329 · Home Hospitalization HOSDOM

Treatment / Follow-up · 67 fields · 5 clinical modules

Treatment plan
Select an option
Enter a value
Antibiotics
Physical therapy
Oxygen therapy
Speech therapy
Occupational therapy
TENS
Enter a value
Date
Date
7
Attending physician main diagnosis
Select an option
WhatsApp
Phone call
Email
Follow-up plan
Date
In-person
Telemedicine
Home visit
Yes
No

Reconciliation isn't a SQL JOIN. It's a 3-level fallback that tolerates schema drift.

The engine — plantilla_ingestor_v5.py — compares the new Oracle JSON against the current BigQuery state and decides, per field, whether it's new, updated, deactivated, or unchanged. The hard part isn't the comparison; it's that the catalog has been written by multiple versions of the script over time. UUID vs MD5 IDs, stopword stripping that changed, module names that drifted (datos_paciente vs datos_de_paciente).

A naive join produces 87 false positives where there are 35 real changes. The fix: a three-level resolver that tries id_registro first, falls back to natural key, and finally to drift-tolerant matching.

Resolver levels

  • Level 1 — id_registro exact match When BQ was loaded with the same MD5 algorithm. Hash hits → record is identical.
  • Level 2 — natural key (module + field) When BQ has UUIDs from GENERATE_UUID(). Same module + same field name → same conceptual record.
  • Level 3 — drift-tolerant match When module names changed across script versions. Strip stopwords, compare. Catches the 52 false renames a naive matcher misses.

Immutability guard

  • aplicar_inmutabilidad() Before reconciliation, force new field names to match their historical names in BQ (matched by Oracle variable_id). This protects 200+ existing dashboards from cosmetic name changes.
  • Soft delete only Fields are never removed from catalogo_campos — they're flagged activo = FALSE with a termination date. History stays queryable.
  • Idempotent by design Running the same JSON twice produces identical SQL. id_registro is deterministic MD5 — no auto-increments, no UUIDs in the engine's output.
Real run, plantilla 329 (Hospital Home Care)
Naive matching
+87 / −79

What a one-level join would say happened — mostly false noise from schema-version drift.

3-level resolver
+35 / ~52 / −34

Real changes plus 52 silent renames preserved. Over half the apparent diff was version drift, not Oracle drift.

Generated SQL
1,258 lines

INSERTs, UPDATEs and soft-deletes — fully idempotent. Re-runnable until the end of time.

From Oracle key-value rows to idempotent BigQuery SQL — the actual transformation.

What follows is a real run on plantilla 329 (Hospital Home Care). On the left, the JSON exported from Oracle — vertical, flat, semantically opaque without context. On the right, what the engine produces — categorized, hashed, idempotent SQL ready to merge into BigQuery.

Nothing was hand-edited between input and output. The same engine handles all 320 templates with this exact contract.

Note on language: Production code is in Spanish (hospital standard). Field names like plan_tratamiento and nombre_antibiotico_3 translate to treatment_plan and antibiotic_name_3. The framework preserves whatever naming convention Oracle uses — no translation layer.

Input · Oracle JSON (extract)
[ { "template_id": 329, "group": "Treatment plan", "variable_name": "Antibiotic name 3", "variable_id": 41892, "group_id": 1247, "variable_row": 0, "variable_col": 2 }, { "template_id": 329, "group": "Treatment plan", "variable_name": "Antibiotic duration 3", "variable_id": 41893, ... } ]
3-level reconciler
Output · 329_delta.sql (extract)
-- NEW in catalogo_campos_plantilla (35) INSERT INTO catalogo_campos_plantilla ( id_registro, plantilla_id_origen, module_estandar, nombre_campo_estandar, orden, activo, fecha_inicio_vigencia ) VALUES ('c022332d9b86f519...',329, 'plan_de_tratamiento', 'nombre_antibiotico_3', 42,TRUE,'2026-04-27'), ('35b8bcd4fffebedd...',329, 'plan_de_tratamiento', 'duracion_antibiotico_3', 50,TRUE,'2026-04-27'); -- DELETIONS · soft-delete (preserves history) UPDATE catalogo_campos_plantilla SET activo = FALSE, fecha_termino_vigencia = '2026-04-27' WHERE id_registro = 'a4f2...';

The view writes itself. JSON_KEYS reads the catalog, generates the DDL.

The final view for plantilla 329 has 67 flattened columns. Each one is a JSON_VALUE extraction from json_general. Without the framework, those 67 columns would have been 67 hardcoded IF-ELSIF branches in PL/SQL. With the framework, they're generated automatically by reading the JSON structure that's already stored in BigQuery.

This is reverse engineering as a design pattern. The schema doesn't dictate the data — the data dictates the schema. When Oracle adds a field, it appears in the JSON. The DDL constructor reads the JSON keys, writes the new column, and rebuilds the view. No human touches the SQL.

The final view · 67 columns from JSON paths
CREATE OR REPLACE VIEW `analytics_ds.home_hospitalization_treatment_followup` AS SELECT module_incident_id, template_id, record_id, created_date, -- Patient data (7 fields) JSON_VALUE(json_general['patient_data']['patient_phone'], '$') AS patient_data_patient_phone, JSON_VALUE(json_general['patient_data']['rest_address'], '$') AS patient_data_rest_address, JSON_VALUE(json_general['datos_paciente']['district'], '$') AS patient_data_district, -- Treatment plan (42 fields) JSON_VALUE(json_general['treatment_plan']['main_diagnosis'], '$') AS treatment_plan_main_diagnosis, JSON_VALUE(json_general['treatment_plan']['antibiotic_name_2'], '$') AS treatment_plan_antibiotic_name_2, JSON_VALUE(json_general['plan_tratamiento']['antibiotic_duration_2'], '$') AS treatment_plan_antibiotic_duration_2, -- Attending physician (3 fields) JSON_VALUE(json_general['attending_physician']['specialty'], '$') AS attending_physician_specialty, -- Follow-up plan (8 fields) JSON_VALUE(json_general['followup_plan']['proposed_control_date'], '$') AS followup_plan_proposed_control_date, -- Sincerely (2 fields) JSON_VALUE(json_general['sincerely']['physician_name'], '$') AS sincerely_physician_name, JSON_VALUE(json_general['sincerely']['physician_id'], '$') AS sincerely_physician_id FROM `transform_ds.template_registry` WHERE plantilla_id = 329;
The DDL constructor · 08_constructor_ddl_plantillas.sql
-- Step 1: Sample the JSON to discover keys WITH sampled AS ( SELECT template_id, json_general AS j FROM template_registry WHERE template_id = 329 LIMIT 1 ), -- Step 2: Extract top-level modules using JSON_KEYS blocks AS ( SELECT template_id, k AS module, j[k] AS module_json FROM sampled, UNNEST(JSON_KEYS(j)) AS k WHERE JSON_TYPE(j[k]) = 'object' ), fields dentro de cada módulo">-- Step 3: Extract fields within each module fields AS ( SELECT template_id, module, subk AS field, CONCAT( 'JSON_VALUE(json_general[', "'", module, "']['", subk, "'], '$') AS ", LOWER(module || '_' || subk) ) AS ddl_column FROM blocks, UNNEST(JSON_KEYS(module_json)) AS subk ), -- Step 4: Aggregate all columns into a SELECT statement complete_ddl AS ( SELECT STRING_AGG(ddl_column, ',\n ' ORDER BY module, field) AS select_cols FROM fields ) SELECT CONCAT( 'CREATE OR REPLACE VIEW v_template_329 AS SELECT\n ', select_cols, '\nFROM template_registry WHERE template_id = 329;' ) AS ddl_output FROM complete_ddl;

The constructor runs once per template whenever the catalog changes. It reads the actual JSON stored in BigQuery, discovers the structure with JSON_KEYS, and writes the DDL. The analyst copies the output, pastes it into BigQuery, and the view rebuilds.

This is the opposite of schema-first design. The data defines the schema. When Oracle evolves, the JSON evolves, the constructor re-reads it, and the DDL regenerates. Zero migrations, zero ALTER TABLE, zero manual column mapping.

The wizard turns 8 flags into 7 questions. Anyone runs it.

Clinical analysts run this — not engineers. The original argparse CLI required memorizing 8 flags and remembering output paths. The wizard asks one question at a time, validates each answer in place, and confirms the plan before executing. Output goes to the script's folder, period — no "where do you want this?" prompt that shifts cognitive load to the operator.

~/plantillas_control · python run_plantilla.py
══════════════════════════════════════════════════════════════ MEDICAL TEMPLATE INGESTION ══════════════════════════════════════════════════════════════ ── 1 · Identification ──────────────────────────────── Template ID to process: 329 JSON path [plantilla_329.json]: variables_329_20260427.json Start date [2026-04-27]: (Enter) ── 2 · BQ state reconciliation ─────────────────────── Do you have the 3 BQ state files? (y/n): y bq_campos.json file [bq_campos.json]: (Enter) bq_valores.json file [bq_valores.json]: (Enter) bq_modulos.json file [bq_modulos.json]: (Enter) Confirm and run? (y/n): y [1/8] Validate input files [2/8] Validate Oracle JSON data 486 rows, plantilla_id=329 [3/8] Process and reconcile catalog +35 ~52 -34 =0 (3-level resolver) [4/8] Generate SQL delta 1,258 lines [5/8] Generate Data Studio view DDL v_template_329 [6/8] Generate revision Excel 4 sheets, 6 collisions [7/8] Register execution SHA-256 786f406c… [8/8] Generate CDC cycle close ✓ COMPLETED plantilla 329 · 3.2s · DELTA

What a single weekly run actually moves through the catalog.

The naive matcher would have reported 87 new fields and 79 deletions for plantilla 329 — a panic-inducing diff. The 3-level resolver tells the truth: 35 genuinely new fields, 52 silent renames preserved, 34 actual soft-deletes. Most of the apparent churn was schema drift between script versions, not Oracle drift.

NEW fields
+35
UPDATED
~121
SOFT-DELETE
−34
VALUES new
+92
VALUES del
−67
MODULES Δ
~12
132 INSERTs · 125 UPDATEs · 70 DELETEs
1,258 lines of generated SQL

Without the framework, this run would have been a one-month engineering ticket. With the framework, it's the output of one Python invocation. Re-runnable. Idempotent. Auditable.

Every run produces an Excel for human validation. Color-coded. Sortable. Auditable.

Before the SQL ever touches BigQuery, the analyst opens the auto-generated Excel and reviews what's about to change. Green is new, amber is updated, red is soft-delete. The collisions tab flags fields whose name appears in more than one module — the engine resolves them with deterministic prefixes, but the human gets the final say.

field_detail
collisions_to_review
value_detail
omitted_fields
Status
Module
Field
Action
NEW
plan_de_tratamiento
nombre_antibiotico_3
INSERT
NEW
plan_de_tratamiento
duracion_antibiotico_3
INSERT
UPD
datos_de_paciente
comuna ← was: comuna_paciente
UPDATE
UPD
plan_de_seguimiento
nombre_medico_tratante ← module renamed
UPDATE
DEL
atentamente
firma (no longer in Oracle)
SOFT-DEL
DEL
atentamente
confirmacion_tratancia_plan_terapeutico
SOFT-DEL

The cycle closes itself. Friday's CDC is the next Monday's worklist.

The Friday CDC is a Scheduled Query that compares the current catalog against the previous week's snapshot and writes detected changes into registro_plantillas_cdc_log with state PENDIENTE. The dashboard shows it. The next Monday, the analyst downloads the state for those exact templates — no need to know which templates need attention, the CDC tells them.

The orchestrator generates the closing SQL itself — every run produces a cierre_cdc.sql that flips the processed records to PROCESADO_INGESTA. Without that file, the next CDC re-detects the same changes as pending. The cycle's self-closing is what makes the system operational at scale.

What CDC tracks

  • NUEVA_VARIABLENew field added in Oracle
  • BAJA_VARIABLEField removed from Oracle (soft-delete in BQ)
  • CAMBIO_MODULOField moved between sections
  • CAMBIO_NOMBREField renamed in Oracle
  • CAMBIO_COORDENADAS_ORDENLayout/ordering change

GCP data governance

  • Labels at DDL time Every CREATE TABLE ships with dg-layer, dg-source, dg-domain, dg-has-pii and dg-data-classification. No retroactive tagging.
  • TRF / CNS layering Transformation tables in ds_trf_datalake_nucleo, analytics views in ds_cns_datalake_nucleo. Strict separation prevents reporting from coupling to upstream changes.
  • Partitioning & clustering Snapshot partitioned by fecha_snapshot, CDC log clustered by (plantilla_id, tipo_cambio, estado_revision) for cheap Looker queries.

The orchestrator is a wizard. The output is a folder. The audit is a JSONL.

The original argparse CLI required the operator to remember 8 flags. That was wrong for the user — clinical analysts run this, not engineers. run_plantilla.py now opens an interactive wizard: step-by-step questions, smart defaults, validation in-place. No flags, no manual, no decisions about output paths.

Every execution writes to a deterministic folder structure ({id}/ for individual runs, bloque_{date}/ for batches), logs to a single ejecuciones.jsonl audit file with SHA-256 hashes of every input, and ends by printing the next 4 manual steps in execution order.

8 steps
0 flags

Validate · Validate · Reconcile · Generate SQL · Generate DDL · Excel · Audit · Close CDC. Each one fail-fast, idempotent.

Output
5 files

delta.sql · ddl_vista.sql · revision.xlsx · cierre_cdc.sql · run.log. All timestamped, all in one folder, all idempotent.

Audit
JSONL

Every run appends a record with SHA-256, counts, timestamp and final state. No execution is ever lost.

The decisions I'd defend in any interview — and the ones I had to rethink.

Decisions I owned

  • Metadata-driven over schema-per-template. Refused to add columns. JSON-native in BigQuery means 320 templates share one physical table. Adding template 321 is zero infra work.
  • Soft delete, never hard delete. catalogo_campos_plantilla is immutable. Fields removed in Oracle stay in the catalog with activo = FALSE. Historical reports keep working forever.
  • CDC drives the worklist, not the calendar. The Monday queue isn't "all 320 templates" — it's only the ones with PENDIENTE changes. Operator sees a clean worklist, not a haystack.
  • Wizard over CLI flags. Removed argparse for the operator-facing scripts. Clinical analysts shouldn't memorize --bq-campos --bq-valores. Step-by-step questions, smart defaults, exit on Ctrl+C.
  • Output folder = script folder. No "where do you want to save this?" prompt. The output is always next to the script. Predictable for the user, scriptable for downstream automation.

Decisions I had to rethink

  • Initial 1-level reconciliation. First version compared by id_registro only. It failed on the first plantilla in production because BQ had been seeded with GENERATE_UUID(). Had to add 2 fallback levels, then a 3rd for stopword drift.
  • A separate "deletions" table. Considered backing up every soft-delete to a dedicated audit table. Rejected — it duplicated data already living in three places (catalog, snapshot, CDC log). Built a v_bajas view instead.
  • Dry-run as a wizard option. Originally a question in the operator wizard. Wrong abstraction — operators want to run, not simulate. Removed entirely; dry-run is a developer-only flag now.
  • Manual ID list per week. First version of the batch required typing the IDs. Replaced with --ids-desde-cdc: read pending from the CDC log directly. Removed an entire class of operator error.

Want the implementation details?

The framework spans 10+ SQL scripts, 3 Python orchestrators, and 700+ pages of documentation. Happy to walk through any layer in detail.