Browse Learn Clojure Foundations as a Java Developer

Review Practical Clojure Metaprogramming Examples

Walk through practical Clojure metaprogramming examples for logging wrappers, generated definitions, and data-first DSLs while keeping macro expansion readable.

Good metaprogramming examples are small enough to review. If an example needs a page of explanation before you can trust its expansion, it is probably too ambitious for a first shared macro.

Example 1: Logging a Single Expression

1(defmacro dbg
2  [expr]
3  `(let [value# ~expr]
4     (println "DBG" '~expr "=>" value#)
5     value#))

Why it works:

Detail Reason
~expr appears once The caller expression evaluates once.
'~expr is quoted The original expression can be printed.
value# is auto-gensymed Generated local avoids caller-name collisions.
The macro returns value# The wrapper preserves the expression value.

This is a good teaching macro because the expansion is ordinary let plus println.

Example 2: Generating Repeated Definitions

Use a helper function to build forms, then a macro to place them at the top level.

 1(defn getter-form
 2  [field]
 3  `(defn ~(symbol (str "get-" (name field)))
 4     [m#]
 5     (get m# ~field)))
 6
 7(defmacro defgetters
 8  [& fields]
 9  `(do
10     ~@(map getter-form fields)))

Usage:

1(defgetters :id :email)

Representative expansion:

1(do
2  (defn get-id [m__auto__] (get m__auto__ :id))
3  (defn get-email [m__auto__] (get m__auto__ :email)))

This pattern is similar to Java annotation-generated boilerplate, but the generated Clojure should still be inspected.

Example 3: Data Before DSL Syntax

Not every metaprogramming-looking problem needs a macro. A data-first route table may be clearer than a macro DSL:

1(def routes
2  [{:method :get
3    :path "/users/:id"
4    :handler get-user}
5   {:method :post
6    :path "/users"
7    :handler create-user}])

This is ordinary data. It can be validated, transformed, documented, loaded in tests, and consumed by a router builder function.

1(defn build-router
2  [routes]
3  (reduce add-route empty-router routes))

Use a macro only if call-site syntax materially improves correctness or readability beyond what data can provide.

Example Selection Table

Problem Good first approach Macro only if
Log one expression Small macro or function wrapper You need the original unevaluated expression.
Generate many similar defs Helper functions plus macro Top-level definitions are truly needed.
Configure routes Data plus builder function Syntax prevents invalid route shapes.
Wrap resource lifecycle Function with thunk Binding/body syntax is central.

Knowledge Check

### Why does `dbg` bind `value#`? - [x] To evaluate the caller expression once and return the same value. - [ ] To convert the result into Java bytecode. - [ ] To prevent the expression from running. - [ ] To create a namespace alias. > **Explanation:** Binding once avoids duplicate evaluation and lets the macro print and return the same value. ### Why use helper functions in `defgetters`? - [x] They keep form construction testable and the macro boundary thin. - [ ] They force all generated functions to be private. - [ ] They prevent top-level definitions. - [ ] They make the macro run at runtime. > **Explanation:** Helper functions can be tested as data transformations. The macro only splices generated forms. ### When should data be preferred over a DSL macro? - [x] When ordinary data plus a builder function is clear and validatable. - [ ] Whenever caller expressions must remain unevaluated. - [ ] When new binding syntax is required. - [ ] When generated code must define functions. > **Explanation:** Data-first design is often simpler than macro syntax and works well for configuration-like problems.
Revised on Saturday, May 23, 2026