Adrienne Vermorel

Structure de projet dbt : le guide complet

La structure d’un projet dbt est l’une des premières décisions que vous prenez au démarrage, et l’une des plus difficiles à modifier par la suite. Si vous vous trompez, dans six mois vous vous retrouverez face à un DAG emmêlé, à vous demander lequel de vos 47 modèles base alimente réellement le dashboard qui vient de tomber en panne.

Le problème, c’est que la plupart des ressources disponibles en ligne sont soit obsolètes (référençant des patterns que dbt Labs a depuis abandonnés), soit trop théoriques (expliquant quoi faire sans expliquer pourquoi c’est important). Ce guide est différent. Il s’appuie sur l’audit de projets de plus de 400 modèles et la construction de pipelines marketing analytics from scratch. Ces patterns ont été éprouvés sur de vrais projets clients.

Cet article présente un framework complet et opinioné pour structurer vos projets dbt. Il commence par les fondamentaux pour les débutants, puis prend position clairement sur les débats qui intéressent vraiment les praticiens expérimentés. Il va au-delà du simple dossier models/ pour couvrir les macros, les tests, les seeds, les snapshots et l’organisation des fichiers YAML. Tout est ancré dans des exemples concrets de marketing analytics : GA4, plateformes publicitaires et modèles d’attribution.

La thèse est simple : la cohérence compte plus que n’importe quelle convention spécifique, mais il vous faut une convention pour être cohérent. L’objectif est une structure où n’importe qui dans votre équipe peut trouver n’importe quel modèle en moins de 10 secondes.

Il est temps de construire cette structure.


L’architecture à trois couches : base → intermediate → marts

Le fondement de tout projet dbt bien structuré est une architecture en couches claire. Les données doivent passer d’un état source-conformed (façonné par des systèmes externes que vous ne contrôlez pas) à un état business-conformed (façonné par les besoins et définitions de votre organisation).

Les couches que j’utilise sont base, intermediate et marts. Vous verrez souvent « staging » à la place de « base » dans la documentation dbt, mais je préfère base/intermediate/marts pour une raison simple : l’ordre alphabétique correspond à l’ordre du lineage. Quand vous ouvrez votre dossier models/, vous voyez base/ en premier, puis intermediate/, puis marts/. C’est exactement l’ordre dans lequel les données traversent votre projet. C’est un détail, mais ça réduit la charge cognitive au quotidien.

La couche base

La couche base est l’endroit où les données sources entrent dans votre projet dbt. Chaque modèle base a une relation 1-to-1 avec une table source. Sans exception.

Ce qui appartient aux modèles base :

  • Renommer les colonnes selon des conventions cohérentes (snake_case, suppression des préfixes comme c_ ou tbl_)
  • Cast de types (strings en dates, integers en booleans)
  • Conversions d’unités basiques (cents en dollars, millisecondes en secondes)
  • Conversions de fuseaux horaires
  • Filtrage des lignes indésirables (données de test, suppressions définitives via WHERE NOT _fivetran_deleted)
  • Gestion des particularités sources (unnesting des champs repeated, déduplication sur clé primaire)

Ce qui N’appartient PAS aux modèles base :

  • Les jointures. Jamais. Si vous avez besoin de données d’une autre source, c’est un modèle intermediate.
  • Les agrégations. Jamais. Les modèles base doivent maintenir le même grain que la source.
  • La logique métier. Si vous écrivez des CASE statements avec des règles métier, vous êtes dans la mauvaise couche.

La couche base doit être mécanique. Quelqu’un qui ne connaît pas votre domaine métier devrait pouvoir comprendre chaque transformation simplement en lisant le SQL.

Convention de nommage : base__[source]__[entité]

Le double underscore entre chaque composant est intentionnel. Il crée une séparation visuelle non ambiguë. base__google_analytics__event est immédiatement analysable ; base_google_analytics_event pourrait être base_google + analytics_event ou base + google_analytics_event.

Structure des dossiers :

models/
└── base/
├── stripe/
│ ├── _stripe__sources.yml
│ ├── _stripe__models.yml
│ ├── base__stripe__payment.sql
│ └── base__stripe__customer.sql
└── ga4/
├── _ga4__sources.yml
├── _ga4__models.yml
└── base__ga4__event.sql

Organisez les modèles base par système source, pas par domaine métier ni par outil de chargement. Utilisez stripe/, pas fivetran/ ni payment/. Le système source est le principe organisateur naturel à cette couche car c’est de là que viennent réellement les données.

Exemple marketing analytics : Un modèle base__ga4__event prend l’export brut GA4, unneste le champ repeated event_params en colonnes propres, cast event_timestamp des microsecondes vers un vrai timestamp, filtre les événements debug_mode = 1, et renomme user_pseudo_id en ga_client_id. Pas de sessionisation, pas d’attribution, pas de logique métier.

La couche intermediate

