Arquitectura Medallion
Flujo de datos desde fuentes brutas hasta marts analíticos para Looker Studio
DAG del Proyecto NutriShop
Linaje de dependencias entre modelos
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.
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]
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.
{{ 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.
{{ 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
{{ 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.
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.
{%- 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
{%- 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 -%}
{%- 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') }}
{%- 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.
-- 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
Best Practices para NutriShop
Reglas del equipo de datos. Revisadas por María Fernández (Data Engineer Senior).
- 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_devpara ahorrar costes
- 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
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