Módulo 7: Messy Domains And Medallion At Depth
Cuando un hecho y dos dimensiones no alcanzan
Descripción
Esta lección construye, con evidencia ejecutada, el argumento central del módulo: un warehouse real casi nunca se queda en "un hecho, dos dimensiones" — crece agregando procesos de negocio nuevos, no solo filas nuevas al proceso original. Vas a reconstruir el warehouse completo de Kiosko —tres hechos, cuatro dimensiones— en una sola conexión de DuckDB, y vas a correr una consulta que ningún módulo anterior necesitó: contar cuántas tablas de hechos distintas comparten cada dimensión, la prueba directa de que dim_store y dim_date ya son dimensiones conformadas en el sentido más estricto —sirven a más de un proceso de negocio a la vez—.
Conexión con el módulo. Esta lección retoma el inventario de la lección 1 y lo convierte en código ejecutado: reconstruye las siete tablas del dominio de Kiosko y mide, con una consulta real, cuántos hechos comparte cada dimensión — la base numérica sobre la que la lección 6 va a construir su argumento de "un solo calendario conformado, tres hechos".
Una analogía: el archivo de un solo cliente, y el archivo de la empresa completa
Imagina el archivo de papel de un consultorio pequeño con un solo paciente: una carpeta, con la ficha del paciente al frente y los resultados de laboratorio detrás. Fácil de diseñar, fácil de mantener — un archivo, dos secciones. Ahora imagina el archivo de un hospital completo: cientos de pacientes, cada uno con su propia carpeta (una dimensión: "quién"), pero también un archivo de citas (un hecho: "cuándo pasó cada consulta"), un archivo de resultados de laboratorio (otro hecho, con su propio ritmo de llegada), un archivo de facturación (un tercer hecho, con su propia moneda de medida), todos compartiendo el mismo archivo de pacientes y el mismo calendario del hospital. Nadie diseña el sistema de un hospital pensando en un solo paciente y una sola visita — se diseña sabiendo, desde el principio, que varios procesos van a compartir el mismo directorio de pacientes y el mismo calendario.
Kiosko, seis módulos después de su primer fact_orders, ya es ese hospital, no ese consultorio de un solo paciente. Esta lección mide, con una consulta real, cuántos "procesos" —hechos— comparten cada "directorio" —dimensión— del dominio de Kiosko hoy.
El material que necesitas
Necesitas, en la misma carpeta: kiosko.py, raw_orders.py y events.py (idénticos a los módulos 1, 2, 4 y 6). No necesitas ningún archivo adicional — el mapeo sesión-tienda y la ventana de actividad por tienda se declaran directamente en el script de esta lección, igual que en los proyectos anteriores.
Ejemplo trabajado: reconstruyendo el dominio completo, y midiendo qué comparte cada dimensión
Primero, reconstruye las siete tablas del dominio, exactamente como quedaron en los módulos 1 a 6 —sin ningún cambio—:
# domain_reconstruction.py
from datetime import date, datetime, timedelta
import duckdb
from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS
from events import RAW_EVENTS
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def generate_date_dim(start_date: str, end_date: str) -> list[dict]:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
rows = []
current = start
while current <= end:
weekday_index = current.weekday()
rows.append({
"date_key": int(current.strftime("%Y%m%d")), "calendar_date": current,
"day_of_week": DAY_NAMES[weekday_index], "month": current.month,
"quarter": (current.month - 1) // 3 + 1, "year": current.year,
"is_weekend": weekday_index >= 5,
})
current += timedelta(days=1)
return rows
con = duckdb.connect()
# --- fact_orders (modulo 1) ---
orders = [
Order(order_id=r[0], store_id=r[1], product_id=r[2], quantity=r[3],
unit_price=r[4], order_ts=datetime.fromisoformat(r[5]))
for r in RAW_ORDERS
]
fact_orders = transform_fact_orders(orders, DIM_STORE, DIM_PRODUCT)
con.execute("""
CREATE TABLE fact_orders (
order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
quantity INTEGER, unit_price DOUBLE, revenue DOUBLE, order_ts TIMESTAMP
)
""")
con.executemany("INSERT INTO fact_orders VALUES (?, ?, ?, ?, ?, ?, ?)",
[(r["order_id"], r["store_id"], r["product_id"], r["quantity"],
r["unit_price"], r["revenue"], r["order_ts"]) for r in fact_orders])
# --- dim_date (modulo 2) ---
dim_date_rows = generate_date_dim("2026-08-01", "2026-08-31")
con.execute("""
CREATE TABLE dim_date (
date_key INTEGER, calendar_date DATE, day_of_week VARCHAR,
month INTEGER, quarter INTEGER, year INTEGER, is_weekend BOOLEAN
)
""")
con.executemany("INSERT INTO dim_date VALUES (?, ?, ?, ?, ?, ?, ?)",
[(r["date_key"], r["calendar_date"], r["day_of_week"], r["month"],
r["quarter"], r["year"], r["is_weekend"]) for r in dim_date_rows])
# --- events + fact_sessions (modulo 6) ---
con.execute("CREATE TABLE events (event_id VARCHAR, event_type VARCHAR, session_id VARCHAR, event_ts TIMESTAMP)")
con.executemany("INSERT INTO events VALUES (?, ?, ?, ?)",
[(r[0], r[1], r[2], datetime.fromisoformat(r[3])) for r in RAW_EVENTS])
STORE_ROTATION = ["S01", "S02", "S03"]
def store_for_session(session_id: str) -> str:
session_number = int(session_id.split("-")[1])
return STORE_ROTATION[(session_number - 1) % 3]
ALL_SESSIONS = [f"SESS-{n:02d}" for n in range(1, 18)]
con.execute("CREATE TABLE session_store_map (session_id VARCHAR, store_id VARCHAR)")
con.executemany("INSERT INTO session_store_map VALUES (?, ?)",
[(sid, store_for_session(sid)) for sid in ALL_SESSIONS])
con.execute("""
CREATE TABLE fact_sessions AS
SELECT e.session_id, m.store_id, MIN(CAST(e.event_ts AS DATE)) AS session_date,
MAX(CASE WHEN e.event_type = 'page_view' THEN e.event_ts END) AS view_ts,
MAX(CASE WHEN e.event_type = 'add_to_cart' THEN e.event_ts END) AS add_to_cart_ts,
MAX(CASE WHEN e.event_type = 'purchase' THEN e.event_ts END) AS purchase_ts,
MAX(CASE WHEN e.event_type = 'purchase' THEN true ELSE false END) AS is_converted
FROM events e JOIN session_store_map m ON e.session_id = m.session_id
GROUP BY e.session_id, m.store_id
""")
print("=== El dominio de Kiosko, reconstruido: 2 hechos ya presentes (fact_orders, fact_sessions) ===")
for table in ["fact_orders", "dim_date", "fact_sessions"]:
count = con.sql(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
print(f" {table:16} {count:3} filas")
Qué esperar.
=== El dominio de Kiosko, reconstruido: 2 hechos ya presentes (fact_orders, fact_sessions) ===
fact_orders 40 filas
dim_date 31 filas
fact_sessions 17 filas
Ahora, la pregunta central de esta lección: ¿cuántos hechos comparte cada dimensión? La respuesta se construye con una tabla de mapeo explícita —hecho por hecho, dimensión por dimensión— y una consulta que la agrega:
# shared_dimensions.py -- continua sobre con de la seccion anterior
FACT_DIMENSION_USAGE = [
("fact_orders", "dim_store"),
("fact_orders", "dim_date"),
("fact_sessions", "dim_store"),
("fact_sessions", "dim_date"),
]
con.execute("CREATE TABLE fact_dimension_usage (fact_table VARCHAR, dimension_table VARCHAR)")
con.executemany("INSERT INTO fact_dimension_usage VALUES (?, ?)", FACT_DIMENSION_USAGE)
print("\n=== Cuantos hechos comparte cada dimension ===")
print(con.sql("""
SELECT dimension_table, COUNT(DISTINCT fact_table) AS facts_sharing_it,
STRING_AGG(DISTINCT fact_table, ', ' ORDER BY fact_table) AS which_facts
FROM fact_dimension_usage
GROUP BY dimension_table
ORDER BY facts_sharing_it DESC, dimension_table
"""))
Qué esperar.
=== Cuantos hechos comparte cada dimension ===
┌─────────────────┬──────────────────┬────────────────────────────┐
│ dimension_table │ facts_sharing_it │ which_facts │
│ varchar │ int64 │ varchar │
├─────────────────┼──────────────────┼────────────────────────────┤
│ dim_date │ 2 │ fact_orders, fact_sessions │
│ dim_store │ 2 │ fact_orders, fact_sessions │
└─────────────────┴──────────────────┴────────────────────────────┘
dim_date y dim_store ya sirven a dos procesos de negocio distintos —la venta y la sesión de navegación—, no uno. Esto no es una coincidencia de diseño: es la definición operativa de "dimensión conformada" que el módulo 2 declaró en teoría y que esta consulta confirma con un número. Y todavía falta sumar fact_store_activity —que también usa dim_store y, en espíritu, dim_date (por activity_date)— a esta tabla; la lección 6 completa esa cuenta con las tres tablas de hechos a la vez.
Diagrama: por qué "un hecho, dos dimensiones" ya no describe a Kiosko
flowchart TD
subgraph Hechos["3 tablas de hechos"]
FO["fact_orders\n(transaccional)"]
FS["fact_sessions\n(accumulating snapshot)"]
FA["fact_store_activity\n(cumulative)"]
end
subgraph Dims["Dimensiones compartidas"]
DS["dim_store\n(conformada)"]
DD["dim_date\n(conformada)"]
end
FO --> DS
FO --> DD
FS --> DS
FS -.-> DD
FA --> DS
FA -.-> DD
La línea punteada marca las conexiones que existen conceptualmente —fact_sessions.session_date y fact_store_activity.activity_date son fechas del mismo calendario que dim_date describe— pero que ningún módulo anterior unió con un JOIN explícito. Esa es, exactamente, la deuda que la lección 6 de este módulo cierra.
Profundización: el argumento cuantitativo, no solo cualitativo
El módulo 3 de esta guía ya te mostró, con EXPLAIN, que la forma de un modelo tiene un costo medible. Esta lección aplica el mismo espíritu a una pregunta distinta: no "qué forma es más rápida de consultar", sino "qué tan compartido está el vocabulario del negocio". Un facts_sharing_it = 1 (una dimensión que solo sirve a un hecho) no es un error — dim_product y dim_product_scd, por ejemplo, hoy solo sirven a fact_orders, porque ningún otro hecho de Kiosko necesita todavía saber qué producto se vendió. Pero un facts_sharing_it = 2 o más, como el que acabas de medir para dim_store y dim_date, es la señal cuantitativa de que esa dimensión pagó su costo de diseño varias veces: se construyó una vez (módulos 1 y 2), y sirvió, sin ningún cambio, a un segundo proceso de negocio completo (módulo 6) que todavía no existía cuando se diseñó.
Esta es, en números concretos, la razón práctica por la que Kimball insiste tanto en construir dimensiones conformadas desde el principio, incluso cuando solo hay un hecho que las use: dim_store se diseñó en el módulo 1 pensando únicamente en fact_orders, pero su forma —una fila por tienda, con llave sustituta, sin ningún atributo específico de ventas— resultó ser exactamente la forma correcta para que fact_sessions, un proceso completamente distinto, la reutilizara sin ningún cambio cinco módulos después.
Errores comunes
Confundir "dimensión compartida" con "tabla de hechos combinada". Qué pasa: alguien, al ver que dim_store sirve a dos hechos, asume que eso significa que fact_orders y fact_sessions deberían combinarse en una sola tabla más grande. Por qué pasa: es fácil pensar que "compartir algo" implica "fusionarse", cuando en realidad es exactamente lo opuesto de lo que este módulo defiende. Cómo detectarlo: si tu conclusión de esta lección es que Kiosko debería tener una sola tabla de hechos gigante en vez de tres, perdiste el argumento central del módulo — ya lo advirtió, de forma explícita, el error común de la lección 1. Cómo corregirlo: dos hechos con grano distinto (una línea de orden, una sesión completa) deben permanecer en tablas separadas, siempre — lo único que comparten es la dimensión, no su propia estructura.
Medir "cuántos hechos comparte una dimensión" contando JOIN en vez de contar procesos de negocio distintos. Qué pasa: alguien escribe una consulta que cuenta cuántas veces aparece JOIN dim_store en el código fuente de la guía completa, en vez de contar cuántos hechos distintos la usan. Por qué pasa: contar apariciones de texto en el código es mecánicamente más simple que razonar sobre procesos de negocio. Cómo detectarlo: si tu conteo de "hechos que comparten dim_store" incluye, por ejemplo, mart_daily_sales_obt del módulo 3 —una tabla derivada, no un hecho independiente—, tu conteo mezcla conceptos distintos. Cómo corregirlo: la pregunta correcta es "¿cuántos procesos de negocio distintos, cada uno con su propio grano declarado, consultan esta dimensión?" — fact_orders y fact_sessions son dos procesos distintos; mart_daily_sales_obt es una vista derivada de fact_orders, no un proceso nuevo.
Asumir que toda dimensión debería, eventualmente, ser conformada por todos los hechos. Qué pasa: alguien, entusiasmado con el resultado de dim_store y dim_date sirviendo a dos hechos, concluye que dim_product "debería" también servir a fact_sessions y fact_store_activity, y busca cómo forzar esa conexión. Por qué pasa: si compartir es bueno, parece razonable maximizarlo en todas las dimensiones posibles. Cómo detectarlo: si intentas escribir un JOIN entre fact_sessions y dim_product sin que exista ninguna columna real que los conecte (fact_sessions no registra qué producto se vio en cada sesión, en el dataset actual de Kiosko), estás forzando una relación que el dato no sostiene. Cómo corregirlo: una dimensión se conforma cuando el negocio realmente la necesita en más de un proceso — dim_store y dim_date lo cumplen naturalmente (toda venta y toda sesión ocurren en una tienda, en una fecha); dim_product no lo cumple hoy porque fact_sessions, tal como esta guía lo construyó, no registra qué producto vio cada sesión. Forzar una conformidad que el dato no sostiene es peor que dejar la dimensión sin compartir.
Ejercicios
Ejercicio 1 — Agrega fact_store_activity a fact_dimension_usage y recalcula el conteo. Usando la tabla fact_dimension_usage ya construida, agrega las filas que le faltan (fact_store_activity usa dim_store) y vuelve a correr la consulta de conteo.
Ver solución
con.execute("INSERT INTO fact_dimension_usage VALUES ('fact_store_activity', 'dim_store')")
print(con.sql("""
SELECT dimension_table, COUNT(DISTINCT fact_table) AS facts_sharing_it
FROM fact_dimension_usage
GROUP BY dimension_table
ORDER BY facts_sharing_it DESC, dimension_table
"""))
Salida esperada:
┌─────────────────┬──────────────────┐
│ dimension_table │ facts_sharing_it │
│ varchar │ int64 │
├─────────────────┼──────────────────┤
│ dim_store │ 3 │
│ dim_date │ 2 │
└─────────────────┴──────────────────┘
dim_store sube a 3 —ahora comparte los tres hechos del dominio de Kiosko—, mientras dim_date se queda en 2 porque, tal como advirtió el diagrama de esta lección, todavía nadie unió fact_store_activity.activity_date contra dim_date con un JOIN explícito. La lección 6 completa esa pieza faltante.
Ejercicio 2 — Verifica que dim_product no aparece en fact_dimension_usage para ningún hecho más allá de fact_orders. Escribe una consulta que confirme, con un assert, que dim_product no está registrada como compartida por fact_sessions ni fact_store_activity en la tabla de mapeo.
Ver solución
product_usage = con.sql("""
SELECT fact_table FROM fact_dimension_usage WHERE dimension_table = 'dim_product'
""").fetchall()
assert product_usage == [], "dim_product no deberia aparecer compartida en este dominio"
print(f"dim_product: {len(product_usage)} hechos registrados (se esperaba 0)")
Salida esperada:
dim_product: 0 hechos registrados (se esperaba 0)
Cero filas — confirmando, con evidencia y no con intuición, lo que el tercer error común de esta lección ya advirtió: dim_product sigue siendo una dimensión de un solo hecho en el dominio actual de Kiosko, y eso es correcto, no una deficiencia a corregir.
Ejercicio 3 — Explica, de memoria, por qué "conformada" es un estado que se mide, no una etiqueta que se declara de antemano. En 2-3 frases, explica la diferencia entre decidir "voy a construir dim_store para que sea conformada" y descubrir, como hizo esta lección, que dim_store terminó siendo conformada.
Ver solución
Cuando el módulo 1 construyó dim_store, ningún otro hecho de Kiosko existía todavía —no había forma de "decidir" que fuera conformada, porque no había un segundo proceso con el cual conformarla—. Lo que sí se decidió, en ese momento, fue construirla con una forma limpia y genérica —llave sustituta, atributos estables, sin nada específico de ventas—, y esa decisión de diseño es la que permitió que, cinco módulos después, un proceso completamente nuevo (fact_sessions) pudiera reutilizarla sin ningún cambio. "Conformada" describe, entonces, un resultado que se mide después —¿cuántos hechos la usan hoy?—, no una intención que se declara antes de que el segundo hecho exista.
Resumen y siguiente paso
Esta lección midió, con una consulta real, el argumento central del módulo: dim_store y dim_date ya sirven a dos procesos de negocio distintos —fact_orders y fact_sessions—, la definición operativa exacta de una dimensión conformada. "Un hecho, dos dimensiones" describía a Kiosko en el módulo 1; "tres hechos, cuatro dimensiones, con dos de ellas ya compartidas" describe a Kiosko hoy — y ese crecimiento, lejos de ser un problema, es la señal de que las dimensiones se diseñaron bien desde el principio.
Antes de avanzar deberías poder: reconstruir de memoria las siete tablas del dominio de Kiosko; escribir la consulta que cuenta cuántos hechos comparte cada dimensión; y explicar por qué dim_product sigue siendo, correctamente, una dimensión de un solo hecho.
La lección 3 retoma la pieza del dominio que este módulo todavía no desarrolló a fondo: order_id, la dimensión degenerada que vive dentro de fact_orders desde el módulo 1, sin tabla propia — con su justificación completa y sus casos de uso reales.
Recursos
- Kimball Group — "Conformed Dimensions" — la definición formal de dimensión conformada que esta lección mide con una consulta real sobre
dim_storeydim_date. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/conformed-dimension. En inglés. - Kimball Group — "Four-Step Dimensional Design Process" — el mismo proceso del módulo 1, ahora visible en cómo cada hecho nuevo de Kiosko (sesiones, actividad) repitió sus cuatro pasos sobre dimensiones que ya existían. kimballgroup.com/.../four-4-step-design-process. En inglés.
- DuckDB — documentación de funciones de agregación de texto (
STRING_AGG), usada en esta lección para listar los hechos que comparten cada dimensión. duckdb.org/docs/current/sql/functions/aggregates. En inglés. - DuckDB — documentación oficial del cliente Python, la interfaz que ejecuta cada consulta de esta lección. duckdb.org/docs/current/clients/python/overview. En inglés.