DEV Community

ivan.gavlik
ivan.gavlik

Posted on

Leiningen — Complete Tutorial & Best Practices

Leiningen is the build tool and project manager for Clojure. Think of it
like npm (Node), Maven (Java), or pip (Python) — but designed around
Clojure's philosophy.

What Leiningen Does

  • Creates and scaffolds projects
  • Manages dependencies (from Clojars + Maven Central)
  • Runs REPLs, tests, and builds
  • Compiles and packages your app (JAR/uberjar)
  • Runs custom tasks via plugins
  • Manages profiles (dev/test/prod configs)

Project Structure
my-app/
├── project.clj ← The heart: dependencies, config, build
├── src/
│ └── my_app/
│ └── core.clj ← Main namespace (note: _ not - in folders)
├── test/
│ └── my_app/
│ └── core_test.clj
├── resources/ ← Static files, config, assets
├── target/ ← Compiled output (git-ignore this)
└── README.md

Note: Clojure namespaces use - (hyphens), but folder/file names use _ (underscores). my-app.core → src/my_app/core.clj

project.clj

  (defproject my-app "0.1.0-SNAPSHOT"
    :description "What this project does"
    :url "https://github.com/you/my-app"
    :license {:name "MIT"}

    ;; Dependencies: [group/artifact "version"]
    :dependencies [[org.clojure/clojure "1.12.0"]
                   [ring/ring-core "1.12.0"]
                   [hiccup "2.0.0-RC3"]]

    ;; Entry point for `lein run`
    :main my-app.core

    ;; Source paths
    :source-paths ["src"]
    :test-paths   ["test"]
    :resource-paths ["resources"]

    ;; AOT compile specific namespaces (needed for uberjar)
    :aot [my-app.core]

    ;; Profiles: override config per environment
    :profiles
    {:dev  {:dependencies [[ring/ring-mock "0.4.0"]]}
     :prod {:aot :all}
     :uberjar {:aot :all :omit-source true}})
Enter fullscreen mode Exit fullscreen mode

Real world Example

Shows how do declare dependencies, plugins on project and profil level plus advanced usages of aliases

(defproject ebp-be "0.1.0-SNAPSHOT"
  :description "Backend project for Event Booking Platform."
  :url "https://github.com/IvanGavlik/ebp"
  :license {:name "MIT"}

  :dependencies [[org.clojure/clojure "1.11.3"]
                 [ring/ring-core "1.12.1"]
                 [ring/ring-jetty-adapter "1.12.1"]
                 [ring/ring-json "0.5.1"]
                 [compojure "1.7.1"]]

  ;; project level plugins
  :plugins [[lein-ring "0.12.6"]
            [lein-shell "0.5.0"]]

  ;; lein-ring plugin config - points to handler
  :ring {:handler ebp.handler/app
         :port 3000
         :auto-reload? true
         :reload-paths ["src"]
         :init ebp.handler/app-init
         :destroy ebp.handler/app-destroy
         :open-browser? true
         :war {:name "ebp-be"}}

  ;; entry point
  :main ebp.core

  :source-paths ["src"]
  :test-paths ["test"]
  :resource-paths ["resources"]

  :uberjar-name "ebp-be.jar"
  :profiles {:dev {:dependencies [[ring/ring-mock "0.6.2"]]
                   :plugins [[lein-ancient "0.7.0"]
                             [lein-cljfmt "0.9.2"]
                             [lein-kibit "0.1.11"]
                             [jonase/eastwood "1.4.3"]
                             [com.jakemccrary/lein-test-refresh "0.26.0"]]
                   ;; lein-ancient plugin config
                   :ancient {:check-clojure? true
                             :allow-snapshots? false
                             :allow-qualified? false}
                   :cljfmt {:indentation? true
                            :remove-trailing-whitespace? true
                            :insert-missing-whitespace? true
                            :remove-surrounding-whitespace? true}
                   :eastwood {:linters [:all]}
                   :test-refresh {:changes-only false}}
             ;; production build
             :uberjar {:aot :all :omit-source true}}
  :aliases {"dev" ["do"
                   ["shell" "cmd" "/c" "start" "cmd" "/k" "lein" "with-profile"
                    "dev" "ring" "server-headless"]
                   ["shell" "cmd" "/c" "start" "cmd" "/k" "lein" "with-profile"
                    "dev" "test-refresh"]
                   ["with-profile" "dev" "repl"]]
            "code-quality" ["with-profile" "dev" "do"
                            "cljfmt"  "fix,"
                            "eastwood,"
                            "kibit,"
                            "test,"
                            ;"ancient" ":all" ":profiles" does not work on windows
                            ]
            "ci"      ["do"
                       "clean,"
                       "cljfmt"  "check,"
                       "eastwood,"
                       "test,"
                       "uberjar"]})
