Skip to Content
L’échange d’apprentissage de HIC débute le 13 Juillet 2026. Voir le programme
AtelierÉlément 11 sur 22 · 2 h

Lab 3 : Modélisation dbt Medallion — cas DHIS2

Ceci est la session pratique de « Lab 3 : Mettre en place l’infrastructure Medallion — Bronze, Silver, Gold » (jour 10 de l’événement), dans la continuité de Formation dbt : de l’installation au premier modèle et de Introduction à la modélisation des données : données agrégées et tracker de DHIS2, organisées en couches Bronze, Silver et Gold.

Ce que vous apprendrez
  • Déclarer les tables DHIS2 brutes comme sources dbt dans la couche Bronze
  • Construire des modèles Bronze fins, en vues 1:1 sur les tables brutes agrégées et tracker
  • Conformer des modèles Silver qui résolvent les références UID/ID en noms lisibles
  • Construire un schéma en étoile Gold avec des modèles dim_ et fact_ pour les données agrégées et tracker
  • Configurer les matérialisations par couche et les tables de faits incrémentales dans dbt_project.yml
  • Écrire des tests relationships, unique et not_null sur les couches Bronze, Silver et Gold
Avant de commencer
Télécharger les fichiers du labo (.zip)

1. Introduction

DHIS2 stocke deux types de données fondamentalement différents : les données agrégées (des totaux rapportés par unité d’organisation, période et catégorie — p. ex. « 120 cas de paludisme dans le district de Kigali, mars 2026 ») et les données tracker (des enregistrements individuels — clients, enrôlements et événements, p. ex. l’historique des visites CPN d’une patiente). Les deux doivent arriver prêts à l’analyse dans l’entrepôt, mais ils partent de tables brutes très différentes.

Nous modélisons les deux à travers le même schéma en trois couches :

Bronze, Silver et Gold sont les noms « medallion » des couches landing, staging et marts vues dans le cours d’introduction.

Hypothèse pour cette démo : les tables PostgreSQL brutes de DHIS2 (ou des extraits API déposés sous forme de tables) sont répliquées dans un schéma raw de l’entrepôt avant l’exécution de dbt. Si vous extrayez plutôt via l’API Web DHIS2 vers du JSON/Parquet, la couche Bronze devient simplement une étape de parsing — les couches Silver et Gold ci-dessous restent identiques.

2. Rappel du modèle de données DHIS2

AgrégéTracker
Une ligne = un nombre récapitulatif pour un combo unité d’organisation + période + élément de données + catégorieUne ligne = un fait survenu à une personne/entité précise
Table centrale : datavalueTables centrales : trackedentityinstance, programinstance, programstageinstance
Adapté à : reporting de routine (totaux HMIS)Adapté à : analyse par cas / longitudinale (parcours patients)
Pas d’identité individuelleIdentité individuelle (dé-identifiée via UID)

3. Architecture en couches

CoucheCe que c’estMatérialisation
BronzeCopie structurelle exacte des tables sources. Pas de renommage, pas de jointures, corrections de typage minimales.Vues — peu coûteuses, toujours fraîches
SilverLisible, dédupliqué, typé et joint pour résoudre les références UID/ID en noms. Une ligne signifie toujours la même chose qu’en Bronze — on la rend simplement utilisable.Vues ou tables
GoldSchéma en étoile dimensionnel : modèles dim_ et fact_ séparés, prêts pour les outils BI (, PowerBI).Tables, souvent incrémentales pour les grandes tables de faits

