The Duct Framework

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

We’ll begin by 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.3.1577

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.

A minimal deps.edn file for Duct can be downloaded using curl.

$ curl -O duct.now/deps.edn

This creates a deps.edn file with a minimal set of dependencies and aliases:

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

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

$ clojure -M:duct
Usage:
	clojure -M:duct [--main | --nrepl | --repl | --setup KEYS | --test]
Options:
  -c, --cider                  Add CIDER middleware (used with --nrepl)
  -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
      --setup KEYS             Run setup scripts (see --setup help)
  -s, --show                   Print out the expanded configuration and exit
  -t, --test                   Run the test suite
      --test-config FILE       Use a custom test config file
      --test-focus ID      []  Limit tests to only this ID or metadata ^:key
      --test-skip ID       []  Skip tests with this ID or metadata ^:key
  -v, --verbose                Enable verbose logging
  -h, --help                   Print this help message and exit

We’ll be using this command a lot, so it’s recommended that you also create either a shell script or shell alias. In a POSIX shell such as Bash, a shell alias can be created 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.

Note
Alternatively, if you have Babashka installed, you can take advantage of its inbuilt task runner to run Duct. The section on Babashka Integration explains how.

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 --setup :duct
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.3"}
        org.duct-framework/main {:mvn/version "0.4.4"}
        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, symbol or metadata keyword

--test-skip ID

skip tests with the test ID, namespace, symbol or metadata keyword

For example, if you wanted to run tests with the :unit metadata key, but exclude the example.long-tests namespace:

$ duct --test --test-focus ^:unit --test-skip example.long-tests

The --test-focus and --test-skip options may be specified multiple times.

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.3"}
        org.duct-framework/main {:mvn/version "0.4.4"}}
 :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 exta shell alias. For example, ductt with an extra ‘t’ for ‘test’.

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

Alternatively, you could use a task runner like Babashka.

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.3"}
        org.duct-framework/main {:mvn/version "0.4.4"}
        org.duct-framework/test {:mvn/version "0.1.0"}
        org.duct-framework/module.logging {:mvn/version "0.6.6"}}
 :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 [{:brief? true, :levels #{:report}, :type :stdout}
              {:path "logs/repl.log", :type :file}]}
 :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"}
Tip
At the Duct REPL you can use the doc macro to get information about keywords annotated by Integrant. For example, evaluated the expression (doc :duct.module/logging) will describe the logging module.

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.3"}
        org.duct-framework/main {:mvn/version "0.4.4"}
        org.duct-framework/module.logging {:mvn/version "0.6.6"}
        org.duct-framework/module.web {:mvn/version "0.13.4"}}
 :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, and to use the Hiccup format for representing HTML, using the :hiccup 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 :hiccup}
   :routes [["/" {:get :todo.routes/index}]]}}}

Then we’ll create a Ring handler function for that route that returns a Hiccup data structure.

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

