Clojure build configuration

Clojure full stack build configuration.

Introduction

This blog post is a continuation for my earlier blog post, in which I created a Clojure full stack application to compare Clojure and Javascript implementations: Comparing Clojure and Javascript.

In this new blog post I created a simple build process to create a standalone uberjar for the Clojure full stack application.

The application can be found in my Github repository: clojure-full-stack-demo.

tools.build

Clojure has batteries included when it comes to how to create a standalone uberjar for your Clojure applications: tools.build. Sean Corfield has created an excellent example web application using Clojure: usermanager-example. Examining the build.clj in that application and reading the excellent tools.build documentation, and with some help in the excellent Clojurians Slack, I managed to create a build process for my own Clojure full stack application.

Build It!

The commands in the just command runner Justfile gives the high-level overview what is about to happen:

@build-uber:
  echo "***** Building frontend *****"
  rm -rf prod-resources
  mkdir -p prod-resources/public/js  
  npx tailwindcss -i ./src/css/app.css -o ./prod-resources/public/index.css
  npx shadow-cljs release app
  echo "***** Building backend *****"
  clj -T:build uber

So, first we create the frontend - the tailwind css file, and then build the release frontend using shadow-cljs.

Once we have the frontend production build, we are ready to build the backend: clj -T:build uber. This will run the uber function in our build.clj file (which is pretty much the same as Sean Corfield’s original file):

(ns build
  (:refer-clojure :exclude [test])
  (:require [clojure.tools.build.api :as b]))

(def lib 'karimarttila/webstore)
(def main 'backend.core)
(def class-dir "target/classes")

(defn- uber-opts [opts]
  (assoc opts
         :lib lib 
         :main main
         :uber-file (format "target/%s-standalone.jar" lib)
         :basis (b/create-basis {:project "deps.edn"
                                 :aliases [:common :backend :frontend]})
         :class-dir class-dir
         :src-dirs ["src/clj" "src/cljc"]
         :ns-compile [main]))

(defn uber [opts]
  (println "Cleaning...")
  (b/delete {:path "target"})
  (let [opts (uber-opts opts)]
    (println "Copying files...")
    (b/copy-dir {:src-dirs   ["resources" "prod-resources" "src/clj" "src/cljc"]
                 :target-dir class-dir})
    (println "Compiling files...")
    (b/compile-clj opts)
    (println "Creating uberjar...")
    (b/uber opts)))

First we clean the target/classes directory. Then we copy the Clojure source files (src/clj and src/cljc, and the files in the resources and prod-resources to the target/classes directory. Remember, that we have already created the frontend production build into the prod-resources directory.

The next step is to compile the Clojure files: (b/compile-clj opts).

The final step is to create the standalone uberjar file: (b/uber opts).

We could have moved the frontend build step inside the build.clj - perhaps I do that later.

Run It!

The final test: run it! See the Justfile once again:

@run-uber:
  PROFILE=prod java -jar target/karimarttila/webstore-standalone.jar

Nothing new here. I just add the PROFILE=prod bash environment variable that we are running the app using production mode.

user.clj Considered Harmful

While I was trying to get the build process work, I stumbled upon a weird issue when running the app using the standalone uberjar for the first time: java.lang.NoClassDefFoundError: clojure/tools/logging/impl/LoggerFactory.

I just couldn’t figure out what caused this. But luckily, we have the excellent Clojurians Slack and helpful community! I asked help in the #beginner channel (2023-03-14) and provided detailed explanation regarding what I had done and what was the problem. After discussing with Alex Miller and Sean Corfield, Sean told me that the root cause for the problem was my user.clj file that was located in the src/clj directory. He instructed me to create a separate alias for the directory and put the culprit file there. So, I changed my deps.edn:

:aliases {:dev {:extra-paths ["dev-resources" "dev-src"]

=> dev-src. So, this is a development time dependency, and is not messing with the compilation any more.

It was also interesting to follow Alex Miller’s and Sean Corfield’s discussion regarding the history of the user.clj file (Clojure auto-loads the user.clj file - maybe we can read about the history of this decision later on in the Clojurians Slack).

To express my gratitude for this excellent community I promised to write a blog post of this build process story, and also to add a chapter called user.clj Considered Harmful.

Conclusions

Using the tools.build it is quite straightforward to create a standalone uberjar for your full stack Clojure application. Don’t forget to check where your user.clj lies, if you use one.

The writer is working at a major international IT corporation building cloud infrastructures and implementing applications on top of those infrastructures.

Kari Marttila

Kari Marttila’s Home Page in LinkedIn: https://www.linkedin.com/in/karimarttila/