4. Structure du projet

        • bronze_dataelement.sql
        • bronze_dataset.sql
        • bronze_indicator.sql
        • bronze_period.sql
        • bronze_organisationunit.sql
        • bronze_categorycombo.sql
        • bronze_categoryoptioncombo.sql
        • bronze_datavalue.sql
        • bronze_trackedentityinstance.sql
        • bronze_trackedentityattributevalue.sql
        • bronze_program.sql
        • bronze_programstage.sql
        • bronze_programinstance.sql
        • bronze_programstageinstance.sql
        • bronze_trackedentitydatavalue.sql
        • bronze_relationship.sql
        • silver_org_units.sql
        • silver_periods.sql
        • silver_data_elements.sql
        • silver_category_option_combos.sql
        • silver_aggregate_datavalues.sql
        • silver_tracked_entities.sql
        • silver_enrollments.sql
        • silver_events.sql
        • silver_event_data_values.sql
        • dim_org_unit.sql
        • dim_period.sql
        • dim_data_element.sql
        • dim_category_option_combo.sql
        • fact_aggregate_data_value.sql
        • dim_tracked_entity.sql
        • dim_program.sql
        • fact_enrollment.sql
        • fact_event.sql
        • fact_event_data_value.sql

5. Bronze — Déclarer les sources

Chaque table brute est déclarée une seule fois dans models/bronze/sources.yml, pointant vers le schéma où atterrissent les tables de DHIS2.

models/bronze/sources.yml
version: 2 sources: - name: dhis2_raw schema: raw tables: # aggregate - name: dataelement - name: dataset - name: indicator - name: period - name: periodtype - name: organisationunit - name: _orgunitstructure - name: categorycombo - name: categoryoptioncombo - name: categoryoption - name: datavalue # tracker - name: trackedentityinstance - name: trackedentityattribute - name: trackedentityattributevalue - name: program - name: programstage - name: programinstance - name: programstageinstance - name: trackedentitydatavalue - name: relationship - name: relationshiptype

6. Bronze — Tables brutes agrégées

TableCe qu’elle contient
dataelementDéfinitions de ce qui est mesuré (p. ex. « CPN 1re visite »)
datasetRegroupements d’éléments de données collectés ensemble sur un même formulaire
indicatorMétriques calculées (formules numérateur/dénominateur)
periodLa période à laquelle une valeur s’applique (mensuelle, trimestrielle…)
organisationunitLa hiérarchie établissement/district/région
_orgunitstructureHiérarchie des unités d’organisation aplatie (noms des niveaux 1–5)
categorycombo / categoryoptioncomboDésagrégations (p. ex. par âge/sexe)
datavalueLe nombre effectivement rapporté — une ligne par unité d’organisation + période + élément de données + combo d’options de catégorie

Les modèles Bronze sont minces — une simple sélection depuis la source, avec un léger typage :

models/bronze/aggregate/bronze_datavalue.sql
-- models/bronze/aggregate/bronze_datavalue.sql select dataelementid, periodid, sourceid as organisationunitid, categoryoptioncomboid, attributeoptioncomboid, cast(value as numeric) as value, storedby, lastupdated, created from {{ source('dhis2_raw', 'datavalue') }}
models/bronze/aggregate/bronze_dataelement.sql
-- models/bronze/aggregate/bronze_dataelement.sql select dataelementid, uid, name, shortname, domaintype, -- AGGREGATE or TRACKER valuetype, lastupdated from {{ source('dhis2_raw', 'dataelement') }}
models/bronze/aggregate/bronze_organisationunit.sql
-- models/bronze/aggregate/bronze_organisationunit.sql select organisationunitid, uid, name, parentid, path, hierarchylevel from {{ source('dhis2_raw', 'organisationunit') }}

Les autres modèles Bronze agrégés (bronze_dataset, bronze_indicator, bronze_period, bronze_categorycombo, bronze_categoryoptioncombo) suivent le même schéma un-pour-un.

7. Bronze — Tables brutes tracker

