📐 Capítulo 3 · Nivel Intermedio

Modelado de Datos

El arte y la ciencia de estructurar datos para análisis eficiente. Desde entidad-relación hasta Data Vault, este capítulo cubre todas las técnicas de modelado que debe conocer un Data Engineer profesional.

⏱️ Lectura: ~55 min
🎯 Nivel: Intermedio
📌 Prerrequisito: Cap. 01, 02

Entity-Relationship (ER) Modeling

El modelado Entidad-Relación es la técnica fundamental para diseñar bases de datos relacionales. Define las entidades (cosas del mundo real), sus atributos y las relaciones entre ellas.

Conceptos Clave

🔷

Entidad

Un objeto o concepto del mundo real que tiene existencia independiente. Ej: Cliente, Producto, Pedido.

📝

Atributo

Propiedad o característica de una entidad. Ej: Cliente tiene nombre, email, fecha_nacimiento.

🔗

Relación

Asociación entre entidades. Tipos: 1:1, 1:N (uno a muchos), N:M (muchos a muchos).

🔑

Clave Primaria (PK)

Atributo(s) que identifican unívocamente cada instancia de la entidad.

🔗

Clave Foránea (FK)

Atributo que referencia la PK de otra entidad, estableciendo la relación entre tablas.

🔄

Cardinalidad

Define cuántas instancias de una entidad pueden relacionarse con cuántas de otra.

Diagrama ER: Sistema de E-Commerce

