RSS →
ARTICLES · DBT

Structurer un projet DBT multi-clients sur Snowflake sans devenir fou

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.

Architecture DBT multi-clients sur Snowflake

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.

M

Mikael Paulhiout

Lead Tech / Data Architect. Écrit sur Snowflake, DBT, Airflow et l'IA appliquée.

RSS LinkedIn