Skip to content
Kevin W. van Rooijen edited this page Aug 19, 2020 · 33 revisions

Files

Duct structures applications around an immutable configuration. By default this is split across three edn files:

  • dev/resources/local.edn
  • dev/resources/dev.edn
  • resources/<project>/config.edn

local.edn

The first file Duct loads is local.edn. This is created by the lein duct setup command and should contain configuration local to the current machine that is used during development.

dev.edn

This dev.edn file contains configuration that is used during development, but that should be shared by all developers in the project. Unlike local.edn, dev.edn is stored in version control.

config.edn

If you create a Duct project with no profile hints, config.edn will look like:

{:duct.profile/base
 {:duct.core/project-ns <project>}

 :duct.profile/dev   #duct/include "dev"
 :duct.profile/local #duct/include "local"
 :duct.profile/prod  {}

 :duct.module/logging {}}

This configuration has a :duct.core/project-ns option that sets the top-level namespace of the project. This is useful for telling modules where to put resources by default.

The logging module transforms the configuration, adding in keys for logging (default timbre). Modules are used heavily in Duct to provide behavior that can be queried and customized.

Completed Configuration

Before Duct initiates the configuration, it must first be prepped. Prepping involves three steps:

  1. Read the configuration
  2. Merge in any included profiles
  3. Apply the modules

Once the configuration has been prepped, the result will be much larger. We can access the completed configuration through the REPL:

user=> (dev)
:loaded
dev=> (prep)
:prepped
dev=> (pprint config)

For the basic configuration we've described so far, this is the completed configuration map:

{:duct.core/project-ns <project> 
 :duct.core/environment :development
 :duct.logger/timbre {:level :debug
                      :appenders {:duct.logger.timbre/spit #ig/ref :duct.logger.timbre/spit
                                  :duct.logger.timbre/brief #ig/ref :duct.logger.timbre/brief}}
 :duct.logger.timbre/spit {:fname "logs/dev.log"}
 :duct.logger.timbre/brief {:min-level :report}}

Environment Variables

Environment variables can be incorporated into the configuration via the #duct/env reader literal. For example:

...
:duct.server.http/jetty
 {:host    #duct/env "SERVER_HOST"
  :port    #duct/env [ "SERVER_PORT" Int ]
  :ssl?    #duct/env [ "SERVER_SSL" Bool :or false ]
  :handler #ig/ref :duct.core/handler
  :logger  #ig/ref :duct/logger}
...

Note on the example above how Duct can coerce the environment variable to a number or boolean, plus you can specify a default value to be used if the environment variable is not defined.

In the case of boolean coercion, true values are true, t, yes, y. False values are false, f, no, n, the empty string and nil.

Top level components

Aka components that aren't a dependency for other components.

A top level component like a scheduler (a component for periodic tasks) has to derive from :duct/server or :duct/daemon. The reason is that when running Duct from -main, only keys that are derived from :duct/daemon are executed. Imagine that in myapp/scheduler.clj is defined the component:

(ns myapp.scheduler
  (:require
    [integrant.core :as ig]))

(defmethod ig/init-key :myapp/scheduler
  [_ conf]
  ;; start the scheduler here
  )

Now the scheduler has to be derived from :duct/daemon, thus add following line into myapp/scheduler.clj:

(derive :myapp/scheduler :duct/daemon)

Don't forget to require myapp/scheduler.clj somewhere, e.g. in main.clj.

Or the derivation can be defined in resources/duct_hierarchy.edn like:

{:myapp/scheduler [:duct/daemon]}

Composite Keys

Vectors denote composite keys in Integrant, and are often used to give a unique identifier to keys we want to use multiple times.

[:duct.migrator.ragtime/sql :foo.migration/example]
{:up ["CREATE TABLE example (id int)"]
 :down ["DROP TABLE example"]}

In this case, the base key is :duct.migrator.ragtime/sql, which denotes a SQL migration. Because it’s likely we’ll want multiple migrations eventually, we make the key unique by adding :foo.migration/example as an identifier. For more about composite keys, see Integrant.

Constants

In order to create a key with a constant value you'll need to derive that key from :duct/const.

[:duct/const :foo.aws/access-key] #duct/env ["AWS_ACCESS_KEY_ID" Str]

This example creates a composite key which Integrant treats as being derived from both :foo.aws/access-key and :duct.const. The value is that of the environment variable AWS_ACCESS_KEY_ID coerced to a String.

Duct Base, Profiles, and Modules

We use Duct modules to modify our configuration on startup. Duct's config.edn is split into three parts.

  • Duct base config
  • Duct profiles
  • Duct modules

Duct Base config

The base config is a map of data inside of the :duct.profile/base key. This is the basis of your configuration, which can be manipulated by modules and profiles. After all profiles have been merged, and module functions applied, the resulting map in :duct.profile/base will be returned.

config.edn

{:duct.profile/base
 {:duct.core/project-ns your.application
 ;; Base configuration
 ,,,}

;; Everything below are modules / profiles
}

Duct Profile

A Duct profile is a map that gets merged into the base config, depending on the profile provided at startup. This allows us to have different behavior depending on the environment. For example we'd want to access the local PostgreSQL database with default credentials during development. But in production we'd want to access a database with a secret on a remote server.

config.edn

{:duct.profile/base
 {:duct.core/project-ns your.application
 ;; Base configuration
 ,,,}

 :duct.profile/dev   #duct/include "dev.edn"
 :duct.profile/prod  #duct/include "prod.edn"

;; Everything below are modules
}

Then in your dev.edn you can specify a custom connection uri, which will be merged into your base configuration at startup.

dev.edn

{:duct.database/sql
 {:connection-uri "jdbc:postgresql://localhost:5432/postgres?user=postgres&password=postgres"}}

Resulting in the final config (in dev environment):

{:duct.core/project-ns your.application
 :duct.database/sql
 {:connection-uri "jdbc:postgresql://localhost:5432/postgres?user=postgres&password=postgres"}}

Duct Module

Duct modules are like profiles, in the sense that they change the base configuration. The difference is that modules are functions which take a base config and return a base config. Whereas profiles simply merge maps depending on the environment. Modules are useful if you need to generate data instead of simply merging it. We could for example use a module to generate routes.

Here's a simple example where we have a duct module named :my/module, which injects a new key in our base config.

config.edn

{:duct.profile/base
 {:duct.core/project-ns your.application
 ;; Base configuration
 ,,,}

 ;; Module keys

 :my/module {:handler/name :my/handler}}

The implementation of our module. Notice how this defmethod returns a anonymous function which takes a config, modifies it, and returns it. This config is the map inside of :duct.profile/base. We can modify it in any way that we want. The opts is the map that we provided to our module. That being {:handler/name :my/handler}, defined in config.edn. In this implementation we simply add a new key to our base config.

(defmethod ig/init-key :my/module [_ opts]
  (fn [config]
    (assoc config (:handler/name opts) {:generated? true})))

After initialization, the following config will be outputted:

{:duct.core/project-ns your.application
 :my/handler {:generated? true}}