Metosin

Configuring Clojure Apps

Last week Steven Deobald asked on Twitter how to do configuration in Clojure:

In this post I'll tell you how we do it at Metosin. We've build a few web applications with a Clojure backend and a ClojureScript frontend. While there's no Metosin architecture carved in stone, some recurring patterns have emerged. Usually we structure the backend with either Component or Mount. The applications are deployed to virtual servers as uberjars using Ansible. To store the configuration, we use EDN files and we load them using Maailma.

We have two configuration files:

  • resources/config-defaults.edn contains a default values for every configuration variable. The defaults are suitable for local development. When the application is deployed, this file is included in the deployed JAR.
  • For each environment (staging/production/etc), we create a file called config-local.edn. It contains environment-specific overrides for the defaults, including things like port numbers and database credentials. This configuration file is deployed separately from the JAR.

We load the configuration with code like this:

(require '[maailma.core :as m])

(defn get-config []
  (m/build-config
    (m/resource "config-defaults.edn")
    (m/file "./config-local.edn")))

Maailma does a deep merge of the configuration maps, which makes overriding the defaults easy. For example, we might have a feature flag for enabling development tools in the application. We then want to disable them in the production environment. The configuration files could look like this:

;; config-defaults.edn
{:http {:port 3000
        :show-dev-tools? true}}

;; config-local.edn for production
{:http {:show-dev-tools? false}}

;; What the production application sees:
{:http {:port 3000
        :show-dev-tools? false}}

If needed, you can overwrite the defaults for local development by creating a config-local.edn file. Sometimes we also maintain an extra configuration file for the locally-run integration tests.

Using Component

With Component, we pass the configuration to the parts of the system when creating them. If needed, the configuration can be also made a part of the system map:

(ns backend.system
  (:require [com.stuartsierra.component :as component]
            [backend.component.db :as db]
            [backend.component.http :as http]
            [maailma.core :as m]))

(defn get-config []
  (m/build-config
    (m/resource "config-defaults.edn")
    (m/file "./config-local.edn")))

(defn new-system []
  (let [env (get-config)]
    (component/map->SystemMap
      {:env  env
       :db   (db/create   (:db env))
       :http (http/create (:http env))})))

The configuration gets reloaded when you restart the system.

Using Mount

When using Mount, we create a state to contain the configuration

(ns backend.mount.config
  (:require [mount.core :as mount :refer [defstate]]
            [maailma.core :as m]))

(deftstate config
  :start (m/build-config
           (m/resource "config-defaults.edn")
           (m/file "./config-local.edn")
           (mount/args)))

The other states can then use the configure by requiring it:

(ns backend.mount.http
  (:require [backend.mount.config :refer [config]]
            [mount.core :as mount :refer [defstate]]))

(defn start-server [port]
  ;; ...
  )

(defstate http
  :start
  (let [port (get-in config [:http :port])]
    (start-server port)))

To reload the configuration files, restart the config state. I usually reload the whole config namespace in my editor, which makes Mount restart the state.

Alternatives

Configuration is not a one-size-fits-all affair and Maailma is not the only thing out there. For another take on EDN configuration files, take a look at the aero library. If you prefer to store the configuration in environmental variables – like Heroku recommends – see environ.