La couche intermediate est l’endroit où les modèles base se combinent pour former des entités cohérentes. C’est là que se fait le travail intellectuel de votre projet.

Mon parti pris : les modèles intermediate doivent être centrés sur les entités, pas sur les transformations.

Les recommandations conventionnelles de dbt suggèrent de nommer les modèles intermediate d’après la transformation qu’ils effectuent : int_payments_pivoted_to_order, int_events_sessionized, int_customers_aggregated_by_account. Je pense que c’est une erreur.

Au lieu de cela, les modèles intermediate doivent représenter des entités propres et bien définies à un grain spécifique. Il en existe deux types :

  1. Modèles entité pure (int__session, int__customer) quand vous devez appliquer une logique métier à un seul modèle base (déduplication, sessionisation, calculs complexes)
  2. Modèles entité enrichie (int__customer__customer_lj_order) quand vous joignez des entités ensemble

La convention de nommage des modèles enrichis est auto-documentée : int__[entité_principale]__[entité1]_[type_join]_[entité2]. Les abréviations de type de jointure sont :

  • lj = LEFT JOIN
  • ij = INNER JOIN
  • cj = CROSS JOIN

Pour les jointures multiples, chaînez-les : int__customer__customer_lj_order_lj_session.

Pourquoi est-ce important ?

  1. Auto-documenté. Vous pouvez lire le nom du modèle et savoir exactement ce qui est joint et comment. Pas besoin d’ouvrir le SQL pour comprendre le grain et la composition.

  2. Réutilisabilité. Les modèles basés sur les entités comme int__session peuvent être référencés par plusieurs marts. Les versions enrichies rendent les dépendances explicites.

  3. Debuggabilité. Quand quelque chose casse, le nom vous indique où chercher. int__customer__customer_lj_order en échec ? Vérifiez la logique de jointure customer-to-order.

Ce qui appartient aux modèles intermediate :

  • Joindre des modèles base pour créer des entités enrichies
  • Logique de sessionisation (regroupement d’événements en sessions)
  • Déduplication entre sources (fusion de fiches client du CRM et de la facturation)
  • Résolution d’identité
  • Logique métier complexe qui ne doit pas encombrer les marts

Important : Ne créez pas un modèle int__customer s’il est identique à base__crm__customer. Ne créez des modèles intermediate que lorsque vous ajoutez de la valeur via des jointures, de la logique métier ou des transformations. Dupliquer les modèles base gaspille du compute et encombre votre DAG.

Contrairement aux modèles base (organisés par source) ou aux marts (organisés par domaine métier), intermediate est organisé par entité. Si vous avez suffisamment de modèles, vous pouvez créer des sous-dossiers par entité (intermediate/customer/, intermediate/session/) mais jamais par domaine métier comme intermediate/marketing/ ou intermediate/finance/. Les modèles intermediate couvrent souvent plusieurs domaines : int__customer__customer_lj_order sert à la fois la finance et le marketing. Le forcer dans le dossier d’un domaine crée des frontières artificielles.

Structure des dossiers :

models/
└── intermediate/
├── _int__models.yml
├── session/
│ ├── int__session.sql
│ └── int__session__session_lj_conversion.sql
├── customer/
│ ├── int__customer__customer_lj_order.sql
│ └── int__customer__customer_lj_order_lj_session.sql
├── int__unified_ad_spend.sql
└── int__attribution_touchpoint.sql

Pour les projets plus petits, gardez une structure plate. Ajoutez des sous-dossiers par entité quand vous avez 3+ modèles pour la même entité.

Exemple marketing analytics : Un modèle int__session prend base__ga4__event, applique votre logique de timeout de session de 30 minutes, calcule la durée de session, et attribue chaque session à une source de trafic. Ensuite int__session__session_lj_conversion enrichit les sessions avec les données de conversion (le nom vous dit exactement ce que vous obtenez).

La couche marts

La couche marts produit des tables prêtes à être consommées par les utilisateurs finaux : analystes, dashboards, reverse ETL, modèles ML.

Autre parti pris : je n’utilise pas les préfixes fct_ et dim_.

La modélisation dimensionnelle (faits et dimensions, star schemas, méthodologie Kimball) a été conçue pour les cubes OLAP et les contraintes des data warehouses des années 1990. Ces contraintes n’existent plus. Les outils BI modernes gèrent parfaitement les tables larges et dénormalisées. Vos analystes se moquent de savoir si customer est techniquement une dimension ; ils veulent juste requêter les données clients.

Les préfixes ajoutent de la cérémonie sans ajouter de clarté. fct_order ne vous dit rien que order ne dise déjà. Et la charge mentale de décider « est-ce un fait ou une dimension ? » est une distraction du vrai travail de modélisation de vos données.

