Ebook · Hoofdstuk 10 van 10
Case Study: Volledige Implementatie
Retailbedrijf RetailCo van 0 naar productie. Alle hoofdstukken in één doorlopend, realistisch voorbeeld.
RetailCo: een fictieve klant
Genoeg theorie. In dit slothoofdstuk lopen we van begin tot eind door de bouw van een echt datawarehouse. RetailCo is fictief, maar de keuzes en problemen zijn ontleend aan echte projecten. We doen het in chronologische volgorde — niet zoals het mooi opgeschreven kan worden, maar zoals het op kantoor werkt.
1. Bedrijfscontext
RetailCo is een mid-market modeketen: 87 winkels in de Benelux, één e-commerce platform, ~120 medewerkers op het hoofdkantoor. Jaaromzet ~€85M. Bestaande BI: Excel-rapporten uit het ERP, met handmatige consolidatie naar e-commerce. Frustratie: cijfers kloppen niet over teams heen, en CEO ziet pas op woensdag de cijfers van vorige week.
Sponsor: CFO. Doel: dagelijkse, geconsolideerde KPI's voor sales, voorraad en marketing — beschikbaar om 08:00 op een Power BI dashboard.
2. Stakeholders en analytical questions
Na vier 1-op-1's komt deze prioriteitslijst:
- "Wat was de gisteromzet per winkel, vergeleken met vorige week en vorig jaar?"
- "Welke productcategorieën hebben de hoogste gross margin?"
- "Wat is de voorraadrotatie per artikel per winkel?"
- "Hoeveel procent van de e-commerce-bezoekers converteert?"
- "Wat is de gemiddelde klantwaarde (CLV) per acquisitiekanaal?"
3. Niet-functionele requirements
- Freshness: T-1 (gisterdata om 06:00 beschikbaar).
- Latency: BI dashboard < 5 sec per visual.
- Concurrency: piek 25 gelijktijdige BI-gebruikers, ~5 power users.
- Volume: ~1.2M order lines / dag, 5 jaar historie nodig.
- Budget: max €4.000/maand inclusief tooling.
- Security: rolgebaseerde toegang, PII gescheiden van analytics.
- Compliance: GDPR — right to be forgotten ondersteund.
4. Architectuurkeuze
Met "Power BI eindgebruikers, beperkt budget, mid-market schaal, T-1 freshness" wordt de keuze:
- Warehouse: Snowflake (X-Small + Medium warehouses). Past in budget, multi-cloud-veilig, eenvoudige operations.
- Ingest: Fivetran voor SaaS-bronnen (Shopify, Salesforce), Airbyte zelf-gehost voor on-prem ERP.
- Transform: dbt Core in GitHub + dbt Cloud Job Scheduler.
- Orkestratie: dbt Cloud (geen aparte Airflow nodig op deze schaal).
- BI: Power BI met DirectQuery / Import-mix.
- Monitoring: Elementary (open-source dbt-package) + Snowflake Resource Monitors.
5. Bronsystemen
| Bron | Type | Connector | Refresh | Volume / dag |
|---|---|---|---|---|
| Microsoft Dynamics ERP | SQL Server on-prem | Airbyte (CDC) | Hourly | ~1M rijen |
| Shopify (e-commerce) | SaaS API | Fivetran | Hourly | ~50k rijen |
| Salesforce CRM | SaaS API | Fivetran | Daily | ~5k rijen |
| Klaviyo (marketing) | SaaS API | Fivetran | Daily | ~10k rijen |
| POS systeem | Daily CSV via SFTP | Custom Python | Daily 02:00 | ~200k rijen |
6. Data model — bus matrix
| Proces ↓ / Dimensie → | Datum | Klant | Product | Winkel | Kanaal | Promotie |
|---|---|---|---|---|---|---|
| Verkoop (POS + e-com) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Voorraad | ✓ | ✓ | ✓ | |||
| Marketing | ✓ | ✓ | ✓ | ✓ | ||
| Inkoop | ✓ | ✓ |
7. Fysiek model — gold layer
-- DIMENSIES
CREATE TABLE dim_date (
date_key INT PRIMARY KEY,
full_date DATE,
year INT,
quarter INT,
month INT,
month_name VARCHAR(20),
week_of_year INT,
day_of_week INT,
weekday_name VARCHAR(20),
is_weekend BOOLEAN,
is_holiday BOOLEAN
);
CREATE TABLE dim_customer (
customer_key INT IDENTITY PRIMARY KEY,
pseudonym_id CHAR(64), -- referentie naar pii_customer
customer_id VARCHAR(50),
segment VARCHAR(50),
acq_channel VARCHAR(50),
country VARCHAR(50),
signup_date DATE,
valid_from DATE,
valid_to DATE,
is_current BOOLEAN,
hash_diff CHAR(64)
);
CREATE TABLE dim_product (
product_key INT IDENTITY PRIMARY KEY,
product_id VARCHAR(50),
sku VARCHAR(50),
name VARCHAR(200),
category VARCHAR(100),
subcategory VARCHAR(100),
brand VARCHAR(100),
unit_cost_eur DECIMAL(10,2),
unit_price_eur DECIMAL(10,2),
valid_from DATE,
valid_to DATE,
is_current BOOLEAN,
hash_diff CHAR(64)
);
CREATE TABLE dim_store (
store_key INT IDENTITY PRIMARY KEY,
store_id VARCHAR(20),
store_name VARCHAR(100),
region VARCHAR(50),
country VARCHAR(50),
m2 INT,
opened_date DATE,
is_active BOOLEAN
);
CREATE TABLE dim_channel (
channel_key INT PRIMARY KEY,
channel_name VARCHAR(50) -- 'POS', 'Web', 'App', 'Marketplace'
);
-- FACTS
CREATE TABLE fact_sales (
sales_key BIGINT IDENTITY,
date_key INT,
customer_key INT,
product_key INT,
store_key INT,
channel_key INT,
order_id VARCHAR(50), -- degenerate dim
quantity INT,
unit_price DECIMAL(10,2),
unit_cost DECIMAL(10,2),
discount DECIMAL(10,2),
net_amount DECIMAL(12,2),
gross_margin DECIMAL(12,2),
load_ts TIMESTAMP
)
CLUSTER BY (date_key);
CREATE TABLE fact_inventory_daily (
date_key INT,
product_key INT,
store_key INT,
units_on_hand INT,
units_received INT,
units_sold INT,
inventory_value_eur DECIMAL(12,2),
PRIMARY KEY (date_key, product_key, store_key)
);
8. dbt project structuur
retailco_dwh/
├── dbt_project.yml
├── models/
│ ├── staging/
│ │ ├── erp/
│ │ │ ├── stg_erp__sales_lines.sql
│ │ │ └── stg_erp__inventory.sql
│ │ ├── shopify/
│ │ │ ├── stg_shopify__orders.sql
│ │ │ └── stg_shopify__customers.sql
│ │ └── salesforce/
│ │ └── stg_salesforce__accounts.sql
│ ├── intermediate/
│ │ ├── int_sales_unioned.sql -- POS + e-com
│ │ └── int_customers_mastered.sql -- MDM matching
│ └── marts/
│ ├── dim_date.sql
│ ├── dim_customer.sql -- SCD Type 2
│ ├── dim_product.sql
│ ├── dim_store.sql
│ ├── dim_channel.sql
│ ├── fact_sales.sql
│ └── fact_inventory_daily.sql
├── tests/
│ ├── assert_sales_total_matches_lines.sql
│ └── assert_no_negative_quantities.sql
└── macros/
└── generate_surrogate_key.sql
9. Voorbeeldmodel — fact_sales
-- models/marts/fact_sales.sql
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='sales_key',
cluster_by=['date_key']
) }}
WITH s AS (
SELECT *
FROM {{ ref('int_sales_unioned') }}
{% if is_incremental() %}
WHERE load_ts > (SELECT MAX(load_ts) FROM {{ this }})
{% endif %}
)
SELECT
{{ dbt_utils.generate_surrogate_key(['s.order_id', 's.line_no']) }} AS sales_key,
TO_NUMBER(TO_CHAR(s.order_date, 'YYYYMMDD')) AS date_key,
c.customer_key,
p.product_key,
st.store_key,
ch.channel_key,
s.order_id,
s.quantity,
s.unit_price,
p.unit_cost_eur AS unit_cost,
s.discount,
s.quantity * (s.unit_price - s.discount) AS net_amount,
s.quantity * (s.unit_price - p.unit_cost_eur) AS gross_margin,
CURRENT_TIMESTAMP AS load_ts
FROM s
LEFT JOIN {{ ref('dim_customer') }} c
ON s.customer_id = c.customer_id AND c.is_current
LEFT JOIN {{ ref('dim_product') }} p
ON s.product_id = p.product_id AND p.is_current
LEFT JOIN {{ ref('dim_store') }} st
ON s.store_id = st.store_id
LEFT JOIN {{ ref('dim_channel') }} ch
ON s.channel = ch.channel_name
10. Quality tests
version: 2
models:
- name: fact_sales
columns:
- name: sales_key
tests: [unique, not_null]
- name: date_key
tests:
- not_null
- relationships: { to: ref('dim_date'), field: date_key }
- name: customer_key
tests:
- relationships: { to: ref('dim_customer'), field: customer_key }
- name: net_amount
tests:
- dbt_utils.expression_is_true: { expression: ">= 0" }
- name: gross_margin
tests:
- dbt_utils.expression_is_true: { expression: "<= net_amount" }
- name: dim_customer
columns:
- name: customer_key
tests: [unique, not_null]
- name: customer_id
tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns: [customer_id, valid_from]
sources:
- name: erp
schema: raw_erp
freshness: { warn_after: { count: 6, period: hour }, error_after: { count: 12, period: hour } }
tables:
- name: sales_lines
loaded_at_field: _loaded_at
11. Pipeline-schema
-- dbt Cloud Job: 'daily_full_build'
-- Schedule: 0 4 * * * (04:00 UTC)
--
-- Stappen:
-- 1. dbt source freshness → fail-fast als bron-data te oud is
-- 2. dbt run --select staging (alle stg_ modellen)
-- 3. dbt run --select intermediate
-- 4. dbt run --select marts
-- 5. dbt test (alle quality tests)
-- 6. dbt docs generate (auto-deploy)
-- 7. Slack notify on success/failure
12. Voorbeeld dashboard query
-- Dagelijkse sales per winkel met YoY-vergelijking
WITH this_year AS (
SELECT s.store_key, st.store_name, st.region,
SUM(f.net_amount) AS revenue_ytd,
SUM(f.gross_margin) AS margin_ytd
FROM fact_sales f
JOIN dim_store st ON st.store_key = f.store_key
WHERE f.date_key BETWEEN 20260101 AND TO_NUMBER(TO_CHAR(CURRENT_DATE, 'YYYYMMDD'))
GROUP BY 1, 2, 3
),
last_year AS (
SELECT s.store_key,
SUM(f.net_amount) AS revenue_ly
FROM fact_sales f
WHERE f.date_key BETWEEN 20250101 AND TO_NUMBER(TO_CHAR(DATEADD(YEAR, -1, CURRENT_DATE), 'YYYYMMDD'))
GROUP BY 1
)
SELECT t.store_name, t.region, t.revenue_ytd, t.margin_ytd,
l.revenue_ly,
(t.revenue_ytd - l.revenue_ly) / NULLIF(l.revenue_ly, 0) AS yoy_growth
FROM this_year t
LEFT JOIN last_year l ON l.store_key = t.store_key
ORDER BY t.revenue_ytd DESC;
13. Wat ging mis (en wat we leerden)
Geen project zonder hobbels. Drie incidenten uit RetailCo's eerste jaar:
- Maand 2: POS-CSV bevat soms negatieve quantities (returns) — onze test brak. Oplossing: returns expliciet als aparte transactie modelleren, niet als negatieve verkoop.
- Maand 4: Shopify hernoemde een veld in de API — onze stg_-laag negeerde stille schema-drift. Oplossing: schema-drift-detectie ingevoerd (zie hoofdstuk 9), met dagelijks snapshot.
- Maand 7: Snowflake-rekening verdubbelde door één junior-analyst die
SELECT * FROM fact_salesin een dashboard-filter gebruikte. Oplossing: queryreviews op power-user-rollen, plus resource monitor met hard limit.
14. Resultaten na 9 maanden
- Dagelijks dashboard om 07:00 voor de directie — geen handmatige consolidatie meer.
- 26 dbt-modellen, 78 tests, 99.4% on-time runs.
- Maandelijkse Snowflake-kosten: €1.840 (binnen budget).
- Twee analisten kunnen nu zelfstandig nieuwe analyses bouwen — voorheen tickets bij IT.
- CEO citaat: "Eindelijk één getal voor omzet."
15. Wat we de volgende keer anders zouden doen
- Vroeger investeren in MDM voor klant — pas in maand 5 begonnen, retroactief lastig.
- PII-scheiding vanaf dag één in plaats van bij eerste GDPR-vraag in maand 6.
- Power BI dataset-grootte beperken — DirectQuery-mix had eerder ingeregeld kunnen worden.
- Documentatie als deliverable per sprint, niet als slotklus.
Slotwoord
Een datawarehouse bouwen is geen technisch project — het is een project waarin techniek het slotsom is van duidelijke afspraken, gedeelde definities en operationele discipline. Elk hoofdstuk van dit ebook raakt een hoek van die discipline. De magie zit niet in een specifieke tool of patroon, maar in de combinatie: weten wat je business vraagt, kiezen wat past, uitvoeren met aandacht voor kwaliteit, en blijven onderhouden.
Veel succes met je eigen warehouse — en denk eraan: de eerste versie is altijd de slechtste. Bouw, leer, verbeter.
Verder met DataPartner365
Dit ebook is geschreven om vrij beschikbaar te zijn. Heb je hulp nodig bij een echt project — van architectuurkeuze tot implementatie of doorontwikkeling? Neem contact op voor een vrijblijvend gesprek.