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.
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.
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.
| 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. |
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.
| 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. |