Classy Paths
When complexity grows we respond in kind. We’ve taken the decision that we need to engineer rather than script or program a solution. Directories emerge and almost inevitably you’re dealing with at least two distinct programming languages. That said, we quite like programming languages.
Classpaths with tools.deps
From our rarefied position atop a Herculean virtual machine we only need declare the directories and dependencies. The price of travel, we must build a classpath that will make the list of delightful dependencies available.
We configure tools.deps via a deps.edn
file. We’ll dive straight into the code, and elaborate below.
{:paths ["resources" "src"] :deps {com.stuartsierra/component {:mvn/version "1.0.0"} org.clojure/clojure {:mvn/version "1.10.1"}} :aliases {:dev {:extra-paths ["dev" "dev-resources"] :extra-deps {com.stuartsierra/component.repl {:mvn/version "0.2.0"} org.clojure/test.check {:mvn/version "1.0.0"} org.clojure/tools.namespace {:mvn/version "1.0.0"}}} :test {:extra-paths ["test" "test-resources"]}}}
Note the following:
- We declare the
resources
andsrc
directories for inclusion in our classpath; - We specify both the Clojure and Component artefacts we require (Clojure’s a library you can load from other JVM-based languages!);
- We enumerate aliases (named by the
:dev
and:test
keywords) that update paths and dependencies with only data.
I chose my verbs carefully there because everything here is EDN. It’s data, and there’s no execution going on. This isn’t something unique to Clojure but you will see this taken to relative extremes here. In a good way.
Most of the bits in deps.edn
exist to specify dependencies that tools.deps copy into ~/.m2
. Each dependency has its purpose.
- Component REPL deals with keeping track of the stateful
system
we’ll construct later; - With
test.check
we can generate data for property-based testing (and more!); tools.namespace
tracks changes to our source code, and will elegantly reload code for us.
When invoking clojure
we list aliases to modify our configuration. In development we make the code in the dev
directory available in addition to src
like so:
clojure -A:dev:test
Clojure supports aliases in numerous places:
clojure --help | rg ' \-(A|M|X)'
Exec function clojure [clj-opt*] -X[aliases] [a/fn] [kpath v]* Run main clojure [clj-opt*] -M[aliases] [init-opt*] [main-opt] [arg*] -Aaliases Use concatenated aliases to modify classpath -X[aliases] Use concatenated aliases to modify classpath or supply exec fn/args -M[aliases] Use concatenated aliases to modify classpath or supply main opts -X:deps mvn-install Install a maven jar to the local repository cache -X:deps git-resolve-tags Resolve git coord tags to shas and update deps.edn
Fast loading REPLs
As the codebase grows the cost of requiring your code can become noticable. Additionally, a REPL is our gateway to the JVM so anything we can do to get a running VM fast is worth a sniff.
(ns user (:require [clojure.spec.alpha :as s] [clojure.spec.test.alpha :as stest] [clojure.tools.namespace.repl :refer [set-refresh-dirs]] [com.stuartsierra.component.user-helpers :refer [set-dev-ns]]))
We need to tell clojure.tools.namespace
which directories to consider for code reloading.
(set-refresh-dirs "dev" "src" "test")
I like my clojure.spec
assertions to speak up in development, and instrument all the things.
(s/check-asserts true) (stest/instrument)
And here we tell Component REPL which namespace to switch to when we really need to load a chunk of our application code.
(set-dev-ns 'app.dev)
An empty dev namespace
I used to keep a load of requires in this dev namespace but with my current workflow these have become somewhat redundant. I’ve also never been a massive fan of the repetition of dev in the file’s path as it makes finding files with various tools more tricky.
(ns app.dev (:require [app.main :as main] [com.stuartsierra.component.repl :refer [set-init]]) (:import (java.util UUID)))
Here we tell Component REPL how to initialise our system, which we’ll be defining shortly.
(set-init (fn [_system] (main/system (main/config))))
A stateful system
(ns app.main (:require [com.stuartsierra.component :as component]) (:import (java.util UUID)))
This is a contrived example where in reality we’d likely use Aero or something I’ve grown quite fond of, Fern.
(defn config [] {:app/uuid (UUID/randomUUID)})
For our purposes an empty system map is adequate.
(defn system [config] (component/system-map ::config config))
Jacking in with Cider & Emacs
At this point there’s some (arguably additional) boilerplate required if you want to use Cider & Emacs.
((nil (cider-clojure-cli-global-options . "-A:dev:test") (cider-ns-refresh-after-fn . "com.stuartsierra.component.repl/start") (cider-ns-refresh-before-fn . "com.stuartsierra.component.repl/stop") (cider-preferred-build-tool . clojure-cli)))
And at this point we should be able to successfully jack in!
Footnotes
You’ll encounter the odd package.json
file when dipping a toe into the JavaScript ecosystem.