Build readable data-processing flows with `comp`, threading macros, and clear transformation boundaries in Clojure.
Pipeline composition: A way of expressing a larger transformation as a sequence of smaller steps, each with a clear input and output shape.
Clojure programs are often easiest to understand when data moves through a visible pipeline. Instead of burying logic inside large functions or mutable objects, you keep each step small, name the transformations clearly, and decide whether you are composing functions or threading a concrete value through operations.
That distinction matters. Clojure gives you both comp and threading macros because they solve related but different problems.
Use comp when you want to define a reusable function ahead of time.
1(def normalize-email
2 (comp clojure.string/lower-case
3 clojure.string/trim))
Use -> when you are walking a single value through operations that conceptually read left to right.
1(-> user
2 :email
3 clojure.string/trim
4 clojure.string/lower-case)
Use ->> when the collection or stream should land in the last argument position.
1(->> orders
2 (filter :paid?)
3 (map :total)
4 (reduce + 0))
Use as-> when the shape changes and neither -> nor ->> stays readable.
1(as-> raw-data $
2 (json/parse-string $ true)
3 (:payload $)
4 (select-keys $ [:id :status :items]))
Pipelines stay maintainable when each step has a predictable contract. A reviewer should be able to answer these questions quickly:
That means the best pipelines are usually shaped around ordinary maps, vectors, sequences, and explicit result values, not hidden mutable state.
1(defn parse-order [payload]
2 (json/parse-string payload true))
3
4(defn select-order-fields [order]
5 (select-keys order [:id :customer-id :items :currency]))
6
7(defn attach-item-count [order]
8 (assoc order :item-count (count (:items order))))
9
10(def process-order
11 (comp attach-item-count
12 select-order-fields
13 parse-order))
Each step is understandable on its own. That is more important than whether the code looks “functional enough.”
A healthy pipeline usually has a pure middle and a small imperative edge.
1(defn enrich-order [order]
2 (-> order
3 normalize-order
4 attach-item-count
5 attach-risk-flags))
6
7(defn save-order! [db order]
8 (jdbc/execute! db ["insert into orders ..." order]))
9
10(defn handle-order! [db payload]
11 (-> payload
12 parse-order
13 enrich-order
14 (save-order! db)))
save-order! is not a hidden step in the middle of a “pure” pipeline. It is the explicit effect boundary. That makes testing and reasoning simpler.
You can overdo this pattern. A pile of one-line helpers with vague names is not automatically clearer than one well-structured function. The point of composition is to reveal the flow, not to multiply indirection.
Common failure modes:
comp where -> would be easier to readThis pattern appears all over idiomatic Clojure:
The underlying idea is the same: keep each step honest, predictable, and small enough to replace without rewriting the whole flow.
When reviewing pipeline-heavy Clojure code, ask:
comp, ->, ->>, or as->?Good pipelines make the data journey obvious. Bad ones turn simple work into ceremonial syntax.