erDiagram CUSTOMER { int customer_id PK string name string email string phone date created_at } ORDER { int order_id PK int customer_id FK int address_id FK timestamp order_date decimal total_amount string status } ORDER_ITEM { int order_item_id PK int order_id FK int product_id FK int quantity decimal unit_price decimal discount } PRODUCT { int product_id PK int category_id FK string name string sku decimal price int stock } CATEGORY { int category_id PK string name int parent_category_id FK } ADDRESS { int address_id PK int customer_id FK string street string city string country string zip_code } CUSTOMER ||--o{ ORDER : "places" CUSTOMER ||--o{ ADDRESS : "has" ORDER ||--o{ ORDER_ITEM : "contains" ORDER }o--|| ADDRESS : "ships_to" ORDER_ITEM }o--|| PRODUCT : "includes" PRODUCT }o--|| CATEGORY : "belongs_to" CATEGORY ||--o{ CATEGORY : "has_subcategory"

Implementación SQL del ER

-- Implementación del diagrama ER en PostgreSQL
CREATE TABLE customers (
    customer_id  SERIAL PRIMARY KEY,
    name         VARCHAR(100) NOT NULL,
    email        VARCHAR(200) UNIQUE NOT NULL,
    phone        VARCHAR(20),
    created_at   TIMESTAMP DEFAULT NOW()
);

CREATE TABLE categories (
    category_id        SERIAL PRIMARY KEY,
    name               VARCHAR(100) NOT NULL,
    parent_category_id INTEGER REFERENCES categories(category_id)  -- Self-reference
);

CREATE TABLE products (
    product_id  SERIAL PRIMARY KEY,
    category_id INTEGER NOT NULL REFERENCES categories(category_id),
    name        VARCHAR(200) NOT NULL,
    sku         VARCHAR(50) UNIQUE NOT NULL,
    price       DECIMAL(10,2) NOT NULL CHECK (price >= 0),
    stock       INTEGER DEFAULT 0 CHECK (stock >= 0)
);

CREATE TABLE addresses (
    address_id  SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
    street      VARCHAR(200) NOT NULL,
    city        VARCHAR(100) NOT NULL,
    country     VARCHAR(100) NOT NULL,
    zip_code    VARCHAR(20)
);

CREATE TABLE orders (
    order_id     SERIAL PRIMARY KEY,
    customer_id  INTEGER NOT NULL REFERENCES customers(customer_id),
    address_id   INTEGER NOT NULL REFERENCES addresses(address_id),
    order_date   TIMESTAMP DEFAULT NOW(),
    total_amount DECIMAL(10,2) NOT NULL CHECK (total_amount >= 0),
    status       VARCHAR(20) DEFAULT 'pending'
                 CHECK (status IN ('pending','processing','shipped','delivered','cancelled'))
);

CREATE TABLE order_items (
    order_item_id SERIAL PRIMARY KEY,
    order_id      INTEGER NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
    product_id    INTEGER NOT NULL REFERENCES products(product_id),
    quantity      INTEGER NOT NULL CHECK (quantity > 0),
    unit_price    DECIMAL(10,2) NOT NULL,  -- Precio al momento de la compra
    discount      DECIMAL(5,2) DEFAULT 0 CHECK (discount BETWEEN 0 AND 100)
);

Normalización y Desnormalización

La normalización es el proceso de organizar los datos en una base de datos para reducir redundancia y mejorar la integridad. En Data Engineering, entender cuándo normalizar y cuándo desnormalizar es crítico.

Primera Forma Normal (1NF)

Regla: Cada columna contiene un único valor atómico (indivisible). No hay grupos repetidos ni arrays en columnas.

Violación:

-- ❌ Viola 1NF: múltiples teléfonos en una columna
customer_id | name  | phones
1           | Ana   | "555-1234, 555-5678"  -- No atómico!

-- ✅ Cumple 1NF
customer_id | name | phone_number | phone_type
1           | Ana  | 555-1234     | mobile
1           | Ana  | 555-5678     | home

Segunda Forma Normal (2NF)

Regla: Cumple 1NF + todos los atributos no-clave dependen de la clave primaria completa (elimina dependencias parciales en PKs compuestas).

-- ❌ Viola 2NF: product_name depende solo de product_id, no de la PK completa
-- PK: (order_id, product_id)
order_id | product_id | product_name | quantity
1        | 101        | Laptop       | 2       -- product_name dep. solo de product_id

-- ✅ Cumple 2NF: separar en dos tablas
-- Tabla order_items (PK: order_id, product_id)
order_id | product_id | quantity
1        | 101        | 2

-- Tabla products (PK: product_id)
product_id | product_name
101        | Laptop

Tercera Forma Normal (3NF)

Regla: Cumple 2NF + ningún atributo no-clave depende de otro atributo no-clave (elimina dependencias transitivas).

-- ❌ Viola 3NF: zip_code → city (dependencia transitiva)
customer_id | name | zip_code | city      -- city depende de zip_code, no de customer_id
1           | Ana  | 28001    | Madrid

-- ✅ Cumple 3NF
-- Tabla customers
customer_id | name | zip_code
1           | Ana  | 28001

-- Tabla zip_codes
zip_code | city
28001    | Madrid

La 3NF es el estándar gold para bases de datos OLTP operacionales.

Boyce-Codd Normal Form (BCNF)

Regla: Versión más estricta de 3NF. Para cada dependencia funcional X → Y, X debe ser una superclave (capaz de identificar todas las filas).

BCNF resuelve anomalías raras en 3NF cuando hay múltiples claves candidatas superpuestas. En la práctica, la mayoría de tablas en 3NF ya cumplen BCNF.

💡 Cuándo aplicar cada forma normal
  • OLTP (operacional): Apuntar a 3NF / BCNF para garantizar integridad
  • OLAP (analítico): Desnormalizar intencionalmente para mejorar performance de queries

Desnormalización Intencional

En Data Warehouses, se desnormaliza intencionalmente para mejorar la velocidad de las queries analíticas. Los JOINs son costosos en tablas de millones de filas.

-- Tabla normalizada (OLTP) - requiere 3 JOINs para análisis
SELECT o.order_id, c.name, p.name, oi.quantity
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
JOIN customers c ON o.customer_id = c.customer_id
JOIN products p ON oi.product_id = p.product_id;

-- Tabla desnormalizada (DWH) - todo en una tabla wide
SELECT order_id, customer_name, product_name, quantity
FROM fact_order_items;  -- No JOINs necesarios!

-- fact_order_items incluye columns de todas las tablas:
-- order_id, order_date, customer_id, customer_name, customer_email,
-- product_id, product_name, category_name, quantity, unit_price, etc.

Trade-offs de desnormalizar:

  • ✅ Queries más rápidas (no JOINs)
  • ✅ Más simple para analistas que usan SQL
  • ❌ Redundancia de datos (mayor storage)
  • ❌ Riesgo de inconsistencias si no se gestiona bien
  • ❌ Updates más costosos

Star Schema

El Star Schema (esquema en estrella) es el modelo dimensional más utilizado en Data Warehouses. Fue popularizado por Ralph Kimball y es el estándar para OLAP y BI.

La estructura es simple: una tabla de hechos central conectada a múltiples tablas de dimensión desnormalizadas. La forma resultante del diagrama asemeja una estrella.

erDiagram FACT_SALES { int sale_id PK int date_key FK int customer_key FK int product_key FK int store_key FK int promotion_key FK decimal quantity_sold decimal sale_amount decimal discount_amount decimal net_amount decimal cost_amount decimal profit_amount } DIM_DATE { int date_key PK date full_date int year int quarter int month string month_name int week int day_of_week string day_name boolean is_weekend boolean is_holiday } DIM_CUSTOMER { int customer_key PK string customer_id string name string email string segment string country string city string zip_code date first_purchase_date } DIM_PRODUCT { int product_key PK string product_id string name string sku string category string subcategory string brand decimal list_price string color string size } DIM_STORE { int store_key PK string store_id string name string city string country string region string store_type int manager_id } FACT_SALES }o--|| DIM_DATE : "sold_on" FACT_SALES }o--|| DIM_CUSTOMER : "bought_by" FACT_SALES }o--|| DIM_PRODUCT : "of_product" FACT_SALES }o--|| DIM_STORE : "at_store"

