🌱 Cultiva IA · Datos & Análisis

Patrones de Transformación dbt

Arquitectura medallion lista para producción · NutriShop España · BigQuery

BigQuery (nutrishop-prod) dbt Core 1.8 Production-ready 📦 Fivetran (Shopify + Stripe + Google Ads) 📊 Looker Studio
12
Modelos totales
↑ 4 staging · 3 int · 5 marts
38
Tests de calidad
↑ unique, not_null, relationships
3
Modelos incrementales
fct_orders, fct_revenue, stg_payments
7
Macros DRY
cents_to_euros, segment_customer…
🏗️

Arquitectura Medallion

Flujo de datos desde fuentes brutas hasta marts analíticos para Looker Studio

Sources
RAW
BigQuery raw.*
shopify.* stripe.* google_ads.*
Staging
stg_*
View · 1:1 con fuente
stg_shopify__orders stg_stripe__payments
Intermediate
int_*
Ephemeral · Joins & lógica
int_orders_enriched int_customer_ltv
Marts
dim_ / fct_
Table · Analytics-ready
dim_customers fct_orders fct_revenue
BI Layer
Looker Studio
Dashboards finales
📐

DAG del Proyecto NutriShop

Linaje de dependencias entre modelos

Grafo de dependencias — nutrishop_analytics
Sources (RAW)
shopify.orders
shopify.customers
shopify.order_items
stripe.payments
stripe.customers
google_ads.campaigns
Staging
stg_shopify__orders
stg_shopify__customers
stg_shopify__order_items
stg_stripe__payments
stg_gads__campaigns
Intermediate
int_orders_enriched
int_customer_ltv
int_revenue_daily
Marts
dim_customers
dim_products
fct_orders
fct_revenue
fct_ad_performance

📛

Convenciones de Nomenclatura

Estándares para modelos, schemas y materialización

Capa Prefijo Patrón Ejemplo Materialización Schema BigQuery
Staging stg_ stg_{fuente}__{tabla} stg_shopify__orders 👁️ view nutrishop_staging
Intermediate int_ int_{entidad}_{accion} int_customer_ltv 👻 ephemeral (sin schema propio)
Marts — Dimensión dim_ dim_{entidad} dim_customers 🗃️ table nutrishop_analytics
Marts — Hechos fct_ fct_{proceso} fct_orders ⚡ incremental nutrishop_analytics
Macros macro macros/{dominio}/{nombre}.sql cents_to_euros.sql

🔷

Capa Staging — Patrones

Limpieza 1:1 de fuentes. Se ejecuta una vez, se reutiliza en todas las capas superiores.

📄staging/shopify/_shopify__sources.yml YAML
version: 2

sources:
  - name: shopify
    description: Raw Shopify data via Fivetran
    database: nutrishop-prod
    schema: raw_shopify
    loader: fivetran
    loaded_at_field: _fivetran_synced
    freshness:
      warn_after: { count: 6, period: hour }
      error_after: { count: 24, period: hour }
    tables:
      - name: orders
        columns:
          - name: id
            tests: [unique, not_null]
          - name: customer_id
            tests: [not_null]
          - name: financial_status
            tests:
              - accepted_values:
                  values: [paid, pending,
                           refunded, voided]
📄staging/shopify/stg_shopify__orders.sql SQL
with source as (
    select * from
    {{ source('shopify', 'orders') }}
),

renamed as (
    select
        -- ids
        id as order_id,
        customer_id,
        location_id,

        -- status
        fulfillment_status,
        financial_status as payment_status,

        -- amounts (centavos → euros)
        {{ cents_to_euros('total_price_set') }}
            as total_price,
        {{ cents_to_euros('subtotal_price') }}
            as subtotal,
        {{ cents_to_euros('total_discounts') }}
            as total_discount,

        -- timestamps
        created_at,
        updated_at,
        processed_at,
        cancelled_at,

        -- metadata
        _fivetran_synced as _loaded_at

    from source
)

