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 {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.
{: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.
{: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.
(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.
(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 {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).
(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 {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 {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.
{: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.
{: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.
(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.
{: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.
(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: |
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.
(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.
{: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:
(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:
(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!.
(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.
{:vars #duct/include "vars.edn"
:system
{:duct.module/logging {}
:tutorial.print/hello #duct.include "hello.edn"}}
{name {:arg name, :env NAME, :type :str, :default "World"
:doc "The name of the person to greet"}}
{: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 {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.
{: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.
{: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.
(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.
(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:
(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:
{: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
:middlewareattached 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:
{: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:
{: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.
{: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 {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.
{: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.
{: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.
{: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.
(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.
{: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.
[[: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.
(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.
{: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.
(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 {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.
{: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.
(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.
<!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.
{: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.
(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 {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.
(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:
{: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.
(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:
{: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.
{: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:
-
database.sql.hikaricp - creates a database connection pool using HikariCP
-
logger.simple - a simple buffered logger that can write to a file or STDOUT
-
scheduler.simple - a component for running periodic tasks via a pool of workers
-
server.http.jetty - starts a Ring adapter based on the Jetty webserver
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:
(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 usinghiccup2.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 satisfyingduct.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 satisfyingduct.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- ajavax.sql.DataSourceinstance -
:logger- a logger that satisfiesduct.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 toragtime_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 totarget/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 tologs/repl.logand:reportlevel logs to STDOUT in brief (no timestamp) -
:test- write all logs tologs/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:
-
:migrations- a data structure of Ragtime SQL Migrations
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 aDataSourceof 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,:schemaor: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 satisfyingduct.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, ornilif a random key should be generated (the default)