TableCe qu’elle contient
trackedentityinstanceUne ligne par personne/entité suivie (client, ménage…)
trackedentityattributeDéfinitions des attributs au niveau de la personne (p. ex. « Date de naissance »)
trackedentityattributevalueLes valeurs d’attributs effectives par entité
programUn programme tracker (p. ex. « Programme CPN »)
programstageUne étape au sein d’un programme (p. ex. « Visite CPN 1 »)
programinstanceUn enrôlement — une entité inscrite dans un programme
programstageinstanceUn événement — une occurrence d’une étape de programme
trackedentitydatavalueLes valeurs de données saisies lors d’un événement précis
relationship / relationshiptypeLiens entre entités (p. ex. mère–enfant)
models/bronze/tracker/bronze_trackedentityinstance.sql
-- models/bronze/tracker/bronze_trackedentityinstance.sql select trackedentityinstanceid, uid, organisationunitid, trackedentitytypeid, created, lastupdated, deleted from {{ source('dhis2_raw', 'trackedentityinstance') }}
models/bronze/tracker/bronze_programinstance.sql
-- models/bronze/tracker/bronze_programinstance.sql select programinstanceid, uid, trackedentityinstanceid, programid, organisationunitid, enrollmentdate, incidentdate, status, -- ACTIVE, COMPLETED, CANCELLED lastupdated from {{ source('dhis2_raw', 'programinstance') }}
models/bronze/tracker/bronze_programstageinstance.sql
-- models/bronze/tracker/bronze_programstageinstance.sql select programstageinstanceid, uid, programinstanceid, programstageid, organisationunitid, executiondate, duedate, status, -- COMPLETED, SCHEDULE, SKIPPED, OVERDUE lastupdated from {{ source('dhis2_raw', 'programstageinstance') }}
models/bronze/tracker/bronze_trackedentitydatavalue.sql
-- models/bronze/tracker/bronze_trackedentitydatavalue.sql select programstageinstanceid, dataelementid, value, storedby, lastupdated from {{ source('dhis2_raw', 'trackedentitydatavalue') }}

Les autres modèles Bronze tracker (bronze_trackedentityattribute, bronze_trackedentityattributevalue, bronze_program, bronze_programstage, bronze_relationship) suivent le même schéma.

Point de contrôleLa couche Bronze se construit sans erreur
Vous devriez voir :
$ dbt run --select bronze.* ... 16 of 16 OK created sql view model bronze.bronze_trackedentitydatavalue .... [CREATE VIEW in 0.11s] Completed successfully Done. PASS=16 WARN=0 ERROR=0 SKIP=0 TOTAL=16
Vous ne voyez pas cela ?
  • ERROR relation "raw.datavalue" does not exist — la table source n’est pas encore répliquée, ou le schema: raw de sources.yml ne correspond pas à l’endroit réel où atterrissent les tables DHIS2. Vérifiez avec select * from raw.datavalue limit 1; avant de relancer.
  • Une vue Bronze se construit mais retourne 0 ligne — le job de réplication en amont n’a pas encore alimenté cette table ; c’est un problème côté source, pas côté dbt. Vérifiez directement le schéma raw.
  • permission denied for schema raw — le rôle d’entrepôt utilisé par dbt n’a pas les droits USAGE/SELECT sur raw ; accordez-les avant de continuer.

8. Silver — Modèles agrégés conformés

models/silver/aggregate/silver_org_units.sql
-- models/silver/aggregate/silver_org_units.sql select o.organisationunitid, o.uid as org_unit_uid, o.name as org_unit_name, s.namelevel1 as national_name, s.namelevel2 as province_name, s.namelevel3 as district_name, s.namelevel4 as facility_name, o.hierarchylevel from {{ ref('bronze_organisationunit') }} o left join {{ source('dhis2_raw', '_orgunitstructure') }} s on o.organisationunitid = s.organisationunitid
models/silver/aggregate/silver_aggregate_datavalues.sql
-- models/silver/aggregate/silver_aggregate_datavalues.sql select dv.dataelementid, de.name as data_element_name, dv.organisationunitid, dv.periodid, p.startdate as period_start_date, p.enddate as period_end_date, dv.categoryoptioncomboid, dv.value, dv.lastupdated from {{ ref('bronze_datavalue') }} dv left join {{ ref('bronze_dataelement') }} de on dv.dataelementid = de.dataelementid left join {{ ref('bronze_period') }} p on dv.periodid = p.periodid where dv.value is not null

De la même manière, silver_periods, silver_data_elements et silver_category_option_combos résolvent les noms/hiérarchies pour les jointures de la couche Gold.

9. Silver — Modèles tracker conformés