select * from renamed
🔹

Capa Intermediate — Lógica de Negocio

Joins, agregaciones y enriquecimiento de datos. Ephemeral: no materializa en disco.

📄intermediate/commerce/int_customer_ltv.sql SQL
{{
    config(materialized='ephemeral')
}}

with customers as (
    select * from {{ ref('stg_shopify__customers') }}
),

orders as (
    select * from {{ ref('stg_shopify__orders') }}
    where payment_status = 'paid'
),

payments as (
    select * from {{ ref('stg_stripe__payments') }}
    where payment_status = 'succeeded'
),

order_agg as (
    select
        customer_id,
        count(*)                      as total_orders,
        sum(total_price)             as total_revenue,
        avg(total_price)             as avg_order_value,
        min(created_at)              as first_order_at,
        max(created_at)              as last_order_at,
        sum(total_discount)          as total_discounts_used,
        countif(
          total_discount > 0
        )                            as orders_with_discount
    from orders
    group by customer_id
)

select
    c.customer_id,
    c.email,
    c.created_at  as customer_since,
    coalesce(oa.total_orders, 0)        as total_orders,
    coalesce(oa.total_revenue, 0)       as lifetime_value,
    coalesce(oa.avg_order_value, 0)     as avg_order_value,
    oa.first_order_at,
    oa.last_order_at,
    -- Segmentación RFM simplificada
    {{ segment_customer_rfm('oa.total_revenue', 'oa.last_order_at') }}
        as customer_segment,
    current_timestamp as _loaded_at

from customers c
left join order_agg oa using (customer_id)
🟢

Capa Marts — Tablas Analíticas

Modelos finales optimizados para BI. fct_ incrementales, dim_ tabla completa.

📄marts/core/dim_customers.sql SQL
{{
    config(
        materialized='table',
        unique_key='customer_key',
        cluster_by=['customer_segment']
    )
}}

with customers as (
    select * from
    {{ ref('int_customer_ltv') }}
),

final as (
    select
        -- surrogate key
        {{ dbt_utils.generate_surrogate_key(
            ['customer_id']) }}
            as customer_key,

        customer_id,
        email,
        customer_since,
        total_orders,
        lifetime_value,
        avg_order_value,
        first_order_at,
        last_order_at,
        customer_segment,

        -- tier económico
        case
            when lifetime_value >= 500
                then 'premium'
            when lifetime_value >= 100
                then 'standard'
            else 'entry'
        end as customer_tier,

        current_timestamp as _loaded_at
    from customers
)

select * from final
📄marts/core/fct_orders.sql SQL
{{
    config(
        materialized='incremental',
        unique_key='order_id',
        incremental_strategy='merge',
        partition_by={
          "field": "created_date",
          "data_type": "date",
          "granularity": "day"
        },
        cluster_by=['customer_segment','payment_status']
    )
}}

with orders as (
    select * from
    {{ ref('stg_shopify__orders') }}

    {% if is_incremental() %}
    where updated_at > (
        select max(updated_at) from {{ this }}
    )
    {% endif %}
),

customers as (
    select customer_key, customer_id,
           customer_segment, customer_tier
    from {{ ref('dim_customers') }}
),

final as (
    select
        o.order_id,
        c.customer_key,
        c.customer_segment,
        c.customer_tier,
        o.payment_status,
        o.fulfillment_status,
        o.total_price,
        o.subtotal,
        o.total_discount,
        date(o.created_at) as created_date,
        o.created_at,
        o.updated_at,
        current_timestamp as _loaded_at
    from orders o
    left join customers c
        on o.customer_id = c.customer_id
)

select * from final
🧪

Estrategia de Testing

Tests automáticos en toda la pipeline. Objetivo: 0 filas fallidas en producción.

