Important
Duct is currently in beta. This means breaking changes are possible but unlikely, and bugs are possible and likely.

1. Introduction

Duct is a framework for developing server-side applications in the Clojure programming language.

Duct does not rely on a project template; the skeleton of a Duct application is defined by an immutable data structure. This structure can then be queried or modified in ways that would be difficult with a more traditional framework.

While Duct has more general use cases, it’s particularly well-suited for writing web applications. This documentation will take you through Duct’s setup and operation, using a web application as an example project.

Warning
This documentation assumes knowledge of Clojure.
Some commands assume a Unix-like shell environment.

2. Fundamentals

This section will introduce the fundamentals of Duct’s design and implement a minimal ‘Hello World’ application. While it may be tempting to skip ahead, a sound understanding of how to use Duct will make later sections easier to follow.

2.1. Project Setup

This section will cover setting up a new Duct project. You’ll first need to ensure that the Clojure CLI is installed. You can check this by running the clojure command.

$ clojure --version
Clojure CLI version 1.12.1.1550

Next, create a project directory. For the purposes of this example, we’ll call the project tutorial.

$ mkdir tutorial && cd tutorial

The Clojure CLI looks for a file called deps.edn. To use Duct, we need to add the Duct Main tool as a dependency, and setup an alias to execute it.

To achieve this, create a new deps.edn file with the following content:

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/main {:mvn/version "0.2.1"}}
 :aliases {:duct {:main-opts ["-m" "duct.main"]}}}

Duct can now be run by invoking the :duct alias.

$ clojure -M:duct
Usage:
        clojure -M:duct [--init | --main | --nrepl | --repl]
Options:
  -c, --cider              Add CIDER middleware (used with --nrepl)
      --init               Create a blank duct.edn config file
      --init-calva         Create a .vscode/settings.json file for Calva
      --init-cider         Create a .dir-locals.el Emacs file for CIDER
      --init-docker        Create a Dockerfile
  -k, --keys KEYS          Limit --main to start only the supplied keys
  -p, --profiles PROFILES  A concatenated list of profile keys
  -n, --nrepl              Start an nREPL server
  -m, --main               Start the application
  -r, --repl               Start a command-line REPL
  -s, --show               Print out the expanded configuration and exit
  -v, --verbose            Enable verbose logging
  -h, --help               Print this help message and exit

We’ll be using this command a lot, so it’s highly recommended that you also create an shell alias. In a POSIX shell such as Bash, this can be done using the alias command.

$ alias duct="clojure -M:duct"

For the rest of this documentation, we’ll assume that this shell alias has been defined.

The final step of the setup process is to create a duct.edn file. This contains the data structure that defines your Duct application. The Duct Main tool has a flag to generate a minimal configuration file for us.

$ duct --init
Created duct.edn

This will create a file: duct.edn.

duct.edn
{:system {}}

2.2. Hello World

As mentioned previously, Duct uses a file, duct.edn, to define the structure of your application. We’ll begin by adding a new component key to the system.

duct.edn
{:system
 {:tutorial.print/hello {}}}

If we try running Duct, it will complain about a missing namespace.

$ duct --main
✗ Initiating system...
Execution error (IllegalArgumentException) at integrant.core/eval1191$fn (core.cljc:490).
No such namespace: tutorial.print

Duct is searching for a definition for the component, but not finding anything. This is unsurprising, as we haven’t written any code yet. Let’s fix this.

First we’ll create the directories.

mkdir -p src/tutorial

Then a minimal Clojure file at: src/tutorial/print.clj.

src/tutorial/print.clj
(ns tutorial.print)

(defn hello [_options]
  (println "Hello World"))

Now if we try to run the application, we get the expected output.

$ duct --main
✓ Initiating system...
Hello World

Congratulations on your first Duct application!

2.3. The REPL

Duct has two ways of running your application: --main and --repl.

In the previous section we started the application with --main, which will initiate the system defined in the configuration file, and halt the system when the process terminates.

The REPL is an interactive development environment.

$ duct --repl
✓ Loading REPL environment...
• Type :repl/help for REPL help, (go) to initiate the system and (reset)
  to reload modified namespaces and restart the system (hotkey Alt-E).
user=>

In the REPL environment the system will not be initiated automatically. Instead, we use the inbuilt (go) function.

user=> (go)
Hello World
:initiated

The REPL can be left running while source files updated. The (reset) function will halt the running system, reload any modified source files, then initiate the system again.

user=> (reset)
:reloading (tutorial.print)
Hello World
:resumed

You can also use the Alt-E hotkey instead of typing (reset).

The configuration defined by duct.edn can be accessed with config, and the running system can be accessed with system.

user=> config
#:tutorial.print{:hello {}}
user=> system
#:tutorial.print{:hello nil}

2.4. Testing

Duct includes a test runner based on Kaocha. By default it looks for test files in the test directory.

We can write a unit test for our ‘Hello World’ function.

test/tutorial/print_test.clj
(ns tutorial.print-test
  (:require [clojure.test :refer [deftest is]]
            [tutorial.print :as tp]))

(deftest unit-test
  (is (= "Hello World\n"
         (with-out-str (tp/hello {})))))

And then run it with the --test option.

$ duct --test
✓ Loading test environment
[(.)]
1 tests, 1 assertions, 0 failures.

Duct also provides a test library for running tests on your entire system.

Add the dependency:

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/main {:mvn/version "0.2.1"}
        org.duct-framework/test {:mvn/version "0.1.0"}}
 :aliases {:duct {:main-opts ["-m" "duct.main"]}}}

This provides a function, duct.test/run, which will start the system. Our ‘hello’ component doesn’t need cleaning up after itself, but it’s good practice to use the duct.test/with-system macro. This will halt the system after the macro’s body completes (see the Integrant section for more information on halting).

test/tutorial/print_test.clj
(ns tutorial.print-test
  (:require [clojure.test :refer [deftest is]]
            [duct.test :as dt]
            [tutorial.print :as tp]))

(deftest unit-test
  (is (= "Hello World\n"
         (with-out-str (tp/hello {})))))

