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 and src 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

1

You’ll encounter the odd package.json file when dipping a toe into the JavaScript ecosystem.