models/silver/tracker/silver_tracked_entities.sql
-- models/silver/tracker/silver_tracked_entities.sql select tei.trackedentityinstanceid, tei.uid as tracked_entity_uid, tei.organisationunitid, ou.org_unit_name, ou.district_name, tei.created as registration_date, tei.deleted from {{ ref('bronze_trackedentityinstance') }} tei left join {{ ref('silver_org_units') }} ou on tei.organisationunitid = ou.organisationunitid where tei.deleted = false
models/silver/tracker/silver_enrollments.sql
-- models/silver/tracker/silver_enrollments.sql select pi.programinstanceid, pi.uid as enrollment_uid, pi.trackedentityinstanceid, pi.programid, prog.name as program_name, pi.organisationunitid, pi.enrollmentdate, pi.incidentdate, pi.status as enrollment_status from {{ ref('bronze_programinstance') }} pi left join {{ ref('bronze_program') }} prog on pi.programid = prog.programid
models/silver/tracker/silver_events.sql
-- models/silver/tracker/silver_events.sql select psi.programstageinstanceid, psi.uid as event_uid, psi.programinstanceid, psi.programstageid, stg.name as program_stage_name, psi.organisationunitid, psi.executiondate, psi.duedate, psi.status as event_status from {{ ref('bronze_programstageinstance') }} psi left join {{ ref('bronze_programstage') }} stg on psi.programstageid = stg.programstageid
models/silver/tracker/silver_event_data_values.sql
-- models/silver/tracker/silver_event_data_values.sql select tdv.programstageinstanceid, tdv.dataelementid, de.name as data_element_name, tdv.value, tdv.lastupdated from {{ ref('bronze_trackedentitydatavalue') }} tdv left join {{ ref('bronze_dataelement') }} de on tdv.dataelementid = de.dataelementid
Point de contrôleSilver résout les noms et les hiérarchies
Vous devriez voir :
$ dbt run --select silver.* ... Done. PASS=9 WARN=0 ERROR=0 SKIP=0 TOTAL=9 select org_unit_name, district_name from silver_org_units limit 3; org_unit_name | district_name ---------------------------+------------------ Kigali Health Center | Kigali District Nyagatare District Hosp. | Nyagatare District Huye Health Post | Huye District
Vous ne voyez pas cela ?
  • district_name est null sur la plupart des lignes — le left join vers _orgunitstructure ne matche pas parce que hierarchylevel diffère entre les deux tables, ou que namelevel3/namelevel4 correspondent au mauvais niveau dans la hiérarchie de votre instance DHIS2. Vérifiez ce mapping face à organisationunit.path.
  • silver_aggregate_datavalues retourne 0 ligne alors que bronze_datavalue contient des données — le cast(value as numeric) du Bronze a échoué silencieusement sur une chaîne non numérique en amont, ou le filtre where dv.value is not null élimine tout parce que la jointure vers bronze_period/bronze_dataelement a produit des nulls inattendus.
  • Vous voyez encore des identifiants bruts au lieu de noms — vous interrogez la table Bronze, pas la table Silver ; Bronze reste volontairement clé sur les identifiants bruts.

10. Gold — Schéma en étoile agrégé

models/gold/aggregate/dim_org_unit.sql
-- models/gold/aggregate/dim_org_unit.sql select distinct organisationunitid as org_unit_key, org_unit_uid, org_unit_name, national_name, province_name, district_name, facility_name, hierarchylevel from {{ ref('silver_org_units') }}
models/gold/aggregate/dim_period.sql
-- models/gold/aggregate/dim_period.sql select distinct periodid as period_key, period_start_date, period_end_date, extract(year from period_start_date) as year, extract(quarter from period_start_date) as quarter, extract(month from period_start_date) as month from {{ ref('silver_aggregate_datavalues') }}
models/gold/aggregate/fact_aggregate_data_value.sql
-- models/gold/aggregate/fact_aggregate_data_value.sql select dataelementid as data_element_key, organisationunitid as org_unit_key, periodid as period_key, categoryoptioncomboid as category_option_combo_key, value, lastupdated from {{ ref('silver_aggregate_datavalues') }}