Quand fct_/dim_ peut encore avoir du sens : Si vous êtes dans un environnement entreprise avec des exigences strictes de gouvernance des données, des conventions Kimball existantes, ou une équipe qui raisonne en termes de modélisation dimensionnelle, gardez les préfixes. La cohérence avec les normes de votre organisation l’emporte sur mes préférences.

Ce qui appartient aux marts :

  • Tables larges et dénormalisées prêtes pour les dashboards
  • Métriques pré-agrégées
  • Logique métier spécifique au domaine
  • Nommage métier final (noms de colonnes compréhensibles par les utilisateurs)

Convention de nommage : mrt__[département]__[entité]. Par exemple : mrt__marketing__campaign_performance, mrt__finance__order, mrt__marketing__session.

Structure des dossiers (par domaine métier) :

models/
└── marts/
├── finance/
│ ├── _finance__models.yml
│ ├── mrt__finance__order.sql
│ └── mrt__finance__revenue.sql
└── marketing/
├── _marketing__models.yml
├── mrt__marketing__session.sql
├── mrt__marketing__campaign_performance.sql
└── mrt__marketing__channel_attribution.sql

Au niveau des marts, organisez par domaine métier (les équipes et cas d’usage qui consommeront les données). Le marketing se moque que les données de session viennent de GA4 ; ce qui compte c’est que ce sont des données marketing.

Exemple marketing analytics : Un mart mrt__marketing__campaign_performance joint int__unified_ad_spend avec les données de conversion de int__conversion, calcule le ROAS, le CPA et d’autres métriques marketing, et produit une table avec une ligne par campagne par jour. Elle est prête à être branchée directement sur un dashboard ou exportée vers un tableur.

Quand avez-vous besoin des trois couches ?

Tous les projets n’ont pas besoin des trois couches.

Projets simples (< 20 modèles) : Base + marts peut suffire. Si vos marts sont des agrégations simples de modèles base uniques, une couche intermediate ajoute de la complexité inutile.

Projets complexes : Dès que vous joignez 3+ modèles base dans un seul mart, extrayez la logique partagée en intermediate. Dès que plusieurs marts ont besoin des mêmes données jointes/transformées, intermediate devient essentiel pour la maintenabilité.

Règle empirique : Si vous copiez-collez de la logique de JOIN entre marts, vous avez besoin d’un modèle intermediate.


Conventions de nommage : le standard du double underscore

Les conventions de nommage font partie de ces sujets où la cohérence compte bien plus que la convention spécifique choisie. Cela dit, voici ce que je recommande :

CouchePatternExemple
Basebase__[source]__[entité]base__stripe__payment
Intermediate (pure)int__[entité]int__session
Intermediate (enrichie)int__[entité]__[entité1]_[join]_[entité2]int__customer__customer_lj_order
Martsmrt__[département]__[entité]mrt__marketing__campaign_performance

Pourquoi le double underscore ?

Le double underscore (__) crée une séparation visuelle non ambiguë entre les composants. Considérez ces alternatives :

  • base__google_analytics__campaign — clairement : couche base, source google_analytics, entité campaign
  • base_google_analytics_campaign — est-ce google + analytics_campaign ? ou google_analytics + campaign ?

Avec des noms de source ou d’entité multi-mots, l’ambiguïté se multiplie. Le double underscore l’élimine.

Cette convention est aussi cohérente avec la syntaxe source de dbt ({{ source('stripe', 'payment') }}) et les pratiques de la communauté dbt au sens large.

Le pattern de nommage des modèles enrichis

Pour les modèles intermediate qui joignent plusieurs entités, la convention de nommage est :

int__[entité_principale]__[entité1]_[type_join]_[entité2]_[type_join]_[entité3]

Abréviations des types de jointure :

  • lj = LEFT JOIN
  • ij = INNER JOIN
  • cj = CROSS JOIN

Exemples :

  • int__customer__customer_lj_order (customer left join avec order)
  • int__session__session_lj_conversion (session left join avec conversion)
  • int__customer__customer_lj_order_lj_session (chaîne customer → order → session)

Cette convention est verbeuse, mais elle est auto-documentée. Vous connaissez le grain (entité principale), quelles données sont incluses (entités jointes), et comment c’est joint (types de jointure). Pas besoin d’ouvrir le fichier SQL pour comprendre ce que vous obtenez.

Noms d’entités au singulier

Utilisez des noms au singulier : customer, order, session, campaign.

La logique : les entités représentent un concept unique. Une table customer contient des enregistrements de clients. Chaque ligne est un client. L’entité est « customer », pas « customers ». Cela garde aussi le nommage cohérent à travers toutes les couches : base__stripe__customer, int__customer, et mrt__finance__customer font tous référence à la même entité.

Nommage des sources : utilisez le système, pas le loader

Nommez vos sources d’après le système source, pas l’outil de chargement :

  • stripe, ga4, hubspot, shopify
  • fivetran_stripe, stitch_hubspot, airbyte_shopify