Enter fullscreen mode Exit fullscreen mode

Full source code here

Explanation of each of the sections

Profiles

You can put inside a profile almost anything that goes in project.clj at the top level:

  :profiles
  {:dev
    {:dependencies   [...]   ; extra deps for this profile
     :plugins        [...]   ; extra plugins
     :source-paths   [...]   ; extra source dirs
     :resource-paths [...]   ; extra resource dirs
     :env            {...]   ; env vars (needs lein-environ)
     :jvm-opts       [...]   ; JVM flags
     :main           ...     ; override entry point
     :aot            [...]}} ; override AOT
Enter fullscreen mode Exit fullscreen mode

Activate a profile:
lein with-profile prod run
lein with-profile dev,test test # multiple profiles

Profiles merge, with later ones winning. :dev is active by default in most commands.

Common Patterns

Separate DB per environment

  :profiles
  {:dev  {:env {:db "jdbc:postgresql://localhost/app_dev"}}
   :test {:env {:db "jdbc:postgresql://localhost/app_test"}}}
Enter fullscreen mode Exit fullscreen mode

Dev-only tools that don't ship

  :profiles
  {:dev {:dependencies [[clj-reload "0.7.0"]
                        [eftest "0.6.0"]]
         :plugins [[lein-cljfmt "0.9.2"]]}}
Enter fullscreen mode Exit fullscreen mode

Different JVM memory per task

  :profiles
  {:dev     {:jvm-opts ["-Xmx512m"]}
   :uberjar {:jvm-opts ["-Xmx2g" "-server"]}}
Enter fullscreen mode Exit fullscreen mode

Extra source dir for dev utilities

  :profiles
  {:dev {:source-paths ["dev"]}}
Enter fullscreen mode Exit fullscreen mode

dev/user.clj — good place for REPL helper functions and startup code.

Plugins

Plugins extend what lein can do.

Where to Put Them

Global — your personal machine only
Good for personal tools you want in every project.

  ;; ~/.lein/profiles.clj  (not in the project repo)
  {:user
    {:plugins [[lein-ancient "0.7.0"]
               [lein-pprint "1.3.2"]]}}
Enter fullscreen mode Exit fullscreen mode

Top-level — for everyone on the project
Committed to git. Every developer gets these when they clone.

  (defproject my-app "0.1.0-SNAPSHOT"
    :plugins [[lein-ring "0.12.6"]
              [lein-ancient "0.7.0"]])
Enter fullscreen mode Exit fullscreen mode

Inside a profile — conditional
Only active when that profile is active.

  :profiles
  {:dev
    {:plugins [[lein-test-refresh "0.25.0"]
               [lein-cljfmt "0.9.2"]]}

   :uberjar
    {:plugins [[lein-shell "0.5.0"]]}}
Enter fullscreen mode Exit fullscreen mode

Most Useful Plugins

Web Development

  ;; Run Ring/Compojure apps with auto-reload
  [lein-ring "0.12.6"]
  lein ring server        ; open browser automatically
  lein ring server-headless
  lein ring uberjar       ; build deployable war

  ;; ring config in project.clj
  :ring
  {;; Required: your main handler function
   :handler my-app.core/app

   ;; Port (default 3000, overridden by $PORT env var)
   :port 3000

   ;; Auto-reload namespaces on file change
   :auto-reload? true

   ;; Which namespaces to watch for reload
   :reload-paths ["src"]

   ;; Run this function before server starts
   :init my-app.core/init

   ;; Run this function when server stops
   :destroy my-app.core/destroy

   ;; Open browser on start (default true with `lein ring server`)
   :open-browser? true

   ;; For WAR packaging: servlet name
   :war {:name "my-app"}}
Enter fullscreen mode Exit fullscreen mode

Testing Ring Handlers