Pourquoi cette forme : une seule table de faits (fact_aggregate_data_value) jointe aux dimensions offre aux outils BI une surface de requête unique et rapide — « montre-moi les visites CPN par district et par trimestre » devient une simple jointure, quelle que soit la structure des tables brutes de DHIS2.

11. Gold — Schéma en étoile tracker

models/gold/tracker/dim_tracked_entity.sql
-- models/gold/tracker/dim_tracked_entity.sql select distinct trackedentityinstanceid as tracked_entity_key, tracked_entity_uid, org_unit_name, district_name, registration_date from {{ ref('silver_tracked_entities') }}
models/gold/tracker/dim_program.sql
-- models/gold/tracker/dim_program.sql select distinct programid as program_key, program_name from {{ ref('silver_enrollments') }}
models/gold/tracker/fact_enrollment.sql
-- models/gold/tracker/fact_enrollment.sql select programinstanceid as enrollment_key, trackedentityinstanceid as tracked_entity_key, programid as program_key, organisationunitid as org_unit_key, enrollmentdate, incidentdate, enrollment_status from {{ ref('silver_enrollments') }}
models/gold/tracker/fact_event.sql
-- models/gold/tracker/fact_event.sql select programstageinstanceid as event_key, programinstanceid as enrollment_key, programstageid as program_stage_key, organisationunitid as org_unit_key, executiondate, duedate, event_status from {{ ref('silver_events') }}
models/gold/tracker/fact_event_data_value.sql
-- models/gold/tracker/fact_event_data_value.sql select programstageinstanceid as event_key, dataelementid as data_element_key, data_element_name, value from {{ ref('silver_event_data_values') }}
Point de contrôleLe schéma en étoile Gold se requête en une seule jointure
Vous devriez voir :
$ dbt run --select gold.* ... Done. PASS=10 WARN=0 ERROR=0 SKIP=0 TOTAL=10 select o.district_name, p.quarter, sum(f.value) as total from fact_aggregate_data_value f join dim_org_unit o on f.org_unit_key = o.org_unit_key join dim_period p on f.period_key = p.period_key group by 1, 2 order by 1, 2; district_name | quarter | total -------------------+---------+------- Kigali District | 1 | 842 Kigali District | 2 | 915 Nyagatare District| 1 | 367
Vous ne voyez pas cela ?
  • La jointure vers dim_org_unit ou dim_period ne retourne aucune ligne — un décalage de type de clé entre la table de faits et la dimension (p. ex. org_unit_key stocké en bigint dans un modèle et casté en text dans un autre). Vérifiez que les modèles de dimension (select distinct) et le modèle de faits utilisent le même type de colonne source.
  • fact_aggregate_data_value contient des doublons après un second dbt run — le unique_key de la config incrémentale dans dbt_project.yml ne correspond pas au grain réel de la table de faits ; relancez avec --full-refresh une fois corrigé.
  • dim_program ou dim_tracked_entity manque des lignes présentes dans Silver — rappelez-vous que ces dimensions sont construites via select distinct sur un modèle Silver qui a déjà filtré certaines lignes (p. ex. where tei.deleted = false) ; vérifiez d’abord la clause where du modèle Silver.

12. Configuration des matérialisations

Définissez les matérialisations par défaut de chaque couche dans dbt_project.yml, afin que personne n’ait à configurer chaque modèle individuellement :

dbt_project.yml
# dbt_project.yml models: my_dhis2_project: bronze: +materialized: view silver: +materialized: view gold: +materialized: table aggregate: fact_aggregate_data_value: +materialized: incremental +unique_key: [data_element_key, org_unit_key, period_key, category_option_combo_key] tracker: fact_event_data_value: +materialized: incremental +unique_key: [event_key, data_element_key]

13. Stratégie de tests