Votre loader peut changer. Votre système source est (souvent plus) stable.


Stratégie de matérialisation : des tables partout

La sagesse conventionnelle pour la matérialisation dbt ressemble à peu près à ça :

CoucheConventionnelJustification
Staging/BaseviewDonnées fraîches, économie de stockage
IntermediateephemeralJuste des CTEs, pas de vraies tables
MartstablePerformance pour les utilisateurs finaux

Je pense que c’est une erreur. Mon approche à la place :

CoucheMon approcheJustification
BasetableDebuggable, stable
IntermediatetableDebuggable, requêtable
MartstablePerformance

Tout est table. Les seules variations sont :

  • Incremental quand le volume est élevé (millions de lignes, ajouts quotidiens)
  • View quand les données doivent être fraîches à la minute près (rare)

L’argument pour les tables partout

Debugging. Quand quelque chose casse (et quelque chose va casser), vous devez pouvoir requêter les résultats intermédiaires. Avec des modèles ephemeral, ces résultats n’existent pas dans votre warehouse. Vous ne pouvez pas faire SELECT * FROM int__session LIMIT 100 pour vérifier votre logique de sessionisation. Vous volez à l’aveugle.

Stabilité. Les views sont réévaluées à chaque requête. Si votre source upstream change de schéma, chaque view downstream casse immédiatement. Les tables agissent comme un buffer : vous avez le temps de remarquer et corriger les problèmes avant qu’ils ne se propagent.

Coût. Le stockage est bon marché. Absurdement bon marché. BigQuery facture 0,02 $ par Go par mois. Une table avec 10 millions de lignes pourrait vous coûter 0,50 $/mois à stocker. Pendant ce temps, recalculer les views à chaque requête coûte du compute, et le compute n’est pas bon marché.

Simplicité. Une seule stratégie de matérialisation est plus facile à comprendre, enseigner et maintenir que trois.

Quand utiliser incremental

Passez à la matérialisation incremental quand :

  • Les tables dépassent des millions de lignes
  • Les ajouts quotidiens sont le pattern dominant
  • Le full refresh prend plus de 5 minutes

Pour le marketing analytics, cela signifie typiquement :

  • Tables d’événements GA4 (incremental avec lookback de 3 jours pour les événements en retard)
  • Extraits quotidiens des plateformes publicitaires (incremental par date, avec un lookback à 30 jours)
  • Logs de transactions

Important : Les modèles incremental sont plus complexes. Ils nécessitent une réflexion approfondie sur votre stratégie incrémentale, les données arrivant en retard, et les cas limites. N’utilisez pas incremental avant que la matérialisation en table ne devienne un goulot d’étranglement.

La seule exception : les views pour la fraîcheur temps réel

Parfois les données ont vraiment besoin d’être fraîches à la minute près, pas à l’heure. Dashboards de campagne en direct pendant un lancement produit. Niveaux de stock en temps réel. Détection de fraude.

Dans ces cas, les views ont du sens parce que vous acceptez le coût de compute au moment de la requête en échange de la fraîcheur.

Mais c’est rare. La plupart des cas d’usage analytics tolèrent très bien une latence horaire ou quotidienne. N’optimisez pas pour le temps réel à moins d’en avoir vraiment besoin.

Configuration dans dbt_project.yml

models:
my_project:
+materialized: table # Par défaut tout en table
base:
+schema: base
ga4:
+materialized: incremental # Haut volume
+incremental_strategy: insert_overwrite
intermediate:
+schema: intermediate
marts:
+schema: marts

Définissez le défaut à table, puis surchargez uniquement là où c’est nécessaire.


Organisation YAML : un fichier par répertoire

Trois approches existent pour organiser vos fichiers .yml :

  1. Par répertoire : _stripe__sources.yml, _stripe__models.yml dans chaque dossier
  2. Par modèle : Un fichier YAML par fichier SQL
  3. Monolithique : Un énorme schema.yml à la racine du projet

L’option 3 est un cauchemar. Ne le faites pas. Un schema.yml de 2000 lignes est impossible à parcourir, impossible à maintenir, et génère des conflits de merge en permanence.

L’option 2 (par modèle) gagne en popularité dans les grands projets. Elle scale bien car chaque modèle est autonome. L’inconvénient : plus de fichiers, et vous ne pouvez pas utiliser les anchors YAML pour réduire la répétition.

Je recommande l’option 1 : par répertoire. C’est la recommandation officielle de dbt Labs, elle équilibre découvrabilité et maintenabilité, et fonctionne bien pour les projets jusqu’à plusieurs centaines de modèles.

Le pattern par répertoire

base/stripe/
├── _stripe__sources.yml # Définitions des sources
├── _stripe__models.yml # Configs des modèles, tests, docs
├── base__stripe__payment.sql
└── base__stripe__customer.sql