Ventajas del Star Schema

Implementación SQL: Star Schema de Ventas

-- TABLA DE HECHOS (Fact Table)
CREATE TABLE fact_sales (
    sale_id         BIGSERIAL PRIMARY KEY,
    -- Foreign Keys a dimensiones (surrogate keys)
    date_key        INTEGER NOT NULL,
    customer_key    INTEGER NOT NULL,
    product_key     INTEGER NOT NULL,
    store_key       INTEGER NOT NULL,
    promotion_key   INTEGER,
    -- Métricas (medidas)
    quantity_sold   DECIMAL(10,2) NOT NULL DEFAULT 0,
    sale_amount     DECIMAL(12,2) NOT NULL DEFAULT 0,
    discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
    net_amount      DECIMAL(12,2) GENERATED ALWAYS AS (sale_amount - discount_amount) STORED,
    cost_amount     DECIMAL(12,2) NOT NULL DEFAULT 0,
    profit_amount   DECIMAL(12,2) GENERATED ALWAYS AS (net_amount - cost_amount) STORED
) PARTITION BY RANGE (date_key);  -- Particionamiento por fecha

-- Dimensión Fecha (la más importante)
CREATE TABLE dim_date (
    date_key        INTEGER PRIMARY KEY,  -- YYYYMMDD como integer
    full_date       DATE NOT NULL,
    year            SMALLINT NOT NULL,
    quarter         SMALLINT NOT NULL CHECK (quarter BETWEEN 1 AND 4),
    month           SMALLINT NOT NULL CHECK (month BETWEEN 1 AND 12),
    month_name      VARCHAR(10) NOT NULL,
    week            SMALLINT NOT NULL,
    day_of_week     SMALLINT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
    day_name        VARCHAR(10) NOT NULL,
    is_weekend      BOOLEAN NOT NULL DEFAULT FALSE,
    is_holiday      BOOLEAN NOT NULL DEFAULT FALSE,
    fiscal_year     SMALLINT,
    fiscal_quarter  SMALLINT
);

