Learn the main macro risks Java teams should review: hidden evaluation, duplicated side effects, variable capture, confusing generated code, and weak tests around expansion behavior.
Macros move complexity from runtime calls into generated code. That can be valuable, but it also creates failure modes that ordinary function review will not catch.
The core rule is direct: if the expansion is surprising, the macro is risky.
| Risk | How it appears | Review defense |
|---|---|---|
| Hidden evaluation | A caller cannot tell when body forms run. | Show macroexpand-1 in examples and docs. |
| Duplicated side effects | A caller expression appears more than once in expansion. | Bind caller expressions once with auto-gensyms. |
| Variable capture | Generated locals collide with caller locals. | Use name# or explicit gensym. |
| Cryptic errors | Failure points at generated code with little context. | Keep expansions small and throw ex-info with useful data. |
| Tooling friction | Search, rename, and debugging do not follow normal function calls. | Prefer functions unless syntax is essential. |
This macro looks harmless:
1(defmacro bad-log
2 [expr]
3 `(do
4 (println "value:" ~expr)
5 ~expr))
But the caller expression appears twice. If it has side effects, they happen twice.
1(bad-log (send-email! user))
A safer version binds the result once:
1(defmacro log-once
2 [expr]
3 `(let [value# ~expr]
4 (println "value:" value#)
5 value#))
This is the same review habit Java engineers use for generated code: count where side-effecting expressions appear.
Generated locals should not depend on names chosen by the caller.
1(defmacro safe-measure
2 [& body]
3 `(let [started# (System/nanoTime)
4 result# (do ~@body)]
5 (println "elapsed ns:" (- (System/nanoTime) started#))
6 result#))
The # suffix in started# and result# creates unique generated symbols inside syntax quote. Without that discipline, macro-generated locals can collide with caller locals or become qualified in awkward ways.
Macros can make a call site look simple while hiding try, throw, retries, transactions, or multiple branches. Hidden control flow is not automatically bad, but it must be explicit in the macro name, docs, and expansion examples.
| Macro name | Reader expectation |
|---|---|
with-open |
Resource scope and cleanup. |
with-retry |
Repeated execution is possible. |
quietly |
Errors may be swallowed or redirected, so document carefully. |
do-business-logic |
Too vague; hides too much. |
Test macro behavior like normal code, then add focused expansion tests only for risky shapes.
| Test type | What it catches |
|---|---|
| Behavior test | The public call site returns and throws correctly. |
| Side-effect count test | Caller forms run the expected number of times. |
| Expansion smoke test | Generated code has the intended broad shape. |
| Error-message test | Macro failures help the caller fix the call site. |
Do not overfit tests to every generated symbol. Auto-gensym names are intentionally unstable.
| Java concern | Macro equivalent |
|---|---|
| Annotation processor generates unreadable code | Macro expands to unreadable Clojure. |
| Reflection hides the real method call | Macro hides evaluation or control flow. |
| Template method calls hooks unexpectedly | Macro evaluates body forms more than the caller expects. |