Pourquoi le préfixe underscore ? Les fichiers commençant par _ se trient en haut du répertoire. Vos fichiers YAML apparaissent avant vos fichiers SQL.

Pourquoi inclure le nom du répertoire ? Recherche fuzzy plus rapide dans les éditeurs. Chercher stripe__models est plus rapide que chercher _models et scanner les résultats.

Quoi mettre où

_sources.yml :

  • Définitions de sources (sources:)
  • Tests de freshness des sources
  • Documentation au niveau des sources

_models.yml :

  • Configurations au niveau des modèles
  • Tests de colonnes (unique, not_null, relationships, accepted_values)
  • Descriptions des colonnes
  • Contracts (si vous les utilisez)

Gardez-les séparés. Mélanger les définitions de sources et de modèles dans un fichier crée de la confusion.


Au-delà de models/ : la structure complète du projet

Un projet dbt c’est plus que des modèles. La structure complète :

my_project/
├── analyses/
│ └── migration_validation.sql
├── dbt_project.yml
├── macros/
│ ├── _macros.yml
│ ├── generate_schema_name.sql
│ └── marketing/
│ ├── channel_grouping.sql
│ └── attribution_weight.sql
├── models/
│ ├── base/
│ ├── intermediate/
│ └── marts/
├── packages.yml
├── seeds/
│ ├── _seeds.yml
│ ├── channel_mapping.csv
│ └── utm_campaign_mapping.csv
├── snapshots/
│ ├── _snapshots.yml
│ └── snap__customer.sql
└── tests/
└── assert_attribution_sums_to_one.sql

macros/

Les macros sont des snippets SQL réutilisables (des fonctions que vous pouvez appeler depuis n’importe quel modèle).

Organisation : Groupez par domaine ou fonction.

macros/
├── _macros.yml # Documentez toutes les macros
├── generate_schema_name.sql
├── grant_permissions.sql
└── marketing/
├── channel_grouping.sql
└── session_timeout.sql

Critique : Documentez toujours vos macros dans _macros.yml. Incluez l’objectif, les arguments et un exemple. Le vous du futur remerciera le vous du présent.

macros/_macros.yml
macros:
- name: channel_grouping
description: |
Mappe les combinaisons UTM source/medium vers des groupes de canaux standardisés.
Retourne : 'Paid Search', 'Organic Search', 'Paid Social', etc.
arguments:
- name: source
type: string
description: La source de trafic (utm_source ou source ga)
- name: medium
type: string
description: Le medium de trafic (utm_medium ou medium ga)

seeds/

Les seeds sont des fichiers CSV que dbt charge dans votre warehouse. Utilisez-les pour des tables de référence statiques qui n’existent dans aucun système source.

Bons cas d’usage :

  • Mappings UTM → campagne
  • Règles de regroupement de canaux
  • Codes pays/région
  • Adresses IP internes à exclure des analytics
  • Mappings email employé → ID client (pour filtrer l’usage interne)

Mauvais cas d’usage :

  • Charger des données réelles. Si elles existent dans un système auquel vous avez accès, utilisez un outil EL approprié.
  • Gros datasets. Les seeds sont chargés entièrement à chaque exécution de dbt seed.

Exemples marketing :

seeds/channel_mapping.csv
source,medium,channel_group
google,cpc,Paid Search
google,organic,Organic Search
facebook,paid,Paid Social
facebook,referral,Organic Social

snapshots/

Les snapshots créent des enregistrements de dimensions à évolution lente de Type 2, préservant les états historiques quand les données sources changent.

Cas d’usage :

  • Attributs client qui changent dans le temps (tier, statut, propriétaire)
  • Historique des paramètres de campagne
  • Historique des prix produits
  • Toute dimension où vous devez répondre à « quelle était la valeur au moment X ? »

Nommage : snap__[entité]snap__customer.sql

Depuis dbt 1.9+, vous pouvez définir les snapshots dans des fichiers YAML au lieu de SQL, ce qui simplifie considérablement la syntaxe.

tests/

dbt a deux types de tests :

Les tests génériques (définis en YAML) sont votre mécanisme de test principal :

_models.yml
models:
- name: int__session
columns:
- name: session_id
tests:
- unique
- not_null
- name: ga_client_id
tests:
- not_null
- relationships:
to: ref('base__ga4__event')
field: ga_client_id

Les tests singuliers (fichiers SQL dans tests/) sont pour les validations complexes couvrant plusieurs modèles :

-- tests/assert_attribution_sums_to_one.sql
-- Les poids d'attribution de chaque conversion doivent sommer à 1.0
SELECT conversion_id
FROM {{ ref('mrt__marketing__channel_attribution') }}
GROUP BY conversion_id
HAVING ABS(SUM(attribution_weight) - 1.0) > 0.001

Standard minimum : Chaque modèle devrait avoir sa clé primaire testée pour unique et not_null. Cela détecte un nombre surprenant de problèmes.