🔑unique + not_null
Garantiza integridad de claves primarias y naturales en cada modelo.
tests: [unique, not_null]
🔗relationships
Verifica FK entre fct_orders.customer_key → dim_customers.customer_key.
to: ref('dim_customers')
📋accepted_values
Valida enumerados: payment_status, customer_tier, fulfillment_status.
values: [paid, pending, refunded]
📐expression_is_true
Reglas de negocio: lifetime_value ≥ 0, total_price > 0, descuentos ≤ subtotal.
expression: ">= 0"
recency
Alerta si fct_orders no recibe datos nuevos en más de 1 día (frescura de datos).
dbt_utils.recency: 1 day
📊row_count
Comprueba que dim_customers tenga al menos 1.000 filas (señal de carga completa).
dbt_utils.row_count: min 1000
📄marts/core/_core__models.yml — Documentación + Tests YAML
version: 2

models:
  - name: dim_customers
    description: Dimensión de clientes con LTV, segmento RFM y tier económico
    columns:
      - name: customer_key
        description: Clave surrogada SHA256 del customer_id
        tests: [unique, not_null]

      - name: lifetime_value
        description: Suma total de pedidos pagados en euros
        tests:
          - dbt_utils.expression_is_true:
              expression: ">= 0"

      - name: customer_tier
        description: premium (≥500€) | standard (≥100€) | entry
        tests:
          - accepted_values:
              values: [premium, standard, entry]

  - name: fct_orders
    description: Tabla de hechos incremental de pedidos Shopify. Particionada por día.
    tests:
      - dbt_utils.recency:
          datepart: day
          field: created_at
          interval: 1
    columns:
      - name: order_id
        tests: [unique, not_null]
      - name: customer_key
        tests:
          - relationships:
              to: ref('dim_customers')
              field: customer_key
🔧

Macros DRY

Lógica centralizada reutilizable. No repetir nunca la misma transformación en dos modelos.

📄macros/finance/cents_to_euros.sql SQL
{%- macro cents_to_euros(column_name, precision=2) -%}
    round({{ column_name }} / 100.0, {{ precision }})
{%- endmacro -%}

-- Uso en cualquier modelo:
{{ cents_to_euros('amount') }} as amount_eur,
{{ cents_to_euros('total_price_set', precision=0) }}
    as total_price_eur
📄macros/marketing/segment_customer_rfm.sql SQL
{%- macro segment_customer_rfm(ltv_col, last_order_col) -%}
    case
        when {{ ltv_col }} >= 500
             and {{ last_order_col }} >= dateadd(
               day, -90, current_date)
            then 'champion'
        when {{ ltv_col }} >= 200
             and {{ last_order_col }} >= dateadd(
               day, -180, current_date)
            then 'loyal'
        when {{ last_order_col }} >= dateadd(
               day, -30, current_date)
            then 'new_customer'
        when {{ last_order_col }} < dateadd(
               day, -365, current_date)
            then 'churned'
        else 'at_risk'
    end
{%- endmacro -%}
📄macros/utils/limit_data_in_dev.sql SQL
{%- macro limit_data_in_dev(column_name, days=7) -%}
    {%- if target.name == 'dev' -%}
        where {{ column_name }} >= dateadd(
            day, -{{ days }}, current_date)
    {%- endif -%}
{%- endmacro -%}

-- Uso: filtra solo 7 días en dev,
-- full scan en producción
select * from {{ ref('stg_shopify__orders') }}
{{ limit_data_in_dev('created_at') }}
📄macros/utils/generate_schema_name.sql SQL
{%- macro generate_schema_name(
        custom_schema_name, node) -%}
    {%- set default_schema = target.schema -%}
    {%- if custom_schema_name is none -%}
        {{ default_schema }}
    {%- else -%}
        {{ default_schema }}_{{ custom_schema_name }}
    {%- endif -%}
{%- endmacro -%}

-- Genera schemas como:
-- dev_mfernandez_staging
-- prod_staging, prod_analytics

Modelos Incrementales

Para tablas con alto volumen. fct_orders (85k pedidos/año) usa merge + partition por día.

