J'ai hérité d'un projet DBT qui gérait 4 business units en parallèle. 380 modèles, 120 fichiers avec des conditions Jinja {% if %}, et un dbt run qui durait 47 minutes parce qu'il reconstruisait tout pour tous les clients à chaque fois. Voici comment je l'ai restructuré en 3 semaines — et pourquoi le multi-client n'est pas un problème DBT, c'est un problème d'architecture.

Le constat : pourquoi le multi-client devient un cauchemar
Au début, tout le monde fait la même chose. Un fichier when dans chaque modèle :
-- ⛔ Ce que tout le monde fait au début
SELECT
{% if var('client') == 'banque' %}
montant,
{% else %}
{{ var('client') }}_montant,
{% endif %}
FROM {{ source('raw', 'operations') }}
Ça marche pour 3 modèles. À 380, c'est l'enfer. Un changement de schéma source = toucher 50 fichiers. Un nouveau client = dupliquer toute la couche staging. Et le pire : dbt run exécute tous les modèles pour tous les clients, même ceux qui n'ont pas changé.
Le problème n'est pas DBT. C'est l'absence de séparation des responsabilités.
Pattern par schéma vs pattern par base : choisir sa stratégie
Avant d'écrire la moindre macro, il faut décider comment isoler les clients côté Snowflake.
| Critère | 1 Base, N schémas | N Bases |
|---|---|---|
| Volume par client | < 1 To | > 1 To |
| Isolation des droits | Moyenne | Forte |
| Coût Snowflake | Partagé (1 warehouse) | Dédié (N warehouses) |
| Complexité DBT | Faible | Élevée (custom targets) |
| Cas typique | Business units d'un même groupe | Clients SaaS / entités juridiques distinctes |
La règle que j'applique : commencer par schéma, basculer en base seulement si la sécurité l'exige. La plupart des projets multi-business-units n'ont pas besoin de bases séparées. Un schéma Snowflake isole déjà les données, et le RBAC fait le reste.
Pattern par schéma : generate_schema_name
La macro generate_schema_name est le point d'entrée. C'est elle que DBT appelle pour chaque modèle. (Documentation officielle)
{# macros/generate_schema_name.sql #}
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- set client = var('client', none) -%}
{%- if client -%}
{%- set client_config = var('clients', {})[client] -%}
{%- set prefix = client_config.schema_prefix -%}
{%- if custom_schema_name is none -%}
{{ prefix }}_{{ default_schema }}
{%- else -%}
{{ prefix }}_{{ custom_schema_name }}
{%- endif -%}
{%- else -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ default_schema }}_{{ custom_schema_name }}
{%- endif -%}
{%- endif -%}
{%- endmacro %}
Résultat : dbt run --vars '{client: banque}' produit les modèles dans bnk_marts, bnk_staging, etc. Un autre client produit dans ass_marts, ass_staging. Tout est isolé, automatiquement.
Variables DBT + Jinja : injecter le contexte client proprement
Le pattern qui sauve : un seul fichier qui centralise tout le contexte client. Pas de logique métier dedans, juste de la configuration.
# vars/clients.yml
clients:
banque:
schema_prefix: bnk
source_schema: raw_banque
currency: EUR
assurance:
schema_prefix: ass
source_schema: raw_assurance
currency: EUR
retail:
schema_prefix: rtl
source_schema: raw_retail
currency: USD
Dans dbt_project.yml, on déclare ce fichier :
vars:
clients: "{{ yaml.safe_load('vars/clients.yml') }}"
Lancer un client spécifique :
dbt run --vars '{client: banque, clients: {"banque": {"schema_prefix": "bnk", "source_schema": "raw_banque", "currency": "EUR"}}}'
En pratique, on scripte ça dans Airflow avec une boucle :
# Airflow DAG
for client in CLIENTS:
dbt_run = DbtRunOperator(
task_id=f"dbt_run_{client}",
vars={"client": client},
)
Le piège à éviter : ne jamais mettre de logique métier dans les variables. Juste du contexte (préfixe de schéma, source, devise). Si tu te retrouves à écrire {% if var('client').use_eur %}, tu recommences le spaghetti.
RBAC Snowflake : un rôle par client, pas par personne
Si tu as lu mon article sur le RBAC 3 couches, tu connais déjà le modèle. Pour le multi-client, on ajoute une dimension : l'isolation par schéma client.
-- Chaque schéma client est isolé via Future Grants
GRANT SELECT ON FUTURE TABLES IN SCHEMA GOLD_DEV.gld_bnk
TO ROLE AR_GLD_BNK_READ_DEV;
GRANT SELECT ON FUTURE TABLES IN SCHEMA GOLD_DEV.gld_ass
TO ROLE AR_GLD_ASS_READ_DEV;
-- Functional Roles par client
CREATE ROLE FR_DATA_ENGINEER_BNK_DEV;
GRANT ROLE AR_GLD_BNK_READ_DEV TO ROLE FR_DATA_ENGINEER_BNK_DEV;
CREATE ROLE FR_DATA_ENGINEER_ASS_DEV;
GRANT ROLE AR_GLD_ASS_READ_DEV TO ROLE FR_DATA_ENGINEER_ASS_DEV;
La règle d'or : un Data Engineer banque ne voit pas les tables assurance, même en dev. Les Future Grants garantissent que tout nouveau modèle DBT créé dans un schéma client est automatiquement accessible au bon rôle, sans intervention manuelle.
Tuer le spaghetti de when : la séparation des responsabilités
C'est la partie qui change tout. Le principe est simple : le modèle DBT commun ne doit jamais savoir quel client il sert.
Étape 1 : des modèles staging spécifiques par client
Chaque client a ses propres colonnes source, ses propres conventions. On normalise ça dans une couche staging spécifique :
-- ✅ stg_banque__orders.sql (spécifique client)
SELECT
id_commande AS order_id,
valeur_euros AS amount,
date_op AS order_date
FROM {{ source('raw_banque', 'operations') }}
-- ✅ stg_assurance__orders.sql (spécifique client)
SELECT
claim_id AS order_id,
payout_amount AS amount,
settlement_date AS order_date
FROM {{ source('raw_assurance', 'claims') }}
Étape 2 : un modèle commun qui ne contient aucun if
-- ✅ int_orders.sql (commun, zéro logique client)
SELECT
order_id,
amount,
order_date
FROM {{ ref('stg_client__orders') }}
Étape 3 : router stg_client__orders vers le bon staging
{# macros/stg_client_ref.sql #}
{% macro stg_client_ref(model_name) %}
{%- set client = var('client') -%}
{%- set client_stg = 'stg_' ~ client ~ '__' ~ model_name -%}
{{ ref(client_stg) }}
{% endmacro %}
Dans le modèle commun :
-- int_orders.sql
SELECT
order_id,
amount,
order_date
FROM {{ stg_client_ref('orders') }}
Le résultat : 380 modèles → 60 spécifiques (staging par client) + 320 communs (réutilisables). Zéro condition {% if %} dans les modèles communs.
Le résultat
| Métrique | Avant | Après |
|---|---|---|
| Modèles | 380 | 380 (60 spécifiques + 320 communs) |
Fichiers avec logique Jinja when |
120 | 0 |
Temps dbt run (1 client) |
47 min | 12 min |
| Ajout d'un nouveau client | ~2 semaines | ~2 jours |
Conditions {% if %} dans les modèles |
~400 | 0 |
Le multi-client n'est pas un cas edge. C'est juste de l'architecture. Si ton modèle DBT sait quel client il sert, tu as déjà perdu.