Use exceptions, ex-info, explicit error values, and validation boundaries deliberately so failures stay diagnosable without becoming normal control flow.
Error handling in Clojure works best when you separate expected failures from unexpected failures. Validation errors, missing business data, or permission denials are usually part of the program’s ordinary outcomes and should often be represented as data. Broken I/O, violated invariants, or corrupted assumptions usually deserve exceptions.
The most important question is not “Should I use try?” but “What kind of failure is this?”
That distinction keeps your code from turning every missing field into a thrown exception while also preventing silent corruption.
flowchart TD
A["Failure happens"] --> B{"Expected part of domain flow?"}
B -->|Yes| C["Return error as data"]
B -->|No| D{"Boundary or I/O problem?"}
D -->|Yes| E["Throw or catch exception with context"]
D -->|No| F["Fail fast on broken invariant"]
Clojure inherits JVM exceptions, and that is appropriate for operations that can fail because the outside world is unreliable.
1(defn read-config! [path]
2 (try
3 (slurp path)
4 (catch java.io.FileNotFoundException e
5 (throw (ex-info "Configuration file not found"
6 {:path path
7 :kind :config/missing}
8 e)))
9 (catch Exception e
10 (throw (ex-info "Unable to load configuration"
11 {:path path
12 :kind :config/unreadable}
13 e)))))
ex-info is usually the most useful way to throw application-level exceptions in Clojure because it adds structured context through ex-data.
1(try
2 (read-config! "config.edn")
3 (catch clojure.lang.ExceptionInfo e
4 (println "kind:" (:kind (ex-data e)))
5 (println "path:" (:path (ex-data e)))))
Good exception practice in Clojure:
ex-info and ex-dataIf the caller is expected to choose a different branch based on failure, returning data is often clearer than throwing.
1(defn create-user [{:keys [email]}]
2 (cond
3 (nil? email)
4 {:status :invalid
5 :errors [:email-required]}
6
7 (re-find #"@" email)
8 {:status :ok
9 :user {:id (random-uuid)
10 :email email}}
11
12 :else
13 {:status :invalid
14 :errors [:email-format-invalid]}))
This style works well for service handlers, command processing, and validation-heavy workflows because the caller can decide how to render the error without first unpacking a thrown exception.
1(case (:status (create-user {:email "bad"}))
2 :ok {:http-status 201}
3 :invalid {:http-status 400
4 :body {:errors [:email-format-invalid]}})
You do not need a full Either or Result library to get the benefit. Ordinary maps are often enough, especially when your team already has a consistent shape for success and error values.
:pre and :post conditions are useful for internal contracts, but they are not a substitute for user-input validation.
1(defn average
2 [values]
3 {:pre [(seq values)
4 (every? number? values)]}
5 (/ (reduce + values) (count values)))
Use these when violating the contract means the program is being used incorrectly. Do not rely on them as your primary runtime validation layer for web forms, API payloads, or untrusted external input.
For boundary validation, explicit validation functions or libraries such as Malli or clojure.spec.alpha are usually better because they produce explainable error data.
A common production pattern is:
1(defn save-order! [datasource order]
2 (try
3 ;; write to database here
4 {:status :ok :order order}
5 (catch Exception e
6 (throw (ex-info "Failed to persist order"
7 {:order-id (:id order)
8 :kind :db/write-failed}
9 e)))))
10
11(defn handle-create-order [datasource command]
12 (let [validated (if (seq (:items command))
13 {:status :ok :command command}
14 {:status :invalid :errors [:empty-items]})]
15 (if (= :invalid (:status validated))
16 validated
17 (save-order! datasource (:command validated)))))
That keeps validation, domain rules, and I/O failures from being mixed into one indistinct “error handling” blob.
Libraries inspired by Either or Result types can help when you need:
They are optional, not mandatory. For many Clojure teams, plain maps plus disciplined conventions are simpler and more idiomatic than importing category-theory vocabulary into otherwise straightforward application code.
Exception everywhere without adding context:pre/:post as if they were user-facing validationThe goal is not “no exceptions.” The goal is that failures carry the right amount of structure and appear at the right architectural boundary.