📄Estrategias incrementales comparadas — BigQuery SQL
-- ESTRATEGIA 1: Merge (recomendada para late-arriving data)
{{
    config(
        materialized='incremental',
        unique_key='order_id',
        incremental_strategy='merge',
        merge_update_columns=['payment_status', 'fulfillment_status',
                              'total_price', 'updated_at', '_loaded_at']
    )
}}

-- ESTRATEGIA 2: Insert+Overwrite por partición (mayor throughput)
{{
    config(
        materialized='incremental',
        incremental_strategy='insert_overwrite',
        partition_by={
            "field": "created_date",
            "data_type": "date",
            "granularity": "day"
        }
    )
}}

-- Filtro incremental estándar con look-back de 3 días
-- (evita pérdida por late-arriving data de Shopify/Stripe)
select * from {{ ref('stg_shopify__orders') }}

{% if is_incremental() %}
where updated_at > (
    select dateadd(day, -3, max(updated_at)) from {{ this }}
)
{% endif %}
💻

Comandos CLI esenciales

Flujo de trabajo diario del equipo de datos de NutriShop

🔨 Desarrollo
dbt run --target dev todo en dev
dbt run --select staging solo staging
dbt run --select +fct_orders fct + upstream
dbt run --select fct_orders+ fct + downstream
dbt run --full-refresh rebuild incremental
🧪 Calidad
dbt test todos los tests
dbt test --select dim_customers modelo concreto
dbt build run + test DAG order
dbt source freshness check fivetran
dbt test --select tag:critical solo críticos
📚 Documentación
dbt docs generate genera docs
dbt docs serve abre en :8080
dbt ls --select tag:marketing listar por tag
🚀 Producción (CI/CD)
dbt build --target prod pipeline completa
dbt run --select state:modified+ solo cambios
dbt test --select state:modified test cambios
dbt compile validar SQL sin run

Best Practices para NutriShop

Reglas del equipo de datos. Revisadas por María Fernández (Data Engineer Senior).

✅ Hacer siempre
  • Staging 1:1 con la fuente — limpiar una vez, usar en todas partes
  • Tests not_null + unique en toda clave primaria
  • Documentar columnas en YAML junto al modelo
  • Usar macros para lógica repetida (>2 veces)
  • Modelos incrementales para tablas >500k filas
  • Look-back de 3 días en filtro incremental (late data)
  • Variables dbt para fechas: {{ var('start_date') }}
  • Dev target con limit_data_in_dev para ahorrar costes
❌ Nunca hacer
  • Raw → Marts directo (saltarse staging)
  • Hardcodear fechas en SQL ('2024-01-01')
  • Repetir la misma lógica de negocio en 2 modelos
  • Ejecutar full-refresh en prod sin aviso al equipo
  • Tests solo en marts (también en staging y fuentes)
  • Mezclar lógica de negocio en staging
  • Ignorar alertas de freshness de Fivetran
  • Materializar intermediate como table (gasto innecesario BQ)
⚙️

Configuración dbt_project.yml

Setup completo para el proyecto nutrishop_analytics en BigQuery

📄dbt_project.yml — nutrishop_analytics YAML
name: nutrishop_analytics
version: "1.0.0"
profile: bigquery

model-paths: ["models"]
test-paths: ["tests"]
macro-paths: ["macros"]
seed-paths: ["seeds"]

vars:
  start_date: "2022-01-01"
  lookback_days: 3
  env_name: "{{ env_var('DBT_ENV', 'dev') }}"

models:
  nutrishop_analytics:
    staging:
      +materialized: view
      +schema: staging
      +tags: ["staging"]
    intermediate:
      +materialized: ephemeral
      +tags: ["intermediate"]
    marts:
      +materialized: table
      +schema: analytics
      +tags: ["marts", "critical"]
      core:
        fct_orders:
          +materialized: incremental
        fct_revenue:
          +materialized: incremental