Vous en êtes au point où copier-coller du SQL entre vos modèles vous met mal à l’aise. La même logique de troncature de date apparaît à cinq endroits. Le même pattern de gestion des nulls revient sans cesse. Votre instinct dit “crée une macro”, et cet instinct mérite d’être questionné.
La plupart des projets dbt ont trop de macros, pas trop peu. La volonté d’éliminer la duplication crée des abstractions qui masquent ce que le SQL fait réellement. Un modèle qui nécessite la lecture de quatre fichiers de macros pour comprendre une seule requête est illisible. Une bonne conception de macros produit du code que votre futur vous (et vos collègues) pourrez maintenir, pas juste du code avec moins de lignes répétées. Si vous cherchez des macros à adopter plutôt qu’à construire, consultez les macros essentielles pour tout projet dbt. Pour les fondamentaux de Jinja dans dbt, voyez le guide des macros dbt.
La règle de trois
La première fois que vous écrivez un pattern, écrivez-le tel quel. En ligne. Pas de macro.
La deuxième fois, notez le pattern. Ajoutez peut-être un commentaire : “Logique similaire dans base__shopify__orders.” Toujours pas de macro.
La troisième fois, vous avez assez d’information pour extraire quelque chose d’utile. Vous avez vu comment le pattern est réellement utilisé. Vous savez quelles parties varient et lesquelles restent constantes. Vous pouvez maintenant construire une abstraction qui correspond à la réalité au lieu de deviner ce dont vous pourriez avoir besoin.
Cette règle vient du génie logiciel, mais la documentation officielle de dbt la soutient : “Favor readability when mixing Jinja with SQL, even if it means repeating some lines.”
En pratique, la progression ressemble à ceci :
-- Première fois : en ligneSELECT order_id, ROUND(amount_cents / 100.0, 2) AS order__amount_dollarsFROM {{ ref('base__shopify__orders') }}-- Deuxième fois : toujours en ligne, mais vous remarquez le patternSELECT payment_id, ROUND(amount_cents / 100.0, 2) AS payment__amount_dollarsFROM {{ ref('base__stripe__payments') }}-- Troisième fois : on extrait la macro{% macro cents_to_dollars(column_name, scale=2) %} ROUND({{ column_name }} / 100.0, {{ scale }}){% endmacro %}La tentation est de brûler les étapes. Vous voyez le pattern dès le premier jour et vous vous dites “je vais forcément le réutiliser.” Parfois c’est vrai. Plus souvent, les deuxième et troisième utilisations ont des différences subtiles qui cassent votre abstraction initiale. Attendre vous permet de construire la bonne abstraction plutôt que celle que vous aviez imaginée.
Le coût de la sur-abstraction
Les macros prématurées créent trois problèmes :
Lisibilité réduite. Chaque appel de macro est un changement de contexte mental. Le lecteur doit ouvrir un autre fichier, comprendre la logique de la macro, puis revenir au modèle. Avec du code en ligne, tout est visible au même endroit.
Complexité accrue. Pour gérer tous les cas que vous aviez imaginés, vous ajoutez des paramètres. Chaque paramètre est un point de décision. Une macro avec sept paramètres est plus difficile à utiliser correctement que du code en ligne.
Code fragile. Quand une macro sert cinq modèles, les modifications deviennent risquées. Vous voulez corriger le comportement pour un modèle sans casser les autres. Alors vous ajoutez un paramètre de plus, ce qui aggrave le problème de complexité.
La règle de trois consiste à rassembler assez d’information pour construire la bonne abstraction, pas simplement à compter les occurrences.
Des macros à responsabilité unique
Une fois que vous avez décidé qu’une macro vaut la peine d’être créée, gardez-la focalisée sur une seule tâche.
Cette macro essaie d’en faire trop :
{% macro process_amount(column, apply_discount=false, discount_rate=0.1, convert_currency=false, target_currency='USD') %} {% set result = column %} {% if apply_discount %} {% set result = result ~ ' * (1 - ' ~ discount_rate ~ ')' %} {% endif %} {% if convert_currency %} {% set result = result ~ ' * get_exchange_rate(\'' ~ target_currency ~ '\')' %} {% endif %} {{ result }}{% endmacro %}Cette macro a cinq paramètres et combine trois opérations distinctes. L’utiliser suppose de comprendre tous les flags, la tester implique de couvrir chaque combinaison, et modifier la logique de remise risque de casser la conversion de devises.
Découpez-la plutôt en macros ciblées :
{% macro cents_to_dollars(column_name, scale=2) %} ROUND({{ column_name }} / 100.0, {{ scale }}){% endmacro %}
{% macro apply_discount(amount_column, discount_rate) %} {{ amount_column }} * (1 - {{ discount_rate }}){% endmacro %}
{% macro convert_currency(amount_column, target_currency) %} {{ amount_column }} * {{ get_exchange_rate(target_currency) }}{% endmacro %}Chaque macro fait une seule chose, et son nom l’explique. La composition dans un modèle est explicite :
SELECT order_id, {{ convert_currency( apply_discount(cents_to_dollars('amount_cents'), 0.1), 'EUR' ) }} AS order__discounted_amount_eurFROM {{ ref('base__shopify__orders') }}Oui, le code du modèle est plus long qu’un seul appel à process_amount. Mais quiconque le lit peut voir exactement ce qui se passe. Quand la finance demande “comment calcule-t-on les montants remisés en EUR ?”, la réponse est directement dans le SQL.
Signal d’alerte : explosion de paramètres. Quand une macro dépasse cinq ou six paramètres, elle en fait probablement trop. Cherchez des moyens de la découper. Si le découpage ne fonctionne pas proprement, l’abstraction n’est peut-être pas la bonne.
Des conventions de nommage qui passent à l’échelle
Le nom d’une macro, c’est sa documentation au point d’appel. Soignez-le.
Utilisez des préfixes verbaux pour les actions :
get_pour les macros qui récupèrent des données :get_column_values,get_latest_partitiongenerate_pour les macros qui créent du SQL :generate_surrogate_key,generate_schema_nameformat_pour les macros qui transforment la sortie :format_timestamp,format_currency
Utilisez des noms descriptifs pour les transformations :
cents_to_dollarsplutôt queconvert_amountextract_domain_from_emailplutôt queparse_emailcalculate_days_sinceplutôt quedate_diff_helper
Le nom doit indiquer ce que fait la macro sans avoir à ouvrir le fichier.
Utilisez un underscore en préfixe pour les helpers internes :
{% macro _build_join_condition(columns) %} {# Helper interne, pas pour un usage direct dans les modèles #}{% endmacro %}
{% macro generate_merge_statement(source, target, columns) %} {# Utilise _build_join_condition en interne #}{% endmacro %}L’underscore signale “ne pas appeler directement.” Votre équipe peut utiliser generate_merge_statement sans comprendre les détails internes.
Une macro par fichier, le nom du fichier correspond au nom de la macro. Quand vous cherchez cents_to_dollars, vous savez qu’elle se trouve dans cents_to_dollars.sql. Pas besoin de fouiller. Pas besoin de défiler un fichier utils.sql de 500 lignes. Cette convention vient du style guide de Brooklyn Data, et c’est l’une des plus utiles que vous puissiez adopter.
Structure de dossiers et organisation
Les macros ont besoin d’un emplacement logique. Cette structure passe bien à l’échelle :
macros/├── _macros.yml # Documentation de toutes les macros├── generate_schema_name.sql # Macros d'override dbt à la racine├── generate_alias_name.sql├── utils/│ ├── cents_to_dollars.sql│ ├── limit_data_in_dev.sql│ └── add_audit_columns.sql├── transformations/│ ├── finance/│ │ ├── calculate_mrr.sql│ │ └── prorate_amount.sql│ └── marketing/│ ├── attribution_weight.sql│ └── channel_grouping.sql└── tests/ └── test_row_count_match.sqlLes macros d’override restent à la racine. Les macros generate_schema_name, generate_alias_name et generate_database_name de dbt sont spéciales. Les garder dans macros/ les rend faciles à trouver et signale leur importance.
Les macros utilitaires ont leur propre dossier. Les helpers génériques comme add_audit_columns ou limit_data_in_dev vont dans utils/. Ce sont les macros que n’importe quel modèle peut utiliser.
Les macros métier sont regroupées par domaine. La finance a sa propre logique. Le marketing a la sienne. Regrouper par domaine facilite la recherche des macros pertinentes et l’attribution de la responsabilité.
Les tests génériques personnalisés vont dans tests/. Quand vous écrivez une macro de test applicable à n’importe quelle colonne (comme test_row_count_match), placez-la avec les autres tests.
La structure exacte compte moins que la cohérence. Choisissez un pattern, documentez-le et tenez-vous-y. Pour l’organisation globale du projet, consultez mon guide de structure de projet dbt.
Documenter les macros
La documentation dans _macros.yml vaut mieux que des commentaires dans les fichiers SQL. La documentation YAML apparaît dans dbt docs, supporte des définitions structurées d’arguments et crée une source unique de vérité.
version: 2
macros: - name: cents_to_dollars description: | Converts an integer cents column to a decimal dollars value.
## Usage ```sql {{ cents_to_dollars('amount_cents') }} {{ cents_to_dollars('amount_cents', scale=4) }} ```
## Notes - Assumes input is already an integer (no validation) - Uses ROUND for consistent decimal places arguments: - name: column_name type: string description: The column containing cents values - name: scale type: integer description: Number of decimal places. Defaults to 2.
- name: limit_data_in_dev description: | Adds a date filter in development environments to limit data volume. Returns empty string in production.
## Usage Add to WHERE clause with AND: ```sql WHERE 1=1 {{ limit_data_in_dev('created_at', 7) }} ``` arguments: - name: column_name type: string description: Date column to filter on. Defaults to 'created_at'. - name: days type: integer description: Number of days to include. Defaults to 3.La description inclut un exemple d’utilisation parce que c’est ce dont les développeurs ont réellement besoin. Ils veulent copier-coller quelque chose qui fonctionne, puis l’adapter. Des descriptions d’arguments abstraites n’aident pas autant qu’un exemple concret.
Tester le comportement des macros
Les macros ont besoin de tests comme tout autre code, et elles s’intègrent naturellement dans votre stratégie de tests. dbt vous offre deux approches.
Les tests d’intégration créent des modèles qui utilisent vos macros et vérifient le résultat :
-- models/tests/test_cents_to_dollars.sql{{ config(materialized='table', schema='dbt_tests') }}
WITH test_data AS ( SELECT 1000 AS amount_cents, 10.00 AS expected UNION ALL SELECT 999 AS amount_cents, 9.99 AS expected UNION ALL SELECT 1 AS amount_cents, 0.01 AS expected)
SELECT amount_cents, {{ cents_to_dollars('amount_cents') }} AS actual, expected, {{ cents_to_dollars('amount_cents') }} = expected AS passedFROM test_dataLancez dbt build --select test_cents_to_dollars et vérifiez que toutes les lignes ont passed = true.
Les unit tests (dbt 1.8+) permettent de tester la logique SQL avant la matérialisation :
unit_tests: - name: test_cents_to_dollars_conversion model: mrt__finance__orders given: - input: ref('base__shopify__orders') rows: - {order_id: 1, amount_cents: 1000} - {order_id: 2, amount_cents: 50} expect: rows: - {order_id: 1, order__amount_dollars: 10.00} - {order_id: 2, order__amount_dollars: 0.50}Ce test vérifie la macro en contexte, dans le cadre d’une vraie transformation de modèle. Pour aller plus loin, consultez le guide complet des unit tests dbt.
Vérifiez le SQL compilé. Lors du débogage, lancez dbt compile --select model_name et regardez dans target/compiled/. Le SQL compilé fait foi ; s’il a l’air faux, la macro est fausse.
Gérer les breaking changes
Parfois, vous devez modifier le comportement d’une macro, et le changement affectera le fonctionnement existant.
Le package dbt-utils illustre l’approche :
- Créez une nouvelle macro avec le comportement amélioré et un nom clair
- Conservez l’ancienne macro en ajoutant un avertissement de dépréciation via
exceptions.warn() - Documentez le chemin de migration dans votre changelog ou README
- Supprimez l’ancienne macro dans une version majeure
{% macro cents_to_dollars_v2(column_name, scale=2, round_mode='half_up') %} {# Nouvelle version avec arrondi configurable #}{% endmacro %}
{% macro cents_to_dollars(column_name, scale=2) %} {{ exceptions.warn("cents_to_dollars is deprecated. Use cents_to_dollars_v2 instead.") }} ROUND({{ column_name }} / 100.0, {{ scale }}){% endmacro %}Pour les projets internes, ce formalisme n’est pas toujours nécessaire. Si votre équipe est petite et que vous pouvez mettre à jour tous les usages dans une seule PR, changez la macro et corrigez les appels. Le versioning compte davantage quand les macros sont partagées entre équipes ou publiées comme packages.
Communiquez clairement les changements. Un message Slack, une description de PR, une note dans le changelog du projet, peu importe le moyen tant qu’il fonctionne pour votre équipe. Les changements de comportement surprises causent plus de friction que le changement lui-même.
Commencez par la retenue
Le meilleur code macro, c’est souvent de ne pas en écrire du tout. Du SQL en ligne, répété quelques fois, vous donne l’information nécessaire pour construire la bonne abstraction quand elle le mérite enfin. Une macro ciblée avec un nom clair et une bonne documentation se rentabilise ; une macro prématurée ne fait qu’ajouter de l’indirection.
Votre objectif est de construire un projet dbt que votre équipe peut comprendre et maintenir. Parfois, cela signifie trois lignes de code similaires plutôt qu’un seul appel de macro. C’est du bon sens, pas un échec.