analyses/

Le dossier analyses/ stocke les requêtes SQL qui bénéficient du templating Jinja et du versioning mais ne créent pas d’objets warehouse.

Cas d’usage :

  • Requêtes de validation de migration
  • Requêtes d’audit ponctuelles
  • Analyses ad-hoc que vous voulez préserver
  • Requêtes que vous exécutez manuellement mais voulez tracker dans git

Fonctionnalités dbt modernes (1.7+) : que faut-il adopter

dbt a considérablement évolué dans les versions récentes. Ce qui vaut la peine d’être adopté en 2026 :

Contracts : oui, pour les marts

Les contracts de modèle définissent des garanties explicites sur le schéma d’un modèle (noms de colonnes, types de données et contraintes). Quand ils sont appliqués, dbt les valide au moment du build.

models:
- name: mrt__marketing__campaign_performance
config:
contract:
enforced: true
columns:
- name: campaign_id
data_type: string
constraints:
- type: not_null
- name: date
data_type: date
constraints:
- type: not_null
- name: spend
data_type: float64

Quand utiliser : Les marts exposés publiquement et consommés par des dashboards, systèmes downstream, ou autres équipes. Les contracts agissent comme un schéma d’API qui communique ce sur quoi les consommateurs downstream peuvent compter.

Quand s’abstenir : Les couches base et intermediate. La cérémonie n’en vaut pas la peine pour des modèles internes que vous seul consommez.

Unit testing : oui, pour la logique complexe

Le unit testing natif (dbt 1.8+) vous permet de tester la logique SQL contre des inputs statiques avant matérialisation.

unit_tests:
- name: test_channel_grouping_paid_search
model: int__session
given:
- input: ref('base__ga4__event')
rows:
- {session_id: 'abc', source: 'google', medium: 'cpc'}
expect:
rows:
- {session_id: 'abc', channel_group: 'Paid Search'}

Quand utiliser :

  • CASE statements complexes
  • Calculs date/heure
  • Logique d’attribution
  • Règles de regroupement de canaux
  • Toute logique métier où les cas limites comptent

Quand s’abstenir : Transformations simples où la logique est évidente.

Groups et access modifiers : oui, même pour des projets uniques

Les groups organisent les modèles sous une propriété commune et permettent le contrôle d’accès.

dbt_project.yml
groups:
- name: marketing
owner:
email: marketing-data@company.com
- name: finance
owner:
email: finance-data@company.com
models:
my_project:
marts:
marketing:
+group: marketing
+access: public
finance:
+group: finance
+access: public

Pourquoi s’embêter dans un projet unique ?

  1. Documentation. Les groups enregistrent explicitement qui possède quoi.
  2. Préparation du futur. Si vous migrez un jour vers dbt Mesh, la structure est déjà en place.
  3. Frontières. Les access modifiers (private, protected, public) garantissent que les équipes ne créent pas de dépendances non intentionnelles.

dbt Mesh : savoir quand vous en avez besoin

dbt Mesh permet de découper un projet monolithique en plusieurs projets interconnectés. C’est puissant, mais c’est aussi complexe.

Signes que vous pourriez en avoir besoin :

  • 500+ modèles avec une CI lente
  • Plusieurs équipes qui se marchent dessus
  • Exigences de sécurité pour l’isolation des données
  • Cadences de déploiement différentes pour différents domaines

Pour la plupart des projets : Un projet unique avec des groups suffit. N’adoptez pas la complexité de Mesh avant d’avoir des problèmes de Mesh.

dbt-project-evaluator : oui, pour l’application des règles

Le package dbt-project-evaluator audite automatiquement votre projet par rapport aux bonnes pratiques. Il vérifie :

  • Tests de clé primaire manquants
  • Modèles sans descriptions
  • Références directes aux sources dans les marts (devrait passer par base)
  • Violations des conventions de nommage
  • Et des dizaines d’autres règles

Ajoutez-le à votre packages.yml :

packages:
- package: dbt-labs/dbt_project_evaluator
version: 0.8.1

Exécutez dbt build --select package:dbt_project_evaluator pour voir les violations.

Astuce pro : Personnalisez les règles pour correspondre à vos conventions. Le package attend des préfixes stg_ par défaut ; vous pouvez le configurer pour les patterns base__.


Marketing Analytics : patterns pratiques

Il est temps d’ancrer tout cela dans des exemples concrets de marketing analytics.

Structure de modélisation GA4

models/
├── base/ga4/
│ ├── _ga4__sources.yml
│ ├── _ga4__models.yml
│ └── base__ga4__event.sql # Unnest, cast, filtrer debug
├── intermediate/
│ ├── int__session.sql # Logique de sessionisation
│ ├── int__session__session_lj_conversion.sql # Session enrichie
│ └── int__conversion.sql # Événements de conversion
└── marts/marketing/
├── mrt__marketing__session.sql # Métriques au niveau session
├── mrt__marketing__user_journey.sql # Parcours utilisateur complets
└── mrt__marketing__conversion_funnel.sql # Analyse de funnel

