Once a macro is worth creating — after following the rule of three — the next question is scope. The single responsibility principle says a macro should do one thing, and its name should describe that thing.
What a Multi-Responsibility Macro Looks Like
Here’s a macro that violates the principle:
{% 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 %}This macro has five parameters and combines three distinct operations: currency conversion, discount application, and… whatever else gets added when the next requirement comes in. The problems compound:
- Using it requires understanding every flag and what combination they produce
- Testing it means covering every combination of boolean flags
- Changing the discount logic risks breaking currency conversion
- When finance asks “how do we calculate discounts?” the answer is buried inside this macro instead of named explicitly
The Focused Alternative
Break it into macros that each do one thing:
{% 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 %}Each macro has a clear name that describes exactly what it does. Each has the minimum parameters needed for that one job. Each can be tested in isolation.
Composing Focused Macros in Models
The composition happens in the model, not inside a mega-macro:
SELECT order_id, {{ convert_currency( apply_discount(cents_to_dollars('amount_cents'), 0.1), 'EUR' ) }} AS order__discounted_amount_eurFROM {{ ref('base__shopify__orders') }}Yes, the model code is longer than a single process_amount call would be. This is a feature, not a bug. Anyone reading this model can see exactly what’s happening: convert to dollars, apply 10% discount, convert to EUR. When finance asks “how do we calculate discounted amounts in EUR?”, the answer is right there in the SQL — no macro archaeology required.
Composition belongs in the model. Macros define the vocabulary; models write the sentences.
The Warning Sign: Parameter Explosion
When a macro grows past five or six parameters, it’s almost certainly doing too much. Parameters accumulate when you try to handle every possible variant inside one abstraction instead of splitting into focused pieces.
Count the parameters before you ship a new macro. If you’re at four, think hard about whether this is still one responsibility. At five, look for the seam where you can split. At seven, the abstraction is almost certainly wrong.
Parameter explosion is particularly sneaky because it usually happens incrementally. The first version has two parameters. The next use case needs a third. Another edge case adds a fourth. By the time you have seven, you’re committed. Each addition felt reasonable at the time, but the accumulation represents accumulated scope creep.
When Composition Feels Awkward
Sometimes composing three macros produces nested calls that are hard to read. This is a signal worth paying attention to. Two possible causes:
The operations genuinely belong together. If you consistently apply discount-then-convert and never one without the other, maybe there’s a third focused macro: discounted_amount_in_currency(column, discount_rate, target_currency). This macro still has one responsibility — “apply business discount and convert” — even though it calls two others internally.
The model is doing too much. If the transformation is genuinely complex and nesting three macros is hard to follow, the complexity might need to live in intermediate models instead of being abstracted into macros. Intermediate models exist precisely to make complex transformations readable.
Neither answer is “pack it all into one macro with flags.”
What Single Responsibility Enables
Focused macros are individually testable. You can write a unit test for cents_to_dollars that only needs to verify the rounding behavior. A unit test for apply_discount only needs to verify the arithmetic. You’re not testing 2^3 combinations of boolean flags.
They’re also individually replaceable. If the currency conversion approach changes, you update convert_currency without touching discount logic. If discount rules evolve, apply_discount changes without risk to currency conversion. This isolation is only possible when responsibilities are cleanly separated.
Documentation is easier too. A macro that does one thing can be described in one sentence. A macro with five parameters and conditional branches requires explaining the interaction between flags, which requires examples showing what each combination produces. See dbt Macro Documentation YAML for how to write descriptions that actually get used.