(deftest system-test
  (is (= "Hello World\nGoodbye.\n"
         (with-out-str
           (dt/with-system [_sys (dt/run)]
             (println "Goodbye."))))))

As --test uses Kaocha under the hood, you can customize how the tests are run via a tests.edn file. See the Kaocha documentation for a full explanation of how this works.

Finally, there are a few options you can use at the command line to filter which tests will be run.

--test-config FILE

use a custom test config file

--test-focus ID

limit tests to a single test ID, namespace or symbol

--test-skip ID

skip tests with the test ID, namespace or symbol

--test-focus-meta KEY

only run tests with the supplied metadata key

--test-skip-meta KEY

skip tests with the supplied metadata key

2.5. Dependencies

So far all of our dependencies have been listed under the :deps key, including org.duct-framework/test, which is only used in tests.

While this isn’t necessarily bad — your test dependencies are unlikely to be large enough to matter — it is good practice to separate out dependencies used for developing or testing into a separate alias in your deps.edn file.

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/main {:mvn/version "0.2.1"}}
 :aliases
 {:duct {:main-opts ["-m" "duct.main"]}
  :test {:extra-deps {org.duct-framework/test {:mvn/version "0.1.0"}}}}}

Then to run the tests you’d use:

$ clojure -M:duct:test --test

As this is quite a lot to type, you may want to add an shell alias. For example, ductt with an extra ‘t’ for ‘test’.

$ alias ductt="clojure -M:duct:test --test"

For the remainder of this document we’ll only use the root-level :deps and the duct alias we defined in the Project Setup. However, it’s important to keep in mind that you can customize your own project to suit your particular requirements and preferences.

2.6. Modules

A module groups multiple components together. Duct provides a number of pre-written modules that implement common functionality. One of these modules is :duct.module/logging.

We’ll first add the new dependency:

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/main {:mvn/version "0.2.1"}
        org.duct-framework/test {:mvn/version "0.1.0"}
        org.duct-framework/module.logging {:mvn/version "0.6.5"}}
 :aliases {:duct {:main-opts ["-m" "duct.main"]}}}

Then we’ll add the module to the Duct configuration.

duct.edn
{:system
 {:duct.module/logging {}
  :tutorial.print/hello {}}}

Before the components are initiated, modules are expanded. We can see what this expansion looks like by using the --show flag. This will print out the expanded configuration instead of initiating it.

$ duct --main --show
{:duct.logger/simple {:appenders [{:type :stdout}]}
 :tutorial.print/hello {}}

The logging module has been replaced with the :duct.logger/simple component.

Note
Data in the configuration file will override data from expansions.

The --show flag also works with the --repl command.