Considérations clés :

  • Les exports GA4 sont des tables shardées (events_YYYYMMDD). Utilisez {{ source('ga4', 'event') }} avec une configuration de table wildcard.
  • Les paramètres d’événements sont imbriqués. Unnestez-les dans la couche base.
  • La sessionisation nécessite une règle de timeout (typiquement 30 minutes). Implémentez-la en intermediate.
  • Utilisez des modèles incremental avec un lookback de 3 jours car les événements GA4 peuvent arriver en retard.

Consolidation multi-plateformes des dépenses publicitaires

Le défi : Google Ads, Meta, LinkedIn, TikTok ont tous des schémas différents et des nommages différents, mais le même besoin métier.

Pattern : Normaliser en base, unifier en intermediate.

models/
├── base/
│ ├── google_ads/
│ │ └── base__google_ads__campaign_performance.sql
│ ├── meta_ads/
│ │ └── base__meta_ads__campaign_insights.sql
│ └── linkedin_ads/
│ └── base__linkedin_ads__campaign_analytics.sql
└── intermediate/
└── int__ad__union.sql

Le modèle int__ad__union fait un UNION de toutes les plateformes dans un schéma standardisé :

-- int__ad__union.sql
SELECT
date,
'google_ads' AS platform,
account_id,
campaign_id,
campaign_name,
impressions,
clicks,
spend,
conversions
FROM {{ ref('base__google_ads__campaign_performance') }}
UNION ALL
SELECT
date,
'meta_ads' AS platform,
account_id,
campaign_id,
campaign_name,
impressions,
clicks,
spend,
conversions
FROM {{ ref('base__meta_ads__campaign_insights') }}
-- ... plateformes additionnelles

Maintenant chaque mart qui a besoin de données de dépenses publicitaires référence cette source unifiée unique.

Modélisation d’attribution

Commencez simple : l’attribution first-touch et last-touch sont le minimum. Ajoutez le multi-touch quand (et si) vous en avez besoin.

Structure :

models/
├── intermediate/
│ ├── int__attribution_touchpoint.sql # Tous les touchpoints avec timestamps
│ ├── int__attribution_path.sql # Chemins ordonnés par conversion
│ └── int__conversion__conversion_lj_touchpoint.sql # Conversion enrichie avec chemins
└── marts/marketing/
├── mrt__marketing__first_touch_attribution.sql
├── mrt__marketing__last_touch_attribution.sql
└── mrt__marketing__multi_touch_attribution.sql

Le modèle int__attribution_touchpoint rassemble tous les touchpoints (sessions, clics publicitaires, ouvertures d’email) avec timestamps et identifiants utilisateur. Le modèle int__attribution_path les ordonne en séquences. Le modèle int__conversion__conversion_lj_touchpoint joint les conversions avec leurs chemins d’attribution.

Les marts appliquent ensuite différentes logiques d’attribution aux mêmes données sous-jacentes, facilitant la comparaison des modèles.

Gérer l’incrementalité dans les données marketing

Différents types de données nécessitent différentes stratégies incrémentales :

DonnéesStratégieLookbackPourquoi
Événements GA4insert_overwrite par date3 joursÉvénements en retard
Métriques plateformes pubinsert_overwrite par date30 joursLes fenêtres d’attribution se mettent à jour rétroactivement
ConversionsFull refresh ou incremental prudentN/AL’attribution peut changer pendant des semaines

La fenêtre de lookback compte. Les plateformes publicitaires recalculent l’attribution à mesure que plus de données arrivent : une conversion attribuée à la Campagne A au jour 1 pourrait passer à la Campagne B au jour 30. Intégrez cela dans votre logique incrémentale.


Erreurs courantes et comment les éviter

Erreur : Sauter la couche intermediate

Symptôme : Marts avec 10+ JOINs, logique répétée à travers les modèles, impossible à débugger.

Solution : Extrayez la logique partagée dans des modèles intermediate basés sur les entités. Si plusieurs marts ont besoin de données de session, créez int__session une seule fois.

Erreur : Organiser base par loader, pas par source

Faux : fivetran/, stitch/, airbyte/

Juste : stripe/, ga4/, hubspot/

Votre loader est un détail d’implémentation. Votre source est la vraie donnée.

Erreur : Logique métier dans la couche base

Symptôme : CASE statements complexes, mappings de canaux hardcodés, règles métier dans les modèles staging.

Solution : Base devrait être uniquement des transformations mécaniques. La logique métier appartient à intermediate ou marts.

Erreur : Fichiers YAML monolithiques

Symptôme : Un seul schema.yml de 2000 lignes à la racine du projet.