-- Poblar dim_date con un rango de fechas
INSERT INTO dim_date
SELECT
    TO_CHAR(d, 'YYYYMMDD')::INTEGER AS date_key,
    d AS full_date,
    EXTRACT(YEAR FROM d)::SMALLINT AS year,
    EXTRACT(QUARTER FROM d)::SMALLINT AS quarter,
    EXTRACT(MONTH FROM d)::SMALLINT AS month,
    TO_CHAR(d, 'Month') AS month_name,
    EXTRACT(WEEK FROM d)::SMALLINT AS week,
    EXTRACT(ISODOW FROM d)::SMALLINT AS day_of_week,
    TO_CHAR(d, 'Day') AS day_name,
    EXTRACT(ISODOW FROM d) IN (6, 7) AS is_weekend,
    FALSE AS is_holiday
FROM GENERATE_SERIES('2020-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL) d;

-- Query analítica típica en Star Schema
SELECT
    d.year,
    d.month_name,
    p.category,
    s.country,
    SUM(f.sale_amount)   AS total_sales,
    SUM(f.profit_amount) AS total_profit,
    COUNT(*)             AS transaction_count,
    AVG(f.sale_amount)   AS avg_ticket
FROM fact_sales f
JOIN dim_date     d ON f.date_key     = d.date_key
JOIN dim_product  p ON f.product_key  = p.product_key
JOIN dim_store    s ON f.store_key    = s.store_key
WHERE d.year = 2025
GROUP BY d.year, d.month_name, d.month, p.category, s.country
ORDER BY d.year, d.month, total_sales DESC;

Snowflake Schema

El Snowflake Schema es una variación del Star Schema donde las dimensiones están normalizadas en sub-dimensiones, creando una estructura que asemeja un copo de nieve.

⭐ Star Schema

  • Dimensiones desnormalizadas
  • Menos JOINs, más rápido
  • Más storage (redundancia)
  • Más simple para analistas
  • Recomendado para BI

❄️ Snowflake Schema

  • Dimensiones normalizadas
  • Más JOINs, potencialmente más lento
  • Menos storage (sin redundancia)
  • Más complejo para analistas
  • Útil para dimensiones muy grandes
-- SNOWFLAKE: dim_product se divide en tablas normalizadas
-- En lugar de tener category y brand directamente en dim_product...

-- Tabla de categorías separada (normalizada)
CREATE TABLE dim_category (
    category_key    INTEGER PRIMARY KEY,
    category_id     VARCHAR(20),
    category_name   VARCHAR(100),
    parent_cat_key  INTEGER REFERENCES dim_category(category_key)  -- jerarquía
);

-- Tabla de marcas separada
CREATE TABLE dim_brand (
    brand_key   INTEGER PRIMARY KEY,
    brand_id    VARCHAR(20),
    brand_name  VARCHAR(100),
    country     VARCHAR(100)
);

-- dim_product referencia las sub-dimensiones
CREATE TABLE dim_product_snowflake (
    product_key  INTEGER PRIMARY KEY,
    product_id   VARCHAR(20),
    name         VARCHAR(200),
    sku          VARCHAR(50),
    category_key INTEGER REFERENCES dim_category(category_key),  -- FK a sub-dim
    brand_key    INTEGER REFERENCES dim_brand(brand_key),        -- FK a sub-dim
    list_price   DECIMAL(10,2)
);

-- Ahora una query necesita más JOINs:
SELECT p.name, c.category_name, b.brand_name
FROM dim_product_snowflake p
JOIN dim_category c ON p.category_key = c.category_key
JOIN dim_brand b ON p.brand_key = b.brand_key;
⚠️ ¿Cuándo usar Snowflake Schema?

El Snowflake Schema tiene sentido cuando: (1) las dimensiones son muy grandes y la redundancia es costosa, (2) hay jerarquías profundas que cambiarán frecuentemente, (3) el storage es una restricción crítica. En la mayoría de casos modernos con cloud DWH (Snowflake, BigQuery), el costo de storage es bajo y se prefiere Star Schema por su simplicidad.

Fact Tables y Dimension Tables en Profundidad

Tipos de Fact Tables

📊 Transaction Fact Table (la más común)

Registra eventos de negocios individuales. Cada fila = una transacción. La granularidad más baja.

-- Cada fila es un ítem de una venta
-- Granularidad: 1 fila por order_item
fact_sales:
sale_id | date_key | customer_key | product_key | quantity | amount
1       | 20260101 | 1001         | 5001        | 2        | 299.98
2       | 20260101 | 1002         | 5002        | 1        | 89.99
3       | 20260101 | 1001         | 5003        | 3        | 150.00

Características: Alta cardinalidad (millones a billones de filas), inmutable una vez escrita, ideal para análisis a nivel transaccional.

📅 Periodic Snapshot Fact Table

Captura el estado de un proceso en intervalos regulares (diario, semanal, mensual). Ideal para métricas que no tienen eventos discretos.

-- Snapshot diario del inventario
-- Granularidad: 1 fila por producto por día
fact_inventory_snapshot:
snapshot_date_key | product_key | warehouse_key | qty_on_hand | qty_reserved | reorder_point
20260101          | 5001        | W01           | 150         | 30           | 50
20260101          | 5002        | W01           | 45          | 10           | 20
20260102          | 5001        | W01           | 148         | 28           | 50

-- Snapshots del saldo bancario diario
fact_account_daily:
date_key | account_key | customer_key | balance | available_credit

Casos de uso: Inventario, saldos bancarios, estados de proyectos, métricas de KPI.

🎯 Accumulating Snapshot Fact Table

Rastrea el ciclo de vida completo de un proceso de negocio. Cada fila se actualiza a medida que el proceso avanza por sus etapas.

-- Proceso de un pedido (se ACTUALIZA con cada etapa)
fact_order_lifecycle:
order_key | date_ordered_key | date_picked_key | date_shipped_key | date_delivered_key | days_to_deliver
1001      | 20260101         | 20260102        | 20260103         | 20260106           | 5
1002      | 20260101         | NULL            | NULL             | NULL               | NULL  -- aún en proceso

-- Ventajas: análisis de duración de etapas, identificar cuellos de botella
-- Cuánto tiempo entre pedido y envío?
SELECT AVG(date_shipped_key - date_ordered_key) as avg_days_to_ship
FROM fact_order_lifecycle
WHERE date_shipped_key IS NOT NULL;

Casos de uso: Pipeline de ventas, proceso de contratación, fulfillment de órdenes, ciclo de vida de siniestros.

Tipos de Dimensiones

📅

Conformed Dimension

Misma dimensión compartida entre múltiples fact tables. Ej: dim_date se usa en fact_sales Y fact_inventory. Permite drill-across entre hechos.

🌉

Bridge Table

Resuelve relaciones M:N entre hechos y dimensiones. Ej: un pedido puede tener múltiples promotions. La bridge table maneja esta complejidad.

🎭

Role-Playing Dimension

La misma dimensión física usada con distintos roles. Ej: dim_date como "order_date", "ship_date" y "delivery_date" en la misma fact table.

🗑️

Junk Dimension

Agrupa flags y códigos de baja cardinalidad que no merecen su propia dimensión. Ej: is_promotional, order_type, payment_method.

🔢

Degenerate Dimension

Atributo dimensional que vive en la fact table sin tabla propia. Ej: order_number en fact_sales. Tiene valor analítico pero no merece dimensión separada.

🌐

Outrigger Dimension

Sub-dimensión referenciada por una dimensión principal, no por la fact table. Uncommon en Star Schema puro.

Slowly Changing Dimensions (SCD)

Las SCDs resuelven uno de los problemas más comunes del DWH: ¿qué pasa cuando cambia un atributo de una dimensión? Por ejemplo, un cliente cambia de ciudad, o un producto cambia de categoría. ¿Cómo manejas el historial?

SCD Tipo 0 — Retain Original

Tipo 0

El atributo nunca cambia una vez creado. Se usa para datos que son verdad histórica inmutable: fecha de nacimiento, número de seguro social, fecha de primera compra.

-- El campo birth_date nunca se actualiza
UPDATE dim_customer SET birth_date = '...'  -- ¡PROHIBIDO!

SCD Tipo 1 — Overwrite

Tipo 1

Sobrescribir el valor actual. No hay historial. Se usa cuando el valor anterior no importa (ej: corrección de typos, actualizar email).

-- Simple UPDATE - pierde historial
UPDATE dim_customer
SET email = 'new@email.com'
WHERE customer_key = 1001;

SCD Tipo 2 — Add New Row ⭐

Tipo 2 — El más usado

Inserta una nueva fila con el valor actualizado, conservando la fila anterior. Permite análisis histórico completo. Es el tipo más importante en Data Engineering.

-- Dimensión con soporte SCD2
customer_key | customer_id | name | city     | is_current | valid_from | valid_to
1001         | C001        | Ana  | Madrid   | FALSE      | 2020-01-01 | 2024-06-15
1550         | C001        | Ana  | Barcelona| TRUE       | 2024-06-15 | 9999-12-31

-- El customer_id (natural key) identifica al cliente real
-- customer_key (surrogate key) identifica la versión histórica

SCD Tipo 3 — Add New Column

Tipo 3

Agrega columnas "previous_value" y "current_value". Solo guarda el cambio más reciente. Limitado pero simple para casos específicos.

-- Solo guarda el valor actual y el anterior
customer_key | name | current_city | previous_city | changed_at
1001         | Ana  | Barcelona    | Madrid        | 2024-06-15

SCD Tipo 4 — History Table

Tipo 4

Tabla de historial separada. La dimensión principal tiene solo el valor actual, y una tabla de historial guarda todos los cambios.

-- dim_customer: solo valor actual
-- dim_customer_history: todo el historial
customer_hist_id | customer_key | city   | changed_at
1                | 1001         | Madrid | 2020-01-01
2                | 1001         | BCN    | 2024-06-15

SCD Tipo 6 — Hybrid (1+2+3)

Tipo 6

Combina tipos 1, 2 y 3. Nueva fila por cambio (tipo 2) + columna previous (tipo 3) + columna current que siempre refleja el valor actual (tipo 1).

customer_key | city      | current_city | is_current | valid_from | valid_to
1001         | Madrid    | Barcelona    | FALSE      | 2020-01-01 | 2024-06-15
1550         | Barcelona | Barcelona    | TRUE       | 2024-06-15 | 9999-12-31

Implementación SCD Tipo 2 con dbt

-- dbt: snapshot para SCD Tipo 2 automático
-- Archivo: snapshots/dim_customer_snapshot.sql
{% snapshot dim_customer_snapshot %}
  {{
    config(
      target_database='dw',
      target_schema='snapshots',
      unique_key='customer_id',
      strategy='timestamp',
      updated_at='updated_at',
    )
  }}

  SELECT
    customer_id,
    name,
    email,
    city,
    country,
    customer_segment,
    updated_at
  FROM {{ source('operational', 'customers') }}

{% endsnapshot %}

-- dbt genera automáticamente las columnas:
-- dbt_scd_id, dbt_updated_at, dbt_valid_from, dbt_valid_to, dbt_is_current

Data Vault

Data Vault es una metodología de modelado de datos para DWH enterprise, desarrollada por Dan Linstedt. Está optimizada para auditabilidad, flexibilidad a cambios y carga paralela. Es especialmente popular en industrias reguladas (banking, insurance).

🏦 Por qué Data Vault

Mientras que el Star Schema es excelente para BI, Data Vault brilla cuando: los requisitos de negocio cambian constantemente, se necesita auditoría completa de todos los cambios, múltiples fuentes alimentan los mismos datos y el compliance es crítico. Es el DWH "raw" sobre el que luego se construyen Data Marts en Star Schema.

Los 3 Componentes de Data Vault

🔑

Hub

Contiene las business keys (claves de negocio naturales). Ej: hub_customer tiene customer_id. Sin atributos descriptivos. Inmutable.

🔗

Link

Representa relaciones entre Hubs (muchos a muchos por defecto). Ej: link_order_customer conecta order_hub con customer_hub. Inmutable.

📋

Satellite

Contiene los atributos descriptivos y cambios históricos (como SCD2 automático). Ej: sat_customer_details tiene nombre, email. Versionado.

-- DATA VAULT: Hub, Link y Satellite

-- HUB CUSTOMER: solo business key + metadata
CREATE TABLE hub_customer (
    customer_hash_key  BYTEA PRIMARY KEY,  -- Hash de la business key
    customer_id        VARCHAR(50) NOT NULL,  -- Business key natural
    load_timestamp     TIMESTAMP NOT NULL DEFAULT NOW(),
    record_source      VARCHAR(100) NOT NULL  -- De dónde vino el dato
);

-- HUB ORDER
CREATE TABLE hub_order (
    order_hash_key  BYTEA PRIMARY KEY,
    order_id        VARCHAR(50) NOT NULL,
    load_timestamp  TIMESTAMP NOT NULL DEFAULT NOW(),
    record_source   VARCHAR(100) NOT NULL
);

-- LINK: relación Order → Customer (muchos a muchos)
CREATE TABLE link_order_customer (
    link_hash_key      BYTEA PRIMARY KEY,
    order_hash_key     BYTEA NOT NULL REFERENCES hub_order(order_hash_key),
    customer_hash_key  BYTEA NOT NULL REFERENCES hub_customer(customer_hash_key),
    load_timestamp     TIMESTAMP NOT NULL DEFAULT NOW(),
    record_source      VARCHAR(100) NOT NULL
);

-- SATELLITE: atributos del customer (con historial automático)
CREATE TABLE sat_customer_details (
    customer_hash_key  BYTEA NOT NULL REFERENCES hub_customer(customer_hash_key),
    load_timestamp     TIMESTAMP NOT NULL,
    load_end_timestamp TIMESTAMP,          -- NULL = registro activo
    hash_diff          BYTEA NOT NULL,     -- Hash de todos los atributos (para detección de cambios)
    name               VARCHAR(100),
    email              VARCHAR(200),
    city               VARCHAR(100),
    country            VARCHAR(100),
    record_source      VARCHAR(100) NOT NULL,
    PRIMARY KEY (customer_hash_key, load_timestamp)
);

Data Vault vs Star Schema: ¿Cuándo usar cada uno?

CriterioData VaultStar Schema (Kimball)
Auditabilidad completa✅ Excelente (nativo)⚠️ Requiere trabajo extra
Flexibilidad a cambios✅ Alta (add satellites)❌ Requiere rediseño
Carga paralela✅ Diseñado para eso⚠️ Dependencias de FK
Simplicidad de queries❌ Complejo (muchos JOINs)✅ Simple e intuitivo
Performance BI❌ Mal (necesita Data Marts)✅ Excelente
Compliance/Regulación✅ Ideal⚠️ Requiere extensiones
Tamaño del equipoEquipos grandesEquipos pequeños y medianos
Industria típicaBanking, Insurance, Gov.Retail, SaaS, E-commerce