CoucheQuoi tester
Bronzenot_null sur les clés primaires — détecter tôt une réplication cassée
SilverTests relationships — confirmer que chaque clé étrangère se résout (p. ex. l’unité d’organisation de chaque datavalue existe)
Goldunique + not_null sur les clés de dimensions ; contrôles de fraîcheur par comptage de lignes sur les faits
models/gold/aggregate/schema.yml
# models/gold/aggregate/schema.yml version: 2 models: - name: dim_org_unit columns: - name: org_unit_key tests: [unique, not_null] - name: fact_aggregate_data_value columns: - name: org_unit_key tests: - relationships: to: ref('dim_org_unit') field: org_unit_key
Point de contrôleLes tests de schéma passent sur les trois couches
Vous devriez voir :
$ dbt test ... Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6
Vous ne voyez pas cela ?
  • Le test relationships sur org_unit_key ÉCHOUE — une ligne de faits référence une unité d’organisation fusionnée ou supprimée dans DHIS2 après l’enregistrement du fait. Réintégrez les unités historiques (supprimées en douceur) dans dim_org_unit, ou filtrez les lignes orphelines dès le modèle Silver.
  • not_null ÉCHOUE sur une clé de dimension — le select distinct du modèle Gold laisse passer un null issu d’une jointure Silver non résolue en amont ; remontez ce null jusqu’au left join Silver qui l’a produit.
  • unique ÉCHOUE sur org_unit_keydim_org_unit est construit avec select distinct sur des colonnes qui ne sont pas réellement uniques par unité d’organisation (p. ex. org_unit_name se répète entre districts) ; le distinct sur la ligne entière n’équivaut pas à un distinct sur la clé.

14. Exécuter le pipeline

# run everything, in dependency order dbt run # run just the aggregate branch, bronze through gold dbt run --select path:models/bronze/aggregate+ path:models/silver/aggregate+ path:models/gold/aggregate+ # run just the tracker branch dbt run --select path:models/bronze/tracker+ path:models/silver/tracker+ path:models/gold/tracker+ # run one layer at a time (good for a live walkthrough) dbt run --select bronze.* dbt run --select silver.* dbt run --select gold.* # test everything dbt test

15. Déroulé de la démo en direct

  1. Montrer les tables brutes datavalue et trackedentityinstance directement dans l’entrepôt — souligner à quel point les identifiants sont illisibles
  2. Lancer dbt run --select bronze.* — des vues 1:1 apparaissent, toujours avec les identifiants bruts
  3. Lancer dbt run --select silver.* — les noms et hiérarchies se résolvent, les données deviennent lisibles
  4. Lancer dbt run --select gold.* — les dimensions et les faits apparaissent
  5. Interroger fact_aggregate_data_value joint à dim_org_unit + dim_period — « les visites CPN par district et par trimestre » en une simple jointure SQL
  6. Interroger fact_event joint à dim_tracked_entity — l’historique complet des visites d’un patient en une seule jointure
  7. Lancer dbt test — montrer les tests de relations attrapant une référence d’unité d’organisation délibérément cassée
Point de contrôleLa démo de référence cassée est bien attrapée par dbt test
Vous devriez voir :
$ dbt test --select fact_aggregate_data_value 1 of 1 START test relationships_fact_aggregate_data_value_org_unit_key__org_unit_key__ref_dim_org_unit_ 1 of 1 FAIL 1 relationships_fact_aggregate_data_value_org_unit_key__org_unit_key__ref_dim_org_unit_ .. [FAIL 1 in 0.08s] Done. PASS=0 WARN=0 ERROR=0 FAIL=1 SKIP=0 TOTAL=1
Vous ne voyez pas cela ?
  • Le test PASSE alors qu’il devrait ÉCHOUER — l’identifiant d’unité d’organisation délibérément cassé doit être inséré dans la source raw avant de relancer dbt run, pas après ; une table Gold non rafraîchie reflète encore les données propres précédentes.
  • Le test ÉCHOUE mais le nombre de faits est à 0 — vous avez ciblé fact_event au lieu de fact_aggregate_data_value ; la démo de référence cassée ci-dessus est écrite pour la branche agrégée.
  • dbt run plante au lieu de faire échouer le test — vous avez cassé une colonne de clé étrangère not null (en la mettant à NULL) plutôt que de la pointer vers une unité d’organisation inexistante ; utilisez un identifiant absent de organisationunit, pas un null.