Use ring-mock — no server needed:

  :profiles {:dev {:dependencies [[ring/ring-mock "0.4.0"]]}}

  (ns my-app.core-test
    (:require [clojure.test :refer :all]
              [ring.mock.request :as mock]
              [my-app.core :refer [app]]))

  (deftest test-home
    (let [response (app (mock/request :get "/"))]
      (is (= 200 (:status response)))))

  (deftest test-not-found
    (let [response (app (mock/request :get "/missing"))]
      (is (= 404 (:status response)))))

  (deftest test-post
    (let [response (app (-> (mock/request :post "/users")
                            (mock/json-body {:name "Alice"})))]
      (is (= 201 (:status response)))))
Enter fullscreen mode Exit fullscreen mode

Testing

  ;; Auto-run tests on file save
  [lein-test-refresh "0.25.0"]
  lein test-refresh

  ;; Test coverage report
  [lein-cloverage "1.2.4"]
  lein cloverage
  ;; generates target/coverage/index.html
Enter fullscreen mode Exit fullscreen mode

Code Quality

  ;; Format code (like Prettier)
  [lein-cljfmt "0.9.2"]
  lein cljfmt check       ; show formatting issues
  lein cljfmt fix         ; auto-fix them

  ;; Linter — catches common bugs
  [jonase/eastwood "1.4.3"]
  lein eastwood

  ;; Suggest more idiomatic Clojure
  [lein-kibit "0.1.8"]
  lein kibit
Enter fullscreen mode Exit fullscreen mode

Dependency Management

  ;; Check for outdated dependencies
  [lein-ancient "0.7.0"]
  lein ancient            ; check deps
  lein ancient :all       ; check deps + plugins
  lein ancient upgrade    ; auto-update versions
Enter fullscreen mode Exit fullscreen mode

Finding Plugins

Aliases

Shortcuts that let you define custom lein commands in project.clj. Instead of typing long commands repeatedly, you define them once and run them with a short name.

Simple Aliases

Single task shortcut

  :aliases
  {"fmt"   ["cljfmt" "fix"]
   "lint"  ["eastwood"]
   "hints" ["kibit"]
   "dev"   ["ring" "server"]}
Enter fullscreen mode Exit fullscreen mode

Example of call
lein fmt

Chaining Tasks with do

Run multiple tasks sequentially — use commas to separate task+args groups:

  :aliases
  {"ci" ["do"
         "clean,"
         "eastwood,"
         "kibit,"
         "test,"
         "uberjar"]}
Enter fullscreen mode Exit fullscreen mode

lein ci ; clean → lint → analyze → test → build

Note: The commas are part of the syntax — they tell do where one task ends and the next begins.

Profile + Task Combos

  :aliases
  {"dev"      ["with-profile" "dev"     "ring" "server"]
   "prod-run" ["with-profile" "prod"    "run"]
   "ci-test"  ["with-profile" "test"    "test"]
   "build"    ["with-profile" "uberjar" "uberjar"]}
Enter fullscreen mode Exit fullscreen mode

lein dev ; start dev server with :dev profile
lein build ; build production jar with :uberjar profil

Why AOT Compilation?

AOT = Ahead-Of-Time compilation

Normally Clojure compiles at runtime (when the JVM starts). AOT means
compiling to .class files before runtime, at build time.

The Problem It Solves
When you run a JAR, the JVM needs a standard Java entry point:
public static void main(String[] args)

Clojure's -main function is not that. It's a Clojure function that gets compiled on the fly when the JVM starts.

Without AOT, the JVM has no idea where to start. It can't find a main
method.

AOT compiles your -main namespace into real .class files with a proper Java main method the JVM can call directly.

What Happens With vs Without AOT ?

Without AOT (lein run):
JVM starts
→ loads Clojure runtime
→ Clojure compiles your namespaces on the fly
→ calls -main
(works fine because lein handles the startup)

With AOT (java -jar my-app.jar):
JVM starts
→ looks for main class in MANIFEST.MF
→ finds compiled .class file ← AOT produced this
→ calls -main
(works because the class file exists)

Concrete Example

(ns my-app.core
    (:gen-class))          ; ← this is also required! tells AOT to generate a
   class

  (defn -main [& args]
    (println "Hello!"))
Enter fullscreen mode Exit fullscreen mode

Without :gen-class in the namespace declaration, even with :aot, the JVM won't find the entry point.

Why Only in the Uberjar Profile?

  :profiles
  {:uberjar {:aot :all}}   ; ← AOT everything for production jar
Enter fullscreen mode Exit fullscreen mode

AOT has downsides in development:

  • Slower build — compiling everything takes time
  • Stale classes — if you change code, old .class files can cause confusing bugs
  • Bigger output — more files in target/

Top comments (0)