$ duct --repl --show
{:duct.logger/simple
 {:appenders
  [{:type :stdout, :brief? true, :levels #{:report}}
   {:type :file, :path "logs/repl.log"}]}
 :tutorial.print/hello {}}

But wait a moment, why is the expansion of the configuration different depending on how we run Duct? This is because the --main flag has an implicit :main profile, and the --repl flag has an implicit :repl profile.

The :duct.module/logging module has different behaviors depending on which profile is active. When run with the :main profile, the logs print to STDOUT, but this would be inconveniently noisy when using a REPL. So when the :repl profile is active, most of the logs are sent to a file, logs/repl.log.

In order to use this module, we need to connect the logger to our ‘hello’ component. This is done via a ref.

duct.edn
{:system
 {:duct.module/logging {}
  :tutorial.print/hello {:logger #ig/ref :duct/logger}}}

The #ig/ref data reader is used to give the ‘hello’ component access to the logger. We use :duct/logger instead of :duct.logger/simple, as keys have a logical hierarchy, and :duct/logger fulfils a role similar to that of an interface or superclass.

Note
The ‘ig’ in #ig/var stands for Integrant. This is the library that Duct relies on to turn configurations into running applications.

Now that we’ve connected the components together in the configuration file, it’s time to replace the println function with the Duct logger.

src/tutorial/print.clj
(ns tutorial.print
  (:require [duct.logger :as log]))

(defn hello [{:keys [logger]}]
  (log/report logger ::hello {:name "World"}))

The duct.logger/report function is used to emit a log at the :report level. This is a high-priority level that should be used sparingly, as it also prints to STDOUT when using the REPL.

You may have noticed that we’ve replaced the "Hello World" string with a keyword and a map: ::hello {:name "World"}. This is because Duct is opinionated about logs being data, rather than human-readable strings. A Duct log message consists of an event, a qualified keyword, and a map of event data, which provides additional information.

When we run the application, we can see what this produces.

$ duct --main
✓ Initiating system...
2024-11-23T18:59:14.080Z :report :tutorial.print/hello {:name "World"}

But when using the REPL, we get a more concise message.

user=> (go)
:initiated
:tutorial.print/hello {:name "World"}

2.7. Variables

Sometimes we want to supply options from an external source, such as an environment variable or command line option. Duct allows variables, or vars, to be defined in the duct.edn configuration.

Currently our application outputs the same log message each time it’s run. Let’s create a configuration var to customize that behavior.

duct.edn
{:vars
 {name {:arg name, :env NAME, :type :str, :default "World"
        :doc "The name of the person to greet"}}
 :system
 {:duct.module/logging {}
  :tutorial.print/hello {:logger #ig/ref :duct/logger
                         :name   #ig/var name}}}

Then in the source file we can add the :name option that the var is attached to.

src/tutorial/print.clj
(ns tutorial.print
  (:require [duct.logger :as log]))

(defn hello [{:keys [logger name]}]
  (log/report logger ::hello {:name name}))

The default ensures that the application functions the same as before.

$ duct --main
✓ Initiating system...
2024-11-23T23:53:47.069Z :report :tutorial.print/hello {:name "World"}

But we can now customize the behavior via a command-line flag, --name, or via an environment variable, NAME.

$ duct --main --name=Clojurian
✓ Initiating system...
2024-11-24T04:45:19.521Z :report :tutorial.print/hello {:name "Clojurian"}

$ NAME=Clojurist duct --main
✓ Initiating system...
2024-11-24T04:45:54.211Z :report :tutorial.print/hello {:name "Clojurist"}

Vars are defined as a map of symbols to maps of options. The following option keys are supported:

:arg

a command-line argument to take the var’s value from

:default

the default value if the var is not set

:doc

a description of what the var is for

:env

an environment variable to take the var’s value from

:type

a data type to coerce the var into (one of: :str, :int or float)

2.8. Profiles

A Duct application has some number of active profiles, which are represented by unqualified keywords. When run via the --main flag, an implicit :main profile is added. When run via (go) at the REPL, an implicit :repl profile is added. When run via (duct.test/run), an implicit :test profile is added.

You can add additional profiles via the --profiles argument. Profiles are an ordered list, with preceding profiles taking priority.

$ duct --profiles=:dev --main

Most of the modules that Duct provides use profiles to customize their behavior to the environment they’re being run under. We can also use the #ig/profile data reader to create our own profile behavior.

Let’s change our component to allow for the log level to be specified.

src/tutorial/print.clj
(ns tutorial.print
  (:require [duct.logger :as log]))

(defn hello [{:keys [level logger name]}]
  (log/log logger level ::hello {:name name}))

In duct.edn we can use a profile to change the log level depending on whether the application uses the :main or :repl profile.

duct.edn
{:vars
 {name {:arg name, :env NAME, :type :str, :default "World"
        :doc "The name of the person to greet"}}
 :system
 {:duct.module/logging {}
  :tutorial.print/hello
  {:logger #ig/ref :duct/logger
   :level  #ig/profile {:repl :report, :main :info}
   :name   #ig/var name}}}

2.9. Integrant

So far we’ve used functions to implement components. The :tutorial.print.hello component was defined by:

src/tutorial/print.clj
(ns tutorial.print
  (:require [duct.logger :as log]))

(defn hello [{:keys [level logger name]}]
  (log/log logger level ::hello {:name name}))

But this is just convenient syntax sugar for Integrant’s init-key method. The following code is equivalent to the previous component definition:

src/tutorial/print.clj
(ns tutorial.print
  (:require [duct.logger :as log]
            [integrant.core :as ig))

(defmethod ig/init-key ::hello [_key {:keys [level logger name]}]
  (log/log logger level ::hello {:name name}))

Duct uses Integrant for its component definitions, and Integrant provides several multimethods to this end. The most common one is init-key. If no such method is found, Integrant searches for a function of the same name.

There is also halt-key!, which defines a teardown procedure for a key. This can be useful for cleaning up files, threads or connections that the init-key method (or function) opened. The return value from init-key will be passed to halt-key!.

src/tutorial/print.clj
(ns tutorial.print
  (:require [duct.logger :as log]
            [integrant.core :as ig))

(defmethod ig/init-key ::hello [_key {:keys [level logger name] :as opts}]
  (log/log logger level ::hello {:name name})
  opts)

(defmethod ig/halt-key! ::hello [_key {:keys [level logger name]}]
  (log/log logger level ::goodbye {:name name}))

For more information on the multimethods that can be used, refer to the Integrant documentation.

2.10. Includes

As a configuration grows, it may become useful to split it up into several smaller files. We can do this via the #duct/include reader tag.

If you tag a filepath string with #duct/include, it indicates to Duct that it should replace the tagged string with the corresponding edn file. You can place this anywhere in the your duct.edn configuration.

For example, suppose we want to factor out all of the vars into their own configuration file, and also have a separate configuration for the ‘hello’ component.

duct.edn
{:vars #duct/include "vars.edn"
 :system
 {:duct.module/logging {}
  :tutorial.print/hello #duct.include "hello.edn"}}
vars.edn
{name {:arg name, :env NAME, :type :str, :default "World"
       :doc "The name of the person to greet"}}
hello.edn
{:logger #ig/ref :duct/logger
 :level  #ig/profile {:repl :report, :main :info}
 :name   #ig/var name}

The path of the includes is always relative to the root configuration file — in this case, duct.edn.

3. Web Applications

While Duct can be used for any server-side application, its most common use-case is developing web applications and services. This section will take you through writing a ‘todo list’ web application in Duct.

3.1. Hello World

We’ll begin by creating a new project directory.

mkdir todo-app && cd todo-app

The first thing we’ll need is a deps.edn file that to provide the project dependencies. This will include Duct main and two additional modules: logging and web.

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/main {:mvn/version "0.2.1"}
        org.duct-framework/module.logging {:mvn/version "0.6.5"}
        org.duct-framework/module.web {:mvn/version "0.12.11"}}
 :aliases {:duct {:main-opts ["-m" "duct.main"]}}}

With that done, we need to ensure that the src directory exists. This is the default directory Clojure uses to store source files.

$ mkdir src
Important
It is especially important to ensure the source directory exists before starting a REPL, otherwise the REPL will not be able to load source changes.

As this is a Duct application, we’ll need a duct.edn file. This will contain the two modules we added to the project’s dependencies.

duct.edn
{:system
 {:duct.module/logging {}
  :duct.module/web {}}}

We can now start the application with --main.

$ duct --main
✓ Initiating system...
2024-11-25T02:51:08.279Z :report :duct.server.http.jetty/starting-server {:port 3000}

The web application should now be up and running at: http://localhost:3000/

Visiting that URL will result in a ‘404 Not Found’ error page, because we have no routes defined. The error page will be in plaintext, because we haven’t specified what features we want for our web application.

We’ll fix both these issues, but before we do we should terminate the application with Ctrl-C and start a REPL. We’ll keep this running while we develop the application to avoid costly restarts and to give us a way of querying the running system.

$ duct --repl
✓ Loading REPL environment...
• Type :repl/help for REPL help, (go) to initiate the system and (reset)
  to reload modified namespaces and restart the system (hotkey Alt-E).
user=> (go)
:duct.server.http.jetty/starting-server {:port 3000}
:initiated

Clojure has many excellent libraries for writing web applications, but it can be difficult to put them all together. Duct’s web module handles that for you, but like all modules, we can always override any default that we don’t like.

For now, we’ll tell the web module to configure the application for use as a webside, using the :site feature. We’ll also add in a single route to handle a web request to the root of our application.

duct.edn
{:system
 {:duct.module/logging {}
  :duct.module/web
  {:features #{:site}
   :routes [["/" {:get :todo.routes/index}]]}}}

Then we’ll create a Ring handler function for that route.

src/todo/routes.clj
(ns todo.routes)

(defn index [_options]
  (fn [_request]
    [:html {:lang "en"}
     [:head [:title "Hello World Wide Web"]]
     [:body [:h1 "Hello World Wide Web"]]]))

Finally, we trigger a (reset) at the REPL.

user=> (reset)
:reloading (todo.routes)
:resumed

Now when we go access http://localhost:3000/ we find a HTML page instead. Congratulations on your first Duct web application!

3.2. Routes

In the previous section we set up a route and a handler function, but you may rightly wonder how the route finds the function.

In the Fundamentals section we learned that key/value pairs in the Duct configuration have definitions in the application’s source files, or from a library.

The function we defined was called todo.routes/index, and therefore we might assume that we’d have a matching key in the configuration.

{:todo.routes/index {}}

This component key could then be connected to the routes via a ref. In other words:

{:duct.module/web {:routes [["/" {:get #ig/ref :todo.routes/index}]]}
 :todo.routes/index {}}

And in fact, this is almost exactly what is going on behind the scenes.

The Duct web module expands out to a great number of components, including a web server, middleware and error handlers, all which can be customized. Amongst these components, it creates a router and a number of route handlers.

A web module configured the following routes:

{:duct.module/web {:routes [["/" {:get :todo.routes/index}]]}}

Will expand out to:

{:duct.router/reitit {:routes [["/" {:get #ig/ref :todo.routes/index}]]}
 :todo.routes/index {}}

The router component uses Reitit, a popular data-driven routing library for Clojure. Other routing libreries can be used, but for this documentation we’ll use the default.

3.3. Handlers

Let’s take a closer look at function associated with the route.

src/todo/routes.clj
(ns todo.routes)

(defn index [_options]
  (fn [_request]
    [:html {:lang "en"}
     [:head [:title "Hello World Wide Web"]]
     [:body [:h1 "Hello World Wide Web"]]]))

This function returns another function, known as a Ring handler. Usually this function will return a response map, but in this case we’re returning a Hiccup vector.

Hiccup is a format for representing HTML as a Clojure data structure. Elements are represented by a vector starting with a keyword, followed by an optional attribute map and then the element body.

The :site feature of the web module adds middleware to turn Hiccup vectors into HTML response maps. If the response is a vector, it wraps the vector in response map. If the response is already a map, it checks the :body of the response for a vector.

If we wanted a custom status code or headers, then the full response map could be returned.

(defn index [_options]
  (fn [_request]
    {:status 200
     :headers {}
     :body [:html {:lang "en"}
            [:head [:title "Hello World Wide Web"]]
            [:body [:h1 "Hello World Wide Web"]]]}))
Note
The :status and :headers keys map optionally be omitted.

Or we could return the string directly:

(defn index [_options]
  (fn [_request]
    {:status 200
     :headers {"Content-Type" "text/html;charset=UTF-8"}
     :body "<!DOCTYPE html>
<html lang=\"en\">
<head><title>Hello World Wide Web</title></head>
<body><h1>Hello World Wide Web</h1></body>
</html>"}))

All of these examples are equivalent, but returning a vector is the most convenient and concise.

3.4. Middleware

Ring middleware are functions that transform Ring handlers. These are often used to parse information from the request map, such as encoded parameters or session data, or to transform the response map, by adding headers or formatting the response body.

In the previous section we saw how a Hiccup data structure could be directly attached to the response body. This is possible because Duct adds default middleware to look for Hiccup and format it into HTML.

Let’s create some middleware that will add a map of custom headers to every response:

src/todo/middleware.clj
(ns todo.middleware)

(defn wrap-headers [headers]
  (fn [handler]
    (fn [request]
      (let [response (handler request)]
        (update response :headers merge headers)))))

Once we’ve created the middleware function, we can give it to the web module via the :middleware key:

duct.edn
{:system
 {:duct.module/logging {}
  :duct.module/web
  {:features #{:site}
   :middleware [#ig/ref :todo.middleware/wrap-headers]
   :routes [["/" {:get :todo.routes/index}]]}

  :todo.middleware/wrap-headers {"X-Powered-By" "Duct"}}}

We add a new key, :todo.middleware/wrap-headers, which configures and creates the middleware function, then we use an Integrant ref to add that function to a vector of middleware.

There three ways to apply middleware:

  • Middleware is applied to all requests (via :middleware)

  • Middleware is applied if any route matches (via :route-middleware)

  • Middleware is applied if a specific route matches (via :middleware attached to individual routes)

The previous example demonstrated how to apply middleware to all requests. However, sometimes you only want middleware to apply if at least one route matches. For example:

duct.edn
{:system
 {:duct.module/logging {}
  :duct.module/web
  {:features #{:site}
   :route-middleware [#ig/ref :todo.middleware/wrap-headers]
   :routes [["/" {:get :todo.routes/index}]]}

  :todo.middleware/wrap-headers {"X-Route-Matches" "True"}}}

This will add the extra header only if the route matches. It won’t be added to the default 404 response that is returned when all routes fail to match.

Finally, you can attach middleware to specific routes, or groups of nested routes by adding the :middleware key to the route itself:

duct.edn
{:system
 {:duct.module/logging {}
  :duct.module/web
  {:features #{:site}
   :routes [["/" {:get :todo.routes/index
                  :middleware [#ig/ref :todo.middleware/wrap-headers]}]]}

  :todo.middleware/wrap-headers {"X-Index-Route" "True"}}}

The web module adds a lot of its own middleware, depending on the :features you choose. Often this is enough, and so we’ll remove the custom middleware for now; it won’t be needed for the rest of this document.

duct.edn
{:system
 {:duct.module/logging {}
  :duct.module/web
  {:features #{:site}
   :routes [["/" {:get :todo.routes/index}]]}}}

3.5. SQL Database

The next step is to add a database to our application. We’ll use SQLite, which means we need the corresponding JDBC adapter as a dependency.

To give us a Clojure-friendly way of querying the database, we’ll also add a dependency on next.jdbc.

Finally, we’ll add the Duct SQL module. This will add a connection pool to the system that we can use to access the database.

Our project dependencies should now look like this:

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/main {:mvn/version "0.2.1"}
        org.duct-framework/module.logging {:mvn/version "0.6.5"}
        org.duct-framework/module.web {:mvn/version "0.12.11"}
        org.duct-framework/module.sql {:mvn/version "0.8.0"}
        org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
        com.github.seancorfield/next.jdbc {:mvn/version "1.3.1048"}}
 :aliases {:duct {:main-opts ["-m" "duct.main"]}}}

We can load these new dependencies either by restarting the REPL, or by using the sync-deps function.

user=> (sync-deps)
[...]

The next step is to add :duct.module/sql to our Duct configuration.

duct.edn
{:system
 {:duct.module/logging {}
  :duct.module/sql {}
  :duct.module/web
  {:features #{:site}
   :routes [["/" {:get :todo.routes/index}]]}}}

Then reset via the REPL:

user=> (reset)
:reloading ()
Execution error (ExceptionInfo) at integrant.core/unbound-vars-exception (core.cljc:343).
Unbound vars: jdbc-url

Wait, what’s this about an unbound var? Where did that come from?

Modules can add vars, and the SQL module adds one called jdbc-url. This var can be set via:

  • A command-line argument, --jdbc-url

  • An environment variable, JDBC_DATABASE_URL

We can also set a default value for this var via the configuration. As SQLite uses a local file for its database, we can add a default to be used in development.

duct.edn
{:vars {jdbc-url {:default "jdbc:sqlite:todo.db"}}
 :system
 {:duct.module/logging {}
  :duct.module/sql {}
  :duct.module/web
  {:features #{:site}
   :routes [["/" {:get :todo.routes/index}]]}}}

If we want to change this in production, we can use the corresponding command-line argument or environment variable to override this default.

user=> (reset)
:reloading ()
:user/added (db sql)
:resumed
Note
The :user/added message informs you about convenience functions that have been added to the REPL environment in the user namespace.

The SQL module adds a database connection pool under the key :duct.database.sql/hikaricp, which derives from the more general :duct.database/sql key. We can use this connection pool as a javax.sql.DataSource instance.

In order to give our route handlers access to this, we’ll use a ref. We could manually add the ref to each of the handler’s option map, as shown below.

{:todo.routes/index {:db #ig/ref :duct.database/sql}

This is useful if only some routes need to access the database. However, in this case, we expect that all routes will need database access in some fashion. To make this easier, the web module has an option, :handler-opts that applies common options to all route handlers it generates.

duct.edn
{:vars {jdbc-url {:default "jdbc:sqlite:todo.db"}}
 :system
 {:duct.module/logging {}
  :duct.module/sql {}
  :duct.module/web
  {:features #{:site}
   :handler-opts {:db #ig/ref :duct.database/sql}
   :routes [["/" {:get :todo.routes/index}]]}}}

This will add the DataSource instance to the :db key of the component options. We can access this from the route handler function we created earlier.

src/todo/routes.clj
(ns todo.routes)

(defn index [{:keys [db]}]
  (fn [_request]
    [:html {:lang "en"}
     [:head [:title "Hello World Wide Web"]]
     [:body [:h1 "Hello World Wide Web"]]]))

Before we go further, however, we should set up the database schema via a migration.

3.6. SQL Migrations

Part of the SQL module is to add a migrator, a component that will manage database migrations. By default the Ragtime library is used, and looks for a migrations.edn file in your project directory.

Let’s create a migration for a table to store the todo list items.

migrations.edn
[[:create-table todo
  [id "INTEGER PRIMARY KEY"]
  [description "TEXT"]
  [checked "INTEGER DEFAULT 0"]]]

When we reset the REPL, the migration is automatically applied.

user=> (reset)
:reloading (todo.routes)
:duct.migrator.ragtime/applying {:id "create-table-todo#336f15d4"}
:resumed

If the migration is modified in any way, its ID will also change. At the REPL, this will result in the old version of the migration being rolled back, and the new version applied in its place.

Running the application via --main will also apply any new migrations to the database. However, if there is any mismatch between migrations, an error will be raised instead.

This difference reflects the environments that --main and --repl are anticipated to be used in. During development a REPL is used and mistakes are expected, so the migrator will work to sync the migrations with the database. During production migrations need to be applied with more care, and so any discrepancies should halt the migration process.

In some production environments, there may be multiple instances of the application running at any one time. In these cases, you may want to run the migrations separately. The --keys option allows you to limit the system to a subset of keys. We can use this option to run only the migrations and logging subsystems.

$ duct --main --keys=:duct/migrator:duct/logger

This will run any component with a key that derives from :duct/migrator or :duct/logger, along with any mandatory dependants.

Note
:duct/logger is often defined as an optional dependency, via a refset. Without explicitly specifying this as one of the keys, the migrator will run without logging.

3.7. Database Integration

Now that we have a database table and a web server, it’s time to put the two together. The database we pass to the index function can be used to populate an unordered list. We’ll change the index function accordingly.

src/todo/routes.clj
(ns todo.routes
  (:require [next.jdbc :as jdbc]))

(def list-todos "SELECT * FROM todo")

(defn index [{:keys [db]}]
  (fn [_request]
    [:html {:lang "en"}
     [:head [:title "Todo"]]
     [:body
      [:ul (for [rs (jdbc/execute! db [list-todos])]
             [:li (:todo/description rs)])]]]))
Tip
It’s often a good idea to factor out each SQL string into its own var. This allows them to be treated almost like function calls when combined with execute!.

We can reset via the REPL and add some test data with the sql convenience function.

user=> (reset)
:reloading (todo.routes)
:resumed
user=> (sql "INSERT INTO todo (description) VALUES ('Test One')")
[#:next.jdbc{:update-count 1}]
user=> (sql "INSERT INTO todo (description) VALUES ('Test Two')")
[#:next.jdbc{:update-count 1}]

If you visit http://localhost:3000/ you’ll be able to see the todo items that were added to the database table.

The next step is to allow for new todo items to be added through the web interface. This is a little more involved, as we’ll need a HTML form and a route to respond to the form’s POST.

First, we add a new handler, new-todo, to the configuration to handle the POST.

duct.edn
{:vars {jdbc-url {:default "jdbc:sqlite:todo.db"}}
 :system
 {:duct.module/logging {}
  :duct.module/sql {}
  :duct.module/web
  {:features #{:site}
   :handler-opts {:db #ig/ref :duct.database/sql}
   :routes [["/" {:get  :todo.routes/index
                  :post :todo.routes/new-todo}]]}}}

Then we need incorporate the POST handler and the form into the codebase.

src/todo/routes.clj
(ns todo.routes
  (:require [next.jdbc :as jdbc]
            [ring.middleware.anti-forgery :as af]))

(def list-todos "SELECT * FROM todo")
(def insert-todo "INSERT INTO todo (description) VALUES (?)")

(defn- create-todo-form []
  [:form {:action "/" :method "post"}
   [:input {:type "hidden"
            :name "__anti-forgery-token"
            :value af/*anti-forgery-token*}]
   [:input {:type "text", :name "description"}]
   [:input {:type "submit", :value "Create"}]])

(defn index [{:keys [db]}]
  (fn [_request]
    [:html {:lang "en"}
     [:head [:title "Todo"]]
     [:body
      [:ul
       (for [rs (jdbc/execute! db [list-todos])]
         [:li (:todo/description rs)])
       [:li (create-todo-form)]]]]))

(defn new-todo [{:keys [db]}]
  (fn [{{:keys [description]} :params}]
    (jdbc/execute! db [insert-todo description])
    {:status 303, :headers {"Location" "/"}}))

There are two new additions here. The create-todo-form function creates a form for making new todo list items. You may notice that it includes a hidden field for setting an anti-forgery token. This prevents a type of attack known as a Cross-site request forgery.

The second addition is the new-todo function. This inserts a new row into the todo table, then returns a “303 See Other” response that will redirect the browser back to the index page.

If you reset via the REPL and check http://localhost:3000/, you should see a text input box at the bottom of the todo list, allowing more todo items to be added.

3.8. ClojureScript

At this point we’re hitting the limitations of what we can do with HTML alone. JavaScript allows for more sophisticated user interaction, and in the Clojure ecosystem we have ClojureScript, a version of Clojure that compiles to JavaScript.

You’ll be unsurprised to learn that Duct has a module for compiling ClojureScript. As always we begin with our dependencies, and add the ‘cljs’ module.

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/main {:mvn/version "0.2.1"}
        org.duct-framework/module.cljs {:mvn/version "0.5.1"}
        org.duct-framework/module.logging {:mvn/version "0.6.5"}
        org.duct-framework/module.web {:mvn/version "0.12.11"}
        org.duct-framework/module.sql {:mvn/version "0.8.0"}
        org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
        com.github.seancorfield/next.jdbc {:mvn/version "1.3.1048"}}
 :aliases {:duct {:main-opts ["-m" "duct.main"]}}}

As before, we can load these dependencies by either restarting the REPL, or by using the (sync-deps) command.

Next, the :duct.module/cljs key needs to be added to the Duct configuration file.

duct.edn
{:vars {jdbc-url {:default "jdbc:sqlite:todo.db"}}
 :system
 {:duct.module/logging {}
  :duct.module/sql {}
  :duct.module/cljs
  {:builds {:client todo.client}}
  :duct.module/web
  {:features #{:site}
   :handler-opts {:db #ig/ref :duct.database/sql}
   :routes [["/" {:get  :todo.routes/index
                  :post :todo.routes/new-todo}]]}}}

The module requires a :builds option to be set. This connects a build name to a ClojureScript namespace, or collection of namespaces. In the above example, the todo.client namespace will be compiled to the target/cljs/client.js JavaScript file. When Duct is started, this will be accessible at: http://localhost:3000/cljs/client.js.

Before todo.client can be compiled, we first need to write it. In order to check everything works, we’ll have it trigger an JavaScript alert.

src/todo/client.cljs
(ns todo.client)

(js/alert "Hello World")

In order to test this script compiles correct, we’ll add the script to our index function in the todo.routes namespace.

(defn index [{:keys [db]}]
  (fn [_request]
    [:html {:lang "en"}
     [:head
      [:title "Todo"]
      [:script {:src "/cljs/client.js"}]]
     [:body
      [:ul
       (for [rs (jdbc/execute! db [list-todos])]
         [:li (:todo/description rs)])
       [:li (create-todo-form)]]]]))

If you restart the REPL and check http://localhost:3000, you should see the alert.

3.9. Single Page Apps

At this point we have all the tools we need to write a web application. We can write routes that return HTML, and we write ClojureScript to augment those roots.

However, there is a common alternative to this ‘traditional’ architecture. We instead serve up a single, static HTML page, and create the UI dynamically with ClojureScript. Communication to the server will be handled by a RESTful API.

In order to demonstrate this type of web application, we’ll pivot and redesign what we have so far. First, we require a static index file. By default this should be placed in the static subdirectory.

static/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>Todo</title>
  </head>
  <body>
    <div id="todos"></div>
    <script src="/cljs/client.js"></script>
  </body>
</html>

We then need to change the routes and add the :api feature to the web module.

duct.edn
{:vars {jdbc-url {:default "jdbc:sqlite:todo.db"}}
 :system
 {:duct.module/logging {}
  :duct.module/sql {}
  :duct.module/cljs {:builds {:client todo.client}}
  :duct.module/web
  {:features #{:site :api}
   :handler-opts {:db #ig/ref :duct.database/sql}
   :routes [["/todos"
             {:get  :todo.routes/list-todos
              :post {:parameters {:body {:description :string}}
                     :handler    :todo.routes/create-todo}}]
            ["/todos/:id"
             {:parameters {:path {:id :int}}
              :delete :todo.routes/remove-todo}]]}}}

There are now have three RESTful API routes:

  • GET /todos

  • POST /todos

  • DELETE /todos/:id

By default, these will expect either JSON or edn, depending on the type of the Content-Type and Accept headers.

The next step is to rewrite the handler functions for these routes. Instead of returning HTML, we’ll return data that will be translated into the user’s preferred format.

src/todo/routes.clj
(ns todo.routes
  (:require [next.jdbc :as jdbc]))

(def select-all-todos "SELECT * FROM todo")
(def insert-todo "INSERT INTO todo (description) VALUES (?)")
(def delete-todo "DELETE FROM todo WHERE id = ?")

(defn list-todos [{:keys [db]}]
  (fn [_request]
    {:body {:results (jdbc/execute! db [select-all-todos])}}))

(defn create-todo [{:keys [db]}]
  (fn [{{{:keys [description]} :body} :parameters}]
    (let [id (val (first (jdbc/execute-one! db [insert-todo description]
                                            {:return-keys true})))]
      {:status 201, :headers {"Location" (str "/todos/" id)}})))

(defn remove-todo [{:keys [db]}]
  (fn [{{{:keys [id]} :path} :parameters}]
    (let [result (jdbc/execute-one! db [delete-todo id])]
      (if (pos? (::jdbc/update-count result))
        {:status 204}
        {:status 404, :body {:error :not-found}}))))

There are three functions for each of the three routes. The list-todos function returns a map as its body. If JSON is requested, the resulting response body will look like something like this:

{
    "results": [
        {
            "todo/checked": 0,
            "todo/description": "Test One",
            "todo/id": 1
        },
        {
            "todo/checked": 0,
            "todo/description": "Test Two",
            "todo/id": 2
        }
    ]
}

The create-todo function creates a new todo item given a description, and the remove-todo function deletes a todo item. In a full RESTful application we’d have more verbs per route, but as this is just an example we’ll limit the application to the bare minimum.

The next step is to create the client code. For this we’ll use Replicant for updating the DOM, and Duct client.http for communicating with the server API.

This requires us to once again update the project dependencies:

deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.1"}
        org.duct-framework/client.http {:mvn/version "0.1.0"}
        org.duct-framework/main {:mvn/version "0.2.1"}
        org.duct-framework/module.cljs {:mvn/version "0.5.1"}
        org.duct-framework/module.logging {:mvn/version "0.6.5"}
        org.duct-framework/module.web {:mvn/version "0.12.11"}
        org.duct-framework/module.sql {:mvn/version "0.8.0"}
        org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
        com.github.seancorfield/next.jdbc {:mvn/version "1.3.1048"}
        no.cjohansen/replicant {:mvn/version "2025.06.21"}}
 :aliases {:duct {:main-opts ["-m" "duct.main"]}}}

Once we’ve run sync-deps in the REPL, we can create a ClojureScript file for the client UI.

src/todo/client.cljs
(ns todo.client
  (:require [replicant.dom :as r]
            [duct.client.http :as http]
            [clojure.core.async :as a :refer [<!]]))

(defonce store (atom {}))

(defn update-todos []
  (a/go (let [resp (<! (http/get [:todos]))]
          (swap! store assoc :todos (-> resp :body :results)))))

(defn delete-todo [id]
  (a/go (<! (http/delete [:todos id]))
        (<! (update-todos))))

(defn create-todo []
  (a/go (let [input (js/document.getElementById "todo-desc")]
          (<! (http/post [:todos] {:description (.-value input)}))
          (<! (update-todos))
          (set! (.-value input) ""))))

(defn- create-todo-form []
  [:div.create-todo
   [:input#todo-desc {:type "text"}]
   [:button {:on {:click create-todo}} "Create"]])

(defn todo-list [{:keys [todos]}]
  [:ul
   (for [{:todo/keys [id description]} todos]
     [:li {:replicant/key id}
      [:span description] " "
      [:a {:href "#" :on {:click #(delete-todo id)}} "delete"]])
   [:li (create-todo-form)]])

(defonce todos
  (js/document.getElementById "todos"))

(add-watch store ::render (fn [_ _ _ s] (r/render todos (todo-list s))))
(update-todos)

Here we reach the edge of Duct. This ClojureScript file is not specific to our framework, but would be at home in any Clojure project. Nevertheless, for the sake of completeness we’ll provide some explanation of what this file does.

The get, post and delete functions from the Duct HTTP client simplify communication with the server. They communicate using the Transit serialization format, and automatically add headers to get around the webservers CSRF protection.

The update-todos, delete-todo and create-todo functions all update the store atom, which contains a data structure that represents the state of the UI. In this case, it’s a list of todo items.

There is a watch attached to the store atom. When the store is changed, the todos DOM element is updated accordingly, with a new unordered list of todo items. Replicant is smart enough to update only the elements that have changed, making updates efficient.

Warning
In the example code, the ‘click’ event is bound to a function. This is not considered best practice for Replicant, but is used in this example for the sake of brevity.

Now that we have both a server and client, we can (reset) the REPL and check the web application at: http://localhost:3000

4. Existing Applications

So far we have worked on the assumption that you are building a Duct application from scratch, but what if you have an existing application? Can Duct provide any benefit in that case?

If you use Integrant, you can use parts of Duct. This section will cover common use-cases.

4.1. Integrant Runner

A common pattern for using Integrant have a -main function that loads and initiates an Integrant configuration. In many cases, you can use Duct to replace this with a data-driven approach.

For example, suppose you’ve written an application with a custom server and worker queue component. You may have an Integrant configuration file that looks like this:

resources/example/app/config.edn
{:example.app/server
 {:queue #ig/ref :example.app/worker-queue}

 :example.app/worker-queue
 {:worker-threads 32}}

In order to run this configuration, you have a main function that loads in the config file and populates the it with additional values from the environment. In the example below, the server port number is pulled from the PORT environment variable.

src/example/app/main.clj
(ns example.app.main
  (:require [clojure.java.io :as io]
            [integrant.core :as ig]))

(defn -main [& _args]
  (let [port (some-> (System/getenv "PORT")
                     (Integer/parseInt)
                     (or 3000))
        config (-> (io/resource "example/app/config.edn")
                   (slurp)
                   (ig/read-string)
                   (assoc-in [:example.app/server :port] port))]
    (ig/load-namespaces config)
    (ig/init config)))

Duct can be used to replace all this with a data-driven configuration. In your deps.edn file, add a Duct alias:

deps.edn
{:aliases
 {:duct {:extra-deps {org.duct-framework/main {:mvn/version "0.2.1"}}
         :main-opts ["-m" "duct.main"]}
 ;; rest of your deps.edn
 }}

Then move your configuration into duct.edn under the :system key. Use the :vars key to define the options you want to pull from the environment or from command-line options.

duct.edn
{:vars
 {port {:env PORT, :type :int, :default 3000}
 :system
 {:example.app/server
  {:port  #ig/var port
   :queue #ig/ref :example.app/worker-queue}

  :example.app/worker-queue
  {:worker-threads 32}}}

To run your application, use clojure -M:duct, or the duct alias defined in the Project Setup section. If your project uses Leiningen rather than tools.deps, check out the section on Leiningen integration.

4.2. Piecemeal Components

You may be using Integrant in such a way that Duct is not suitable for your project. However, you may be able to still take advantage of Duct’s libraries.

Many of the libraries Duct provides can be used independently as part of any Integrant configuration. Here are three of the most useful outside of Duct:

5. Integrations

5.1. Babashka

Babashka is a fast, native Clojure scripting runtime, and it includes a task runner that can be configured with a bb.edn file.

Duct can set up a basic bb.edn file for you with the --init-bb option.

$ duct --init-bb
Created bb.edn

This sets up three tasks: main, test and repl that correspond to running --main, --test and --repl.

$ bb test
✓ Loading test environment
[(..)]
2 tests, 2 assertions, 0 failures.

One advantage of using a task runner like Babashka is that you can specify different dependencies for task. Another advantage is that a new developer doesn’t have to set up a duct alias as long as they have Babashka installed.

5.2. Docker

Docker is a system for running software in containers — isolated and virtual environments designed to run an application.

To create a Docker container for your Duct application, you will need a Dockerfile that describes how to build it. Duct will set one up for you with the --init-docker option.

$ duct --init-docker
Created Dockerfile

To build the container, run:

$ docker build . -t <container-name>

This will create a container with all the dependencies downloaded. It will also handle any compilation from keys deriving from :duct/compiler. This means that your ClojureScript will be compiled, if you’re using the ClojureScript module.

To run the container:

$ docker run -p 3000:3000 <container-name>

This will start the Duct application and bind the container port 3000 to the host machine’s port 3000 so you can access your application at: http://localhost:3000

This container is configured to only run keys deriving from :duct/daemon (and those it references). This includes keys like :duct.server.http/jetty provided by the web module. This will exclude migrations in order avoid multiple containers behind a load balancer all trying to update the database at once.

In order to run the migrations, you’ll need to run Duct in your deployment environment with only the :duct/migrator keys. This should be part of your deployment scripts and run once each time you deploy.

clojure -M:duct -mvk :duct/migrator

5.3. Emacs

Emacs is a popular editor for Clojure. CIDER extends Emacs with support for interactive programming in Clojure.

To use Emacs/CIDER with Duct, use the --init-cider option in your project directory.

$ duct --init-cider
Created .dir-locals.el

This creates a hidden file, .dir-locals.el, that sets up CIDER with Duct-specific options. To connect to the project using CIDER, open a file in the project (such as duct.edn) and type: M-x clojure-jack-in RET

Tip
The ‘M’ in M-x means ‘meta’ and is often bound to the Alt key. ‘C’ usually means Ctrl.

Once CIDER has connected, you can open a REPL with: C-c C-z

This works in a similar way to the command-line REPL. To start up Duct, you can use the (go) command:

user> (go)

To reset the project, you can use (reset) at the REPL, or type: M-x cider-ns-refresh RET

There’s also a key binding for this command: C-c M-n r

5.4. Hashp

Hashp is a small debugging library that is integrated by default in Duct.

To use it, you can put #p in front of any expression, and it will print the expression, its value, and its location in your project.

For example, at the REPL:

user=> (* 2 #p (+ 1 1))
#p[user/eval11138:1] (+ 1 1) => 2
4

However, under --main nothing will be printed. In fact, #p will be completely ignored at compile time and incur no additional performance cost. This is because --main is intended for production use, and --repl for development and debugging.

5.5. Leiningen

It’s recommended that you use Duct with deps.edn, however it is possible to use Duct with Leiningen.

To do so, you’ll need to update your project file with profile for Duct, and an alias to run it:

project
(defproject org.example/app "0.1.0-SNAPSHOT"
  ;; the rest of your project file goes here
  :aliases  {"duct" ["trampoline" "with-profile" "+duct" "run"]}
  :profiles {:duct {:dependencies [[org.duct-framework/main "0.1.11"]]
                    :main duct.main}})

Now you can run Duct with:

lein duct

Note that the startup time suffers somewhat. In local tests using Leiningen increased startup time from 900ms to 1400ms.

5.6. Visual Studio Code

Visual Studio Code is a popular modern editor, and the Calva plugin gives it full Clojure support.

To setup Calva for Duct, use the --init-calva option in your project directory.

$ duct --init-calva
Created .vscode/settings.json

This creates a project settings file for VS Code. To connect to the project, you can either:

  • Click the ‘REPL’ button at the bottom of the window.

  • Run the Calva: Start a Project REPL and Connect command.

  • Use the keyboard shortcut: Ctrl-Alt-C Ctrl-Alt-J

Then choose the ‘Duct’ project type.

Tip
Use Ctrl-Shift-P to open the command palette.

You’ll be presented with a REPL where you can start the application with (go)

clj꞉user꞉> (go)

To reset the project, you can use (reset) at the REPL, or run the command: Calva: Refresh All Namespaces.