Browse Learn Clojure Foundations as a Java Developer

Build Data-First DSLs with Clojure Macros

Learn how to use Clojure macros for small internal DSLs without abandoning data-first design, and when plain maps or functions should remain the preferred API.

An internal domain-specific language (DSL) is a call-site style embedded in Clojure that makes a domain easier to express. Good Clojure DSLs stay close to data. They do not hide everything behind syntax.

For Java engineers, a Clojure DSL can look like a fluent builder. The difference is that Clojure can often make the DSL a small layer over maps, vectors, and functions instead of a new object hierarchy.

Start with Data

Before writing a macro, ask whether plain data is enough:

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

This is searchable, printable, transformable, and easy to validate. If this shape is readable, keep it.

Add Syntax Only for Repeated Structure

If repeated maps make the route table noisy, a small macro can remove boilerplate:

1(defmacro routes [& specs]
2  (vec
3    (for [[method path handler] specs]
4      {:method method
5       :path path
6       :handler handler})))

Usage:

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

The macro returns a vector of maps. The handlers remain ordinary symbols resolved by the generated code. There is no parser, no string DSL, and no hidden runtime interpreter.

Compare with a Java Builder

Java builder habit Clojure data-first habit
Chain methods to construct an object. Build maps and vectors directly.
Hide validation inside builder methods. Validate data with functions or specs.
Encode domain grammar in classes. Keep the grammar visible in data and small macros.
Use reflection or annotations for discovery. Prefer explicit values unless runtime discovery is required.

Keep the Macro Thin

The macro should shape syntax. Runtime validation should usually be a function:

1(defn validate-route [{:keys [method path handler]}]
2  (when-not (#{:get :post :put :delete} method)
3    (throw (ex-info "Unsupported route method" {:method method})))
4  (when-not (string? path)
5    (throw (ex-info "Route path must be a string" {:path path})))
6  (when-not (ifn? handler)
7    (throw (ex-info "Route handler must be callable" {:handler handler}))))

That keeps the macro small and lets normal tests cover most behavior.

DSL Review Checklist

Question Good answer
Could plain data solve this? Yes: use data. No: justify the syntax.
Does the macro generate data or ordinary function calls? Yes, and the expansion is readable.
Is runtime behavior testable without macro expansion? Yes, helper functions carry most logic.
Does the DSL hide too much control flow? No, readers can still reason about execution.

Knowledge Check

### What should you try before writing a macro-based DSL? - [x] A plain data representation such as maps and vectors. - [ ] A custom parser. - [ ] Java reflection. - [ ] `eval` over strings. > **Explanation:** Clojure data is already expressive. A macro should add syntax only when plain data becomes repetitive or awkward. ### In the `routes` example, what does the macro mainly remove? - [x] Repeated map boilerplate at the call site. - [ ] Runtime validation. - [ ] The need for route handlers. - [ ] The JVM compiler. > **Explanation:** The macro keeps the DSL thin by turning compact route vectors into ordinary route maps. ### Where should most DSL validation logic usually live? - [x] In functions that can be tested normally. - [ ] Inside a large macro body. - [ ] In a string parser. - [ ] In global mutable state. > **Explanation:** Keeping runtime behavior in functions makes the DSL easier to test and keeps the macro focused on syntax.
Revised on Saturday, May 23, 2026