Browse Learn Clojure Foundations as a Java Developer

Practice Writing Useful Clojure Macros

Practice writing small, reviewable Clojure macros by controlling evaluation, avoiding variable capture, keeping runtime logic in functions, and inspecting expansions before trusting the abstraction.

Macro practice should train judgment, not just syntax. A useful macro changes the call-site shape in a way a function cannot, expands into ordinary readable Clojure, and keeps runtime logic testable.

Use these exercises from a REPL. For each macro, inspect the expansion before you run it.

Practice Rules

Rule Why it matters
Expand first with macroexpand-1. You need to review generated code before trusting it.
Test with side effects. Atoms and logging reveal repeated evaluation.
Use generated locals. Macro-owned names should not capture caller names.
Move runtime work into functions. Functions are easier to test than large generated forms.

Exercise 1: Log an Expression Once

Write a macro named log-once that prints the caller’s expression and value, then returns the value.

1(log-once (+ 1 2))
2;; prints something like: (+ 1 2) => 3
3;; returns: 3

Acceptance checks:

Check Expected result
(log-once (swap! counter inc)) The atom increments once, not twice.
(macroexpand-1 '(log-once (+ 1 2))) The caller expression appears in one evaluated position.
Temporary names Generated with # or gensym.

Starter shape:

1(defmacro log-once [expr]
2  ;; Replace this with a syntax-quoted expansion.
3  nil)

Review hint: bind ~expr once, then print the quoted form and generated value.

Exercise 2: Wrap a Body with Timing

Write a macro named with-timing that runs a body, prints elapsed milliseconds, and returns the body’s final value.

1(with-timing "load users"
2  (load-users db)
3  :done)
4;; returns: :done

Acceptance checks:

Check Expected result
Body has multiple forms. All forms run in order.
Body returns a value. The final body value is returned.
Body throws. Timing still prints from finally, then the exception propagates.

Starter shape:

1(defmacro with-timing [label & body]
2  ;; Use try/finally and generated locals.
3  nil)

Review hint: the macro should arrange control flow. If you want to format or publish timing data, put that behavior in a helper function.

Exercise 3: Build a Thin Route DSL

Create a small macro named routes that turns compact route specs into data.

1(routes
2  [:get "/health" health-handler]
3  [:post "/users" create-user!])

Target shape:

1[{:method :get
2  :path "/health"
3  :handler health-handler}
4 {:method :post
5  :path "/users"
6  :handler create-user!}]

Acceptance checks:

Check Expected result
Expansion size Small enough to read.
Runtime validation Kept in a function, not hidden inside the macro.
Handler symbols Remain ordinary Clojure symbols in the generated code.

Starter shape:

1(defmacro routes [& specs]
2  ;; Generate a vector of route maps.
3  nil)

Review hint: if plain data is already readable for your team, do not write the macro. The exercise is about recognizing the boundary.

Exercise 4: Add Diagnostic Context

Write a macro named with-context that catches an exception, attaches a runtime context map and the quoted body form, then rethrows.

1(with-context {:job-id job-id}
2  (import-users! db file))

Acceptance checks:

Check Expected result
Exception data Includes :context and :form.
Original cause Preserved as the cause of the new exception.
Logging Optional and delegated to a helper function.

Starter shape:

1(defmacro with-context [context & body]
2  ;; Build a quoted form value outside the syntax quote.
3  nil)

Review hint: the macro is justified only if the diagnostic needs the original body form. If a context map and exception are enough, use a function.

Self-Review Before You Move On

Question Pass condition
Can you explain the expansion aloud? Yes, without relying on the macro name.
Does any caller expression appear twice? No, unless repeated evaluation is the point.
Are macro-owned locals generated? Yes.
Could a function solve the same problem more clearly? No, or the macro should be deleted.

Knowledge Check

### What should you do before trusting a newly written macro? - [x] Inspect its expansion with `macroexpand-1`. - [ ] Add it to every namespace. - [ ] Replace all helper functions with macros. - [ ] Run it only with literal values. > **Explanation:** Macro work is generated-code work. `macroexpand-1` lets you inspect the form your macro produces. ### Why test a macro with `(swap! counter inc)`? - [x] It reveals whether the macro evaluates an argument more than once. - [ ] It makes the macro compile to Java source. - [ ] It prevents all variable capture. - [ ] It disables syntax quote. > **Explanation:** Side effects make repeated evaluation visible. If the counter changes twice, the expansion reused the caller form incorrectly. ### What is a sign that a macro should probably be a function? - [x] It only transforms already computed runtime values. - [ ] It needs unevaluated body forms. - [ ] It must quote the caller's expression for diagnostics. - [ ] It introduces a small syntax wrapper around a body. > **Explanation:** Runtime value transformation is normal function territory. Macros are for syntax and evaluation control.
Revised on Saturday, May 23, 2026