Solution : Un fichier YAML par répertoire. _stripe__models.yml dans base/stripe/, _marketing__models.yml dans marts/marketing/.

Erreur : Sur-imbrication des dossiers

Faux : models/staging/external/stripe/payments/v2/current/

Juste : Gardez la profondeur des dossiers ≤ 3 niveaux. Plat vaut mieux qu’imbriqué.

Erreur : Ephemeral partout

Symptôme : Impossible de débugger, impossible de requêter les résultats intermédiaires, pas de visibilité sur la logique de transformation.

Solution : Les tables sont bon marché. La visibilité est inestimable. Matérialisez en tables par défaut.

Erreur : Sauter les tests

Symptôme : Clés primaires en doublon, valeurs null dans des colonnes NOT NULL, relations cassées, tout découvert en production.

Solution : Au minimum, testez chaque clé primaire pour unique et not_null. Ajoutez des tests de relations pour les clés étrangères.


Tout assembler : un exemple complet

Une structure de projet marketing analytics complète appliquant tout ce qui a été couvert :

marketing_analytics/
├── dbt_project.yml
├── packages.yml
├── macros/
│ ├── _macros.yml
│ └── marketing/
│ ├── channel_grouping.sql
│ └── session_timeout_minutes.sql
├── models/
│ ├── base/
│ │ ├── ga4/
│ │ │ ├── _ga4__sources.yml
│ │ │ ├── _ga4__models.yml
│ │ │ └── base__ga4__event.sql
│ │ ├── google_ads/
│ │ │ ├── _google_ads__sources.yml
│ │ │ ├── _google_ads__models.yml
│ │ │ └── base__google_ads__campaign_performance.sql
│ │ └── meta_ads/
│ │ ├── _meta_ads__sources.yml
│ │ ├── _meta_ads__models.yml
│ │ └── base__meta_ads__campaign_insight.sql
│ ├── intermediate/
│ │ ├── _int__models.yml
│ │ ├── session/
│ │ │ ├── int__session.sql
│ │ │ └── int__session__session_lj_conversion.sql
│ │ ├── int__conversion.sql
│ │ ├── int__ad__union.sql
│ │ └── int__attribution_touchpoint.sql
│ └── marts/
│ └── marketing/
│ ├── _marketing__models.yml
│ ├── mrt__marketing__session.sql
│ ├── mrt__marketing__campaign_performance.sql
│ ├── mrt__marketing__channel_attribution.sql
│ └── mrt__marketing__daily_ad_spend.sql
├── seeds/
│ ├── _seeds.yml
│ └── channel_mapping.csv
├── snapshots/
│ └── snap__campaign_setting.sql
└── tests/
└── assert_attribution_weights_valid.sql

Et la configuration clé dans dbt_project.yml :

name: marketing_analytics
version: '1.0.0'
vars:
session_timeout_minutes: 30
attribution_lookback_days: 30
models:
marketing_analytics:
+materialized: table
base:
+schema: base
ga4:
+materialized: incremental
+incremental_strategy: insert_overwrite
intermediate:
+schema: intermediate
marts:
+schema: marts
marketing:
+group: marketing
+access: public
groups:
- name: marketing
owner:
email: marketing-data@company.com

Conclusion

La meilleure structure de projet dbt est celle où n’importe qui peut trouver n’importe quel modèle en moins de 10 secondes. Tout le reste (conventions de nommage, définitions des couches, stratégies de matérialisation) sert cet objectif.

Points clés à retenir :

  1. base → intermediate → marts. Trois couches. L’ordre alphabétique correspond à l’ordre du lineage. Chaque couche a des règles claires sur ce qui y appartient.

  2. Nommage basé sur les entités avec jointures auto-documentées. int__session pour les entités pures, int__customer__customer_lj_order pour les enrichies. Le nom vous dit ce qu’il y a dedans.

  3. Pas de préfixes fct_/dim_. Nous avons dépassé la modélisation dimensionnelle. Des noms de mart comme mrt__marketing__session sont plus clairs.

  4. Tables partout. Le stockage est bon marché. La visibilité pour le debugging est coûteuse. Matérialisez en tables par défaut, incremental quand le volume l’exige.

  5. La cohérence est reine. Choisissez des conventions, documentez-les, appliquez-les avec dbt-project-evaluator. Les conventions spécifiques importent moins que de les appliquer de manière cohérente.

Si vous démarrez un nouveau projet, adoptez ces patterns dès le premier jour. Si vous héritez d’un projet existant, choisissez vos batailles et n’essayez pas de tout refactorer d’un coup. Commencez par les conventions de nommage dans les nouveaux modèles, puis migrez progressivement le reste.

Quelles que soient les conventions que vous choisissez, écrivez-les. Un CONTRIBUTING.md à la racine de votre projet dbt qui documente vos décisions de structure vaut plus que n’importe quel guide de bonnes pratiques, y compris celui-ci.