(defn index [_options]
  (fn [_request]
    {:body [: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]
    {:body [: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 full response map, but in this case we’re omitting all but the :body, which contains 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 :hiccup feature of the web module adds middleware to turn Hiccup vectors into HTML response maps. If the response body is a vector, it coverts it to a string of HTML. The missing :status and :headers keys that are usually present in the response map are given default values.

The next example is equivalent, with the default values set explicitly.

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

Which in turn is also equivalent to:

(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>"}))

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 :hiccup}
   :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 :hiccup}
   :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 :hiccup}
   :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 :hiccup}
   :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.3"}
        org.duct-framework/main {:mvn/version "0.4.4"}
        org.duct-framework/module.logging {:mvn/version "0.6.6"}
        org.duct-framework/module.web {:mvn/version "0.13.4"}
        org.duct-framework/module.sql {:mvn/version "0.9.0"}
        org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
        com.github.seancorfield/next.jdbc {:mvn/version "1.3.1070"}}
 :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 :hiccup}
   :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 :hiccup}
   :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 :hiccup}
   :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]
    {:body [: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

One of the things the SQL module adds is a migrator, a component that will manage database migrations. By default the Ragtime migration library is used.

The migrations are supplied via the :migrations key on the module. As there may be many migrations, it’s worth separating these out into their own file via the #duct/include tag.

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

In the above case, we’ve separated out the migrations into the migrations.edn file. Let’s add a migration to this file that will create 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]
    {:body [: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
  {:migrations #duct/include "migrations.edn"}
  :duct.module/web
  {:features #{:site :hiccup}
   :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]
    {:body [: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.3"}
        org.duct-framework/main {:mvn/version "0.4.4"}
        org.duct-framework/module.cljs {:mvn/version "0.5.2"}
        org.duct-framework/module.logging {:mvn/version "0.6.6"}
        org.duct-framework/module.web {:mvn/version "0.13.4"}
        org.duct-framework/module.sql {:mvn/version "0.9.0"}
        org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
        com.github.seancorfield/next.jdbc {:mvn/version "1.3.1070"}}
 :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
  {:migrations #duct/include "migrations.edn"}
  :duct.module/cljs
  {:builds {:client todo.client}}
  :duct.module/web
  {:features #{:site :hiccup}
   :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]
    {:body [: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 the module’s features. The :hiccup feature is no longer needed as we’ll be handling all that from the client itself, and we’ll add the :api feature to allow the client to communicate with the server.

duct.edn
{:vars {jdbc-url {:default "jdbc:sqlite:todo.db"}}
 :system
 {:duct.module/logging {}
  :duct.module/sql
  {:migrations #duct/include "migrations.edn"}
  :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.3"}
        org.duct-framework/client.http {:mvn/version "0.1.0"}
        org.duct-framework/main {:mvn/version "0.4.4"}
        org.duct-framework/module.cljs {:mvn/version "0.5.2"}
        org.duct-framework/module.logging {:mvn/version "0.6.6"}
        org.duct-framework/module.web {:mvn/version "0.13.4"}
        org.duct-framework/module.sql {:mvn/version "0.9.0"}
        org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
        com.github.seancorfield/next.jdbc {:mvn/version "1.3.1070"}
        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

3.10. Deployment

During development we typically run every key int the system, but when we deploy, it’s often useful to run a subset.

We can specify which keys to run with the --keys option. This will run all the keys we specify, along with their descendent and dependent keys.

Note
A key is descendent on another if it is derived from that key via Clojure’s derive function. A key is dependent on another if it is linked via an Integrant ref or refset.

Duct defines a hierarchy of keys, with many descending from a small selection of ancestor keys:

:duct/compiler

compile static assets

:duct/migrator

migrate the database schema

:duct/daemon

run daemon process such as a server

When deploying, we need to initiate all three of these lineages.

Compilation typically takes place once on the build server. In the web application we’ve been building, this will compile ClojureScript into static JavaScript.

$ duct --main --keys :duct/compiler  # compiles static resources

Migration requires access to the database, and typically must be run by a single machine. If two machines attempt to migrate the database at the same time we’re likely to run into conflicts. Thus, as part of the deployment, a single machine should be designated to run the migration.

$ duct --main --keys :duct/migrator  # migrates the database

Finally we run the daemon processes of our application. This includes the web server, :duct.server.http/jetty, which is added as part of the web module.

$ duct --main --keys :duct/daemon    # runs the application

Daemons may be run on multiple machines, and may be started and stopped by the host environment at any time.

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.4.4"}}
         :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 some of the most useful outside of Duct:

5. Integrations

Duct makes an effort to integrate nicely with many existing tools, editors and libraries within the Clojure ecosystem.

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 --setup :bb.

$ duct --setup :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 each 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 --setup :docker.

$ duct --setup :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 --setup :cider in your project directory.

$ duct --setup :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. Git

Git is a version control system that needs no introduction.

The --setup :git command adds a .gitignore file suitable for Duct projects. It will also initiate an empty Git repository in the current directory if no repository already exists.

$ duct --setup :git
Created empty Git repository in .git
Created .gitignore

5.5. 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.6. 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.clj
(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.4.4"]]
                    :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.7. 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 --setup :calva in your project directory.

$ duct --setup :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.

6. Components

:duct.compiler.cljs.shadow/release

org.duct-framework/compiler.cljs.shadow {:mvn/version "0.1.4"}

When initiated, compiles ClojureScript files into production-ready JavaScript.

Takes a Shadow CLJS configuration map, with the :builds map swapped out for a singular build configuration under the :build key. See the Shadow CLJS documentation for more information.

:duct.compiler.cljs.shadow/server

org.duct-framework/compiler.cljs.shadow {:mvn/version "0.1.4"}

Starts a Shadow CLJS development server that re-compiles the project’s ClojureScript and pushes any changes to the browser whenever the system is reset.

Takes a Shadow CLJS configuration map, with the :builds map swapped out for a singular build configuration under the :build key. See the Shadow CLJS documentation for more information.

:duct.database/sql

org.duct-framework/database.sql {:mvn/version "0.4.1"}

Create a javax.sql.DataSource instance from a db-spec map. See the next.jdbc documentation for a full list of options, but most of the time the :jdbcUrl option is all that’s required.

:duct.database.sql/hikaricp

org.duct-framework/database.sql.hikaricp {:mvn/version "0.7.1"}

Create a pooled javax.sql.DataSource instance from a db-spec map. See the next.jdbc documentation for a full list of options, but most of the time the :jdbcUrl option is all that’s required. When the system is halted, the connection pool is cleaned up.

:duct.handler/file

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that serves static files from the local filesystem.

Takes the following options:

  • :paths - a map of path strings to middleware option maps

  • :not-found - a response map to be returned if no path matches

The paths connect a top-level path prefix to a set of options used by the file-response function in Ring. The most commonly used option is :root, which is a path to the directory that contains the files to be served.

If the :not-found option is not supplied, nil is returned from the handler.

:duct.handler/reitit

org.duct-framework/router.reitit {:mvn/version "0.5.2"}

Creates a Ring handler using the Reitit create-default-handler function.

Takes the following options:

  • :not-found - a handler for 404 HTTP errors

  • :method-not-allowed - a handler for 405 HTTP errors

  • :not-acceptable - a handler for 406 HTTP errors

:duct.handler/resource

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that serves static files from the JVM classpath.

Takes the following options:

  • :paths - a map of path strings to middleware option maps

  • :not-found - a response map to be returned if no path matches

The paths connect a top-level path prefix to a set of options used by the resource-response function in Ring. The most commonly used option is :root, which is a path prefix shared by the resources to be served.

If the :not-found option is not supplied, nil is returned from the handler when a resource is not found.

:duct.handler/static

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that always returns the same Ring response, which is defined by the value associated with this key.

:duct.handler.static/bad-request

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that always returns the same 400 Bad Request' Ring response, defined by the value associated with this key. The `:status is always 400.

:duct.handler.static/internal-server-error

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that always returns the same 500 Internal Error' Ring response, defined by the value associated with this key. The `:status is always 500.

:duct.handler.static/method-not-allowed

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that always returns the same 405 Method Not Allowed' Ring response, defined by the value associated with this key. The `:status is always 405.

:duct.handler.static/not-found

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that always returns the same 404 Not Found' Ring response, defined by the value associated with this key. The `:status is always 404.

:duct.handler.static/ok

org.duct-framework/handler {:mvn/version "0.1.4"}

Creates a Ring handler that always returns the same 200 OK' Ring response, defined by the value associated with this key. The `:status is always 200.

:duct.logger/simple

org.duct-framework/logger.simple {:mvn/version "0.4.7"}

A simple buffered logger that adheres to the duct.logger/Logger protocol.

Takes the following options:

  • :appenders - a collection of appender configurations

  • :buffer-size - the size of the logging ring buffer (default: 1024)

  • :polling-rate - the delay in ms between each poll (default: 5)

  • :poll-chunk-size - the max no. of logs to process each poll (default: 8)

  • :shutdown-delay - the delay in ms before shutting down (default: 100)

  • :shutdown-timeout - the time to wait in ms for shutdown (default: 1000)

Appender configurations are maps that have a :type option that can be one of:

  • :file - logs are appended to a file

  • :stdout - logs are sent to STDOUT

File appenders have the following options:

  • :levels - a set of log levels (or :all) to limit the appender to

  • :brief? - whether to omit timestamps and levels (defaults to false)

STDOUT appenders have the following options:

  • :levels - a set of log levels (or :all) to limit the appender to

  • :path - the path of the log file

:duct.middleware.web/defaults

org.duct-framework/module.web {:mvn/version "0.13.4"}

A collection of sensible Ring middleware defaults, using the Ring-Defaults library. Takes a Ring-Defaults configuration as its options map.

:duct.middleware.web/hiccup

org.duct-framework/module.web {:mvn/version "0.13.4"}

Ring middleware that looks for response bodies that contain Hiccup vectors, and renders them as a string of HTML5.

Takes the following options:

  • :hiccup-renderer - a function to render Hiccup (defaults to using hiccup2.core/html)

:duct.middleware.web/hide-errors

org.duct-framework/module.web {:mvn/version "0.13.4"}

Ring mddleware that hides any uncaught exceptions behind a 500 `Internal Error' response generated by an error handler. Intended for use in production when exception details need to be hidden.

Takes the following options:

  • :error-handler - the error handler used when there are exceptions

:duct.middleware.web/log-errors

org.duct-framework/module.web {:mvn/version "0.13.4"}

Ring middleware to log uncaught exceptions. Takes the following options:

  • :logger - a logger satisfying duct.logger/Logger

:duct.middleware.web/log-requests

org.duct-framework/module.web {:mvn/version "0.13.4"}

Ring middleware to log each request. Takes the following options:

  • :logger - a logger satisfying duct.logger/Logger

  • :level - the level to log requests at (default :info)

:duct.middleware.web/stacktrace

org.duct-framework/module.web {:mvn/version "0.13.4"}

Ring middleware that generates a stacktrace for uncaught exceptions. Intended for development use when seeing the stacktrace could be useful.

Takes the following options:

  • :color? - if true, use ANSI colors at the terminal (default false)

:duct.middleware.web/webjars

org.duct-framework/module.web {:mvn/version "0.13.4"}

Ring middleware to serve static resources from [WebJars][1]. Takes the following options:

  • :path - the path to serve the assets on (default ``/assets'')

:duct.migrator/ragtime

org.duct-framework/migrator.ragtime {:mvn/version "0.6.0"}

When initiated, runs migrations on a SQL database using the Ragtime migration library.

Takes the following options:

  • :database - a javax.sql.DataSource instance

  • :logger - a logger that satisfies duct.logger/Logger

  • :strategy - a Ragtime strategy: :apply-new, :raise-error or :rebase

  • :migrations - a vector of Ragtime SQL migrations

  • :migrations-table - the table to store applied migrations in (defaults to ragtime_migrations)

:duct.module/cljs

org.duct-framework/module.cljs {:mvn/version "0.5.2"}

A module that adds support for compiling ClojureScript for use in the browser.

Takes the following options:

  • :asset-path - the web server path where the compiled JavaScript can be accessed (defaults to /cljs)

  • :builds - a map of keywords to ClojureScript namespace symbols

  • :output-dir - the directory to put the compiled JavaScript in (defaults to target/cljs)

In the :repl profile the module will create a development server that will compile and push ClojureScript changes to the browser. In the :main and :test profiles, it will compile the ClojureScript once to production- ready JavaScript.

The :builds map determines where the compiled ClojureScript is placed. The keys determine the name of the asset (for example, :client will place the compiled JavaScript in client.js), while the values determine the ClojureScript namespaces to be places into the JavaScript asset.

:duct.module/logging

org.duct-framework/module.logging {:mvn/version "0.6.6"}

A Duct module that adds logging to the configuration using the :duct.logger/simple component. Takes no options, but uses a different logging setup depending on the active profile:

  • :main - write all logs in full to STDOUT

  • :repl - write all logs to logs/repl.log and :report level logs to STDOUT in brief (no timestamp)

  • :test - write all logs to logs/test.log

:duct.module/sql

org.duct-framework/module.sql {:mvn/version "0.9.0"}

A module that adds components for accessing SQL databases to the configuration.

Takes the following option:

In the :repl profile, migrations are run using the :rebase strategy (conflicts replace existing migrations), while in the :main profile the :raise-error strategy is used (conflicts throw exceptions).

At the REPL, two functions are added to the user namespace:

  • db - get a DataSource of the running system’s database

  • sql - run a SQL statement on the system’s database

A Duct jdbc-url variable is added to configure the JDBC database URL.

:duct.module/web

org.duct-framework/module.web {:mvn/version "0.13.4"}

A module that adds components for web applications to the configuration.

Takes the following options:

  • :features - a set of keywords that specify which features to add

  • :handler-opts - a map of options passed to all route handlers

  • :middleware - an ordered collection of middleware functions

  • :middleware-opts - a map of options passed to all middleware

  • :route-middleware - middleware that is only applied if a route matches

  • :routes - routing data passed to the Reitit router

The features of the module determine how the module should be configured.

  • :api - add middleware and configration for a RESTful web API

  • :hiccup - add middleware to automatically parse Hiccup responses

  • :site - add middleware and configuration for a user-facing web app

A Duct port variable is added to configure the port the HTTP server will run on (default 3000).

Keyword endpoints in the :routes will be converted into refs to handlers, and any missing handler will be added to the configuration.

:duct.repl/refers

org.duct-framework/repl.refers {:mvn/version "0.1.1"}

When initiated, aliases are added to the user namespace. When halted, these aliases are removed. This is useful for adding helper functions to the REPL, particularly from modules.

Takes a map of alias symbols to fully qualified symbols as its configuration.

:duct.router/reitit

org.duct-framework/router.reitit {:mvn/version "0.5.2"}

Creates a Ring handler using the Reitit routing library.

Takes the following options:

  • :routes - the Reitit routing data

  • :middleware - a vector of middleware to apply to the Ring handler

  • :data - data to add to every Reitit route

  • :handlers - a vector of handlers to fall back

The :data key takes a map and acts as it does in Reitit, except for the following keys:

  • :muuntaja - a map of Muuntaja options to be merged with the defaults

  • :coercion - one of: :malli, :schema or :spec

These keys will automatically add relevant middleware.

:duct.scheduler/simple

org.duct-framework/scheduler.simple {:mvn/version "0.2.1"}

A simple scheduler that runs jobs (zero-argument functions) at regular intervals.

Takes the following options:

  • :jobs - a collection of maps defining the jobs to run

A job map has three keys:

  • :delay (optional) - how long in seconds to delay before the first job

  • :interval - how long in seconds between the start of each job

  • :run - a zero-argument function run at each interval

:duct.server.http/jetty

org.duct-framework/server.http.jetty {:mvn/version "0.3.4"}

Starts a HTTP server using the Ring Jetty adapter. Takes the following options:

  • :handler - the Ring handler function to use

  • :logger - a logger satisfying duct.logger/Logger (optional)

  • :port - the port number to listen on

In addition, all of the options supported by the run-jetty function are also supported.

:duct.session-store/cookie

org.duct-framework/module.web {:mvn/version "0.13.4"}

A Ring session store based on the standard cookie-based store: ring.middleware.session.cookie/cookie-store. When this key is resumed, typically as part of a (reset), it will use the existing store if the options haven’t changed.

Takes the following options:

  • :key - a string containing 16 hex-encoded bytes generated from a secure random source, or nil if a random key should be generated (the default)