In this blog post, we are going to build a server-side web application using Clojure and a framework called Duct. Why Duct? Most Clojure web applications are built up in a bespoke fashion using a collection of libraries picked and put together by the developer. Duct provides a modular framework that takes away some of the efforts of searching for these libraries and enables you to get a basic, server-side web application up and running faster. There are other Clojure web frameworks, but Duct has a nice mix of defaults without too much of a learning curve.
I am going to assume a certain amount of basic Clojure knowledge. If you are new to Clojure, take a look at Clojure for the Brave and True, Clojure from the ground up, or the loads of useful resources at the London ClojureBridge site.
If you want to see the completed code, I’ve committed it to GitHub here.
Prerequisites
In order to build this web application, you will need to install the following:
- Java JDK 8 or greater - Clojure runs on the Java Virtual Machine and is, in fact, just a Java library (JAR). I built this using version 8, but a greater version should work fine too.
- Leiningen - Leiningen, usually referred to as lein (pronounced ‘line’), is the most commonly used Clojure build tool.
- Git - The ubiquitous distributed version control tool.
Getting a basic web application
One of the nice things about Duct is that you can use its lein templates to give you a skeleton web application out of the box. We are going to use this to give you a starting point for your web application that will allow you to enter and list films with a description and rating for each. For this starter application, we are going to use SQLite as the database engine for development. In a later blog post, we will refactor this to use PostgreSQL. The following command will give you an initial project with the basic structure we require:
$ lein new duct film-ratings +site +ataraxy +sqlite +example
This will create a new directory called ‘film-ratings’ with the following structure:
.
├── db
├── dev
│ ├── resources
│ │ └── dev.edn
│ └── src
│ ├── dev.clj
│ └── user.clj
├── project.clj
├── README.md
├── resources
│ └── film_ratings
│ ├── config.edn
│ ├── handler
│ │ └── example
│ │ └── example.html
│ └── public
├── src
│ └── film_ratings
│ ├── boundary
│ ├── handler
│ │ └── example.clj
│ └── main.clj
└── test
└── film_ratings
├── boundary
└── handler
└── example_test.clj
We will explore these files in a minute, but for now, you need to generate the local config for Duct. This config is just for your machine and will be automatically excluded from the Git repository using entries generated for you in the .gitignore
file. To generate this local config, enter:
$ lein duct setup
Let’s check that it runs
At this point, we should run the application to check that we have a working setup. To do this enter the following commands:
$ lein repl
nREPL server started on port 37347 on host 127.0.0.1 - nrepl://127.0.0.1:37347
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.9.0
...
user=> (dev)
:loaded
dev=> (go)
:duct.server.http.jetty/starting-server {:port 3000}
:initiated
dev=>
This will start the application listening on port 3000
. Open a browser and type in the URL http://localhost:3000/
and you will see “Resource not found”.
This may seem odd, but the server is working. It’s just that you don’t have a route for the path /
. Let’s take a look at the config for the application. Open the file film-ratings/resources/film_ratings/config.edn
in your favorite text editor or IDE and you will see this:
{:duct.core/project-ns film-ratings
:duct.core/environment :production
:duct.module/logging {}
:duct.module.web/site {}
:duct.module/sql {}
:duct.module/ataraxy
{[:get "/example"] [:example]}
:film-ratings.handler/example
{:db #ig/ref :duct.database/sql}}
This is a basic config file for Duct. It specifies the project namespace, the target environment (production in this case, although you will look at the development config later), and some placeholders for logging, site, and SQL configuration.
The most interesting lines in this file are:
:duct.module/ataraxy
{[:get "/example"] [:example]}
:film-ratings.handler/example
{:db #ig/ref :duct.database/sql}}
These specify how to map a URL to a handler function that deals with the HTTP request to the URL. In this case, we are using a library called ataraxy
to specify the routes from URL to function. You can see in the :duct.module/ataraxy
map entry that the template generated a route that maps an HTTP GET request for the URL /example
to the keyword :example
.
Since there’s a route defined for /example
, you can type http://localhost:3000/example
into your browser.
Handling a request
How does the {[:get "/example"] [:example]}
route end up serving that example handler page?
By default, Duct assumes that the keyword in the route value (:example
) will be prefixed with {{project-ns}}.handler
and it will look for that key to determine the handler configuration. In this example, film-ratings.handler/example
defines in its options value an Integrant reference to the :duct.database/sql
key. Integrant is a micro-framework that builds a config and then constructs a running system by starting components defined in the config in the correct sequence. We will revisit the database options later. For now, just note that Duct looks for an Integrant key to determine what function acts as a handler.
You will find the handler function in the file film-ratings/src/film_ratings/handler/example.clj
:
(defmethod ig/init-key :film-ratings.handler/example [_ options]
(fn [{[_] :ataraxy/result}]
[::response/ok (io/resource "film_ratings/handler/example/example.html")]))
A handler is a function that returns another function that accepts an HTTP request and returns a response. The inner function here simply returns a vector telling ataraxy to return a status 200 (OK) and the example.html
file as the body of the response.
Handlers are given options initialized by Integrant according to the config. In this case the options are not used but this is a way of getting a handle on resources like databases.
Set up continuous integration
Before we go any further, let’s commit what we have so far to Git and set up our continuous integration build.
First, let’s create our Git repository locally. Open up a new terminal session (leave the one running lein
open) and enter the following commands from the film-handler
root directory:
$ git init
$ git add .
$ git commit -m "Duct app generated w/ +site +ataraxy +sqlite +example"
At this point, you will need a GitHub account. If you don’t have one, sign up at GitHub. Sign into your account and add a new repository called film-handler
. Copy the URL for the repository and use it in the following commands:
$ git remote add origin <github repo url>
$ git push --set-upstream origin master
If your URL is the https
version, you will be prompted for your GitHub username and password (if you have multi-factor authentication enabled you will need to generate a personal access token in GitHub to use as the password).
You now have your code in GitHub. We are going to use CircleCI to run continuous integration builds. Go to https://circleci.com/ and create an account, signing up with your GitHub credentials.
Before telling CircleCI how to run a build, it is worth running one manually:
$ lein do test, uberjar
lein test film-ratings.handler.example-test
Ran 1 tests containing 1 assertions.
0 failures, 0 errors.
Compiling film-ratings.main
Compiling film-ratings.handler.example
Created .../film-ratings/target/film-ratings-0.1.0-SNAPSHOT.jar
Created .../film-ratings/target/film-ratings-0.1.0-SNAPSHOT-standalone.jar
You can see that this ran a test and then packaged the app as a standalone uberjar (an uberjar is a single archive file containing all the required libraries). Let’s simplify the name of the uberjar by editing the project.clj
file and adding an uberjar-name key-value:
...
:main ^:skip-aot film-ratings.main
:uberjar-name "film-ratings.jar"
:resource-paths ["resources" "target/resources"]
...
If you re-run the lein do test, uberjar
you will see the jar name changed to film-ratings.jar
.
Now it’s time to add a .circleci
directory and a config.yml
to the root film-handler
directory.
$ mkdir .circleci
$ cd .circleci
$ touch config.yml
$ cd ..
Now edit the empty config.yml
to have the following lines of YAML code.
version: 2
jobs:
build:
working_directory: ~/cci-film-ratings # directory where steps will run
docker:
- image: circleci/clojure:lein-2.8.1
environment:
LEIN_ROOT: nbd
JVM_OPTS: -Xmx3200m # limit the maximum heap size to prevent out of memory errors
steps:
- checkout
- restore_cache:
key: film-ratings-{{ checksum "project.clj" }}
- run: lein deps
- save_cache:
paths:
- ~/.m2
key: film-ratings-{{ checksum "project.clj" }}
- run: lein do test, uberjar
Then we need to add the edited project.clj
file and the CircleCI config to GitHub.
$ git add .
$ git commit -m "Added circleci"
$ git push origin
Next, go to your CircleCI account and select Add Projects. Pick your repo from the list and click Set Up Project. By default, the OS and language should be pre-selected to Linux
and Clojure
so just click Start Building. If you then click Building and drill into the build job, you will see the build running and eventually you will see a successful build.
Add an index page
You don’t really need the example page, so let’s make changes to remove it and add an index page for the /
route. To do this, you need to edit the config.edn
to remove the example route and add a new index route. The config should look like this:
{:duct.core/project-ns film-ratings
:duct.core/environment :production
:duct.module/logging {}
:duct.module.web/site {}
:duct.module/sql {}
:duct.module/ataraxy
{[:get "/"] [:index]}
:film-ratings.handler/index {}
}
You now need to create a handler for the index page, so create a file called index.clj
in the film-ratings/src/film_ratings/handler
directory. Add the following to the index file:
(ns film-ratings.handler.index
(:require [ataraxy.core :as ataraxy]
[ataraxy.response :as response]
[film-ratings.views.index :as views.index]
[integrant.core :as ig]))
(defmethod ig/init-key :film-ratings.handler/index [_ options]
(fn [{[_] :ataraxy/result}]
[::response/ok (views.index/list-options)]))
This defmethod
initializes the :film-ratings.handler/index
key with a handler function that takes a request and de-structures the result of the ataraxy route. In this case, we haven’t bothered naming it because it’s not used. You can also see two arguments to the defmethod
, the first will be the key keyword, :film-ratings.handler/index
in this case, the second is any initialized options defined in the config, again not used in this example.
This handler now references a list-options
function in a film-ratings.views.index
namespace that doesn’t exist, so let’s create that file. Add a new directory called film-ratings/src/film_ratings/views
and create a new index.clj
file containing:
(ns film-ratings.views.index
(:require [film-ratings.views.template :refer [page]]))
(defn list-options []
(page
[:div.container.jumbotron.bg-white.text-center
[:row
[:p
[:a.btn.btn-primary {:href "/add-film"} "Add a Film"]]]
[:row
[:p
[:a.btn.btn-primary {:href "/list-films"} "List Films"]]]]))
You can see the list-options
function returns an HTML-like data structure wrapped in a function call to a page
function; again that doesn’t yet exist. This HTML-like data structure is rendered to real HTML using a library called hiccup. In order to use hiccup you need to add it as a dependency to the project.clj
file:
:dependencies [[org.clojure/clojure "1.9.0"]
[duct/core "0.6.2"]
[duct/module.logging "0.3.1"]
[duct/module.web "0.6.4"]
[duct/module.ataraxy "0.2.0"]
[duct/module.sql "0.4.2"]
[org.xerial/sqlite-jdbc "3.21.0.1"]
[hiccup "1.0.5"]]
Now let’s add that page
function referenced in the index view namespace. Create a film-ratings/src/film_ratings/views/template.clj
file:
(ns film-ratings.views.template
(:require [hiccup.page :refer [html5 include-css include-js]]
[hiccup.element :refer [link-to]]
[hiccup.form :as form]))
(defn page
[content]
(html5
[:head
[:meta {:name "viewport" :content "width=device-width, initial-scale=1, shrink-to-fit=no"}]
[:title "Film Ratings"]
(include-css "https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css")
(include-js
"https://code.jquery.com/jquery-3.3.1.slim.min.js"
"https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"
"https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js")
[:body
[:div.container-fluid
[:div.navbar.navbar-dark.bg-dark.shadow-sm
[:div.container.d-flex.justify-content-between
[:h1.navbar-brand.align-items-center.text-light "Film Ratings"]
(link-to {:class "py-2 text-light"} "/" "Home")]]
[:section
content]]]]))
(defn labeled-radio [group]
(fn [checked? label]
[:div.form-check.col
(form/radio-button {:class "form-check-input"} group checked? label)
(form/label {:class "form-check-label"} (str "label-" label) (str label))]))
You can also see another function, labeled-radio
, which returns hiccup for a radio button. You will need this later. You can now delete the film-ratings/src/film_ratings/handler/example.clj
and film-ratings/test/film_ratings/handler/example_test.clj
files and the film-ratings/resources/handler/example
directory and contents.
Running new index page
Let’s check that the index page renders properly.
Go back to your running lein repl
terminal. Usually, you are able to refresh the state of your app by running (reset)
in the repl, but in this case you added a new dependency (hiccup). This
needs to be reloaded by restarting the repl like so:
user=> (quit)
Bye for now!
$ lein repl
...
user=> (dev)
:loaded
dev=> (go)
:duct.server.http.jetty/starting-server {:port 3000}
:initiated
dev=>
Next, open a browser to the http://localhost:3000/
URL.
Before committing these changes we should add a test for the index page, just to cover any chance of us accidentally breaking it at some point. Create a file film-ratings/test/film_ratings/handler/index_test.clj
with the following content:
(ns film-ratings.handler.index-test
(:require [film-ratings.handler.index]
[clojure.test :refer [deftest testing is]]
[ring.mock.request :as mock]
[integrant.core :as ig]))
(deftest check-index-handler
(testing "Ensure that the index handler returns two links for add and list films"
(let [handler (ig/init-key :film-ratings.handler/index {})
response (handler (mock/request :get "/"))]
(is (= :ataraxy.response/ok (first response)))
(is (= "href=\"/add-film\""
(re-find #"href=\"/add-film\"" (second response))))
(is (= "href=\"/list-films\""
(re-find #"href=\"/list-films\"" (second response)))))))
And then run the test:
$ lein test
lein test film-ratings.handler.index-test
Ran 1 tests containing 3 assertions.
0 failures, 0 errors.
Now commit these changes and push them to GitHub.
$ git add .
$ git commit -m "Added index page"
$ git push
This will kick off a build on CircleCI that you can see in your CircleCI console.
Add films
So far we have an application that just shows an index page that has buttons that link to nothing. Next, we need to add a handler to add a film and one to list films. We also need to wire this into a database.
Let’s start by adding in our database. If you look in the film-ratings/dev/resources
you will see a dev.edn
file. On start up in development mode (which is how you’ve been running the app so far), Duct merges this development config file with the production config file before Integrant starts the application.
{:duct.core/environment :development
:duct.core/include ["film_ratings/config"]
:duct.module/sql
{:database-url "jdbc:sqlite:db/dev.sqlite"}}
As you can see, this will set :duct.module/sql
to a reference for a SQLite database that Integrant starts when you start the app. At start up, this will be a reference to a map containing a running database connection.
At the moment, this references an empty database. However, you can use Duct’s Ragtime module to use Ragtime, a database migration library, to populate the database with a film table. Add the following lines to the film-ratings/resources/film_ratings/config.edn
:
:film-ratings.handler/index {}
:duct.migrator/ragtime
{:migrations [#ig/ref :film-ratings.migrations/create-film]}
[:duct.migrator.ragtime/sql :film-ratings.migrations/create-film]
{:up ["CREATE TABLE film (id INTEGER PRIMARY KEY, name TEXT UNIQUE, description TEXT, rating INTEGER)"]
:down ["DROP TABLE film"]}
}
Let’s add the handlers for the add films form view and the post request to add the film to the database. First, add the routes to the film-ratings/resources/film_ratings/config.edn
file:
:duct.module/ataraxy
{[:get "/"] [:index]
"/add-film"
{:get [:film/show-create]
[:post {film-form :form-params}] [:film/create film-form]}}
The new add-film
URL is now mapped to two handlers, one for the GET method and one for the POST method. Note the post route uses Clojure destructuring to extract out of the request the form parameters and passes them as an argument to the :film/create
handler.
At this point, we haven’t defined the :film/create
or :film/show-create
Integrant keys and their options. To do so, add these lines in the config.edn
:
:film-ratings.handler/index {}
:film-ratings.handler.film/show-create {}
:film-ratings.handler.film/create {:db #ig/ref :duct.database/sql}
The create
key has an Integrant reference to the database that will be passed to the handler in the options map. Next, we need to create the handlers for both of these keys. The keys are namespaced as film-ratings.handler.film
. This is the namespace Duct and Integrant will look for to find the function to initialize handler keys. We need to create a new file for that namespace, film-ratings/src/film_ratings/handler/film.clj
:
(ns film-ratings.handler.film
(:require [ataraxy.core :as ataraxy]
[ataraxy.response :as response]
[film-ratings.boundary.film :as boundary.film]
[film-ratings.views.film :as views.film]
[integrant.core :as ig]))
(defmethod ig/init-key :film-ratings.handler.film/show-create [_ _]
(fn [_]
[::response/ok (views.film/create-film-view)]))
(defmethod ig/init-key :film-ratings.handler.film/create [_ {:keys [db]}]
(fn [{[_ film-form] :ataraxy/result :as request}]
(let [film (reduce-kv (fn [m k v] (assoc m (keyword k) v))
{}
(dissoc film-form "__anti-forgery-token"))
result (boundary.film/create-film db film)
alerts (if (:id result)
{:messages ["Film added"]}
result)]
[::response/ok (views.film/film-view film alerts)])))
The :film-ratings.handler.film/show-create
defmethod returns a handler that simply returns the result of a call to create-film-view
wrapped in a vector with a ::response/ok
keyword that’s rendered by ataraxy
to an HTTP response with a status of 200.
The :film-ratings.handler.film/create
defmethod takes the database as an option and its enclosed handler function de-structures the film-form from the ataraxy
result. The film-form will be a map of the HTML form (that we haven’t created yet). That map has keys that are the names of the form fields as strings plus it has an anti-forgery token. The first line of the let
removes the anti-forgery token and changes the string keys to keywords for convenience. Then a create-film
function is called with the database and the film form as arguments. This returns a result map that should have an :id
or a :messages
key-value pair. The handler function then returns the response from the call to film-view
.
At this point, the views functions don’t exist and neither does the create-film
function. We need to create a new views file, film-rating/src/film_ratings/views/film.clj
:
(ns film-ratings.views.film
(:require [film-ratings.views.template :refer [page labeled-radio]]
[hiccup.form :refer [form-to label text-field text-area submit-button]]
[ring.util.anti-forgery :refer [anti-forgery-field]]))
(defn create-film-view
[]
(page
[:div.container.jumbotron.bg-light
[:div.row
[:h2 "Add a film"]]
[:div
(form-to [:post "/add-film"]
(anti-forgery-field)
[:div.form-group.col-12
(label :name "Name:")
(text-field {:class "mb-3 form-control" :placeholder "Enter film name"} :name)]
[:div.form-group.col-12
(label :description "Description:")
(text-area {:class "mb-3 form-control" :placeholder "Enter film description"} :description)]
[:div.form-group.col-12
(label :ratings "Rating (1-5):")]
[:div.form-group.btn-group.col-12
(map (labeled-radio "rating") (repeat 5 false) (range 1 6))]
[:div.form-group.col-12.text-center
(submit-button {:class "btn btn-primary text-center"} "Add")])]]))
(defn- film-attributes-view
[name description rating]
[:div
[:div.row
[:div.col-2 "Name:"]
[:div.col-10 name]]
(when description
[:div.row
[:div.col-2 "Description:"]
[:div.col-10 description]])
(when rating
[:div.row
[:div.col-2 "Rating:"]
[:div.col-10 rating]])])
(defn film-view
[{:keys [name description rating]} {:keys [errors messages]}]
(page
[:div.container.jumbotron.bg-light
[:div.row
[:h2 "Film"]]
(film-attributes-view name description rating)
(when errors
(for [error (doall errors)]
[:div.row.alert.alert-danger
[:div.col error]]))
(when messages
(for [message (doall messages)]
[:div.row.alert.alert-success
[:div.col message]]))]))
The create-film-view
returns hiccup that displays a form to enter a films name, description, and a rating between 1 and 5. Note that the page
function from the film-ratings.view.template
namespace is used to wrap the hiccup from create-film-view
in more hiccup that provides the navbar etc. The film-view
function takes a map representing the film and a map containing errors or messages and renders hiccup to display the film and alerts for errors and messages. The hiccup that renders a film’s attributes has been extracted out into its own function, film-attributes-view
, as you will reuse this in the films list.
Adding database functions as a Boundary
Until now, we have been implementing functions and config that mainly handle HTTP requests and responses. We have added migrations to create a Film
table in the development SQLite database and Integrant references to that database, but we’ve not actually done anything with the database.
It’s time to add a Boundary namespace to handle the database interactions. Boundaries are a Duct concept to isolate external dependencies from the rest of the code. A Boundary is a Clojure protocol and associated implementation. A Clojure protocol is a similar concept to an interface in other languages.
The current config maps the options passed to the :film-ratings.handler.film/create
handler to the :duct.database/sql
key which Integrant will initialize with a reference to a duct.database.sql.Boundary
record. In the handler, we have a reference to a function that we now need to implement.
This function is create-film
in the film-ratings.boundary.film
namespace and this function would need to be defined in a protocol that extends the duct.database.sql.Boundary
record in the Duct framework with our implementation of the create-film
function.
Let’s go ahead and create that film-ratings/src/film_ratings/boundary/film.clj
file:
(ns film-ratings.boundary.film
(:require [clojure.java.jdbc :as jdbc]
duct.database.sql)
(:import java.sql.SQLException))
(defprotocol FilmDatabase
(list-films [db])
(create-film [db film]))
(extend-protocol FilmDatabase
duct.database.sql.Boundary
(list-films [{db :spec}]
(jdbc/query db ["SELECT * FROM film"]))
(create-film [{db :spec} film]
(try
(let [result (jdbc/insert! db :film film)]
(if-let [id (val (ffirst result))]
{:id id}
{:errors ["Failed to add film."]}))
(catch SQLException ex
{:errors [(format "Film not added due to %s" (.getMessage ex))]}))))
Let’s concentrate on the create-film
function. This function is defined in your FilmDatabase
protocol. This protocol is then implemented as extending the duct.database.sql.Boundary
record with your implementation of create-film
.
Unsurprisingly, the create-film
function takes a reference to the FilmDatabase
(initialized for you at start up with a live database connection by Integrant) and a reference to the film map from the form parameters parsed by the handler. The function uses the clojure.java.jdbc/insert!
function to insert the film map into the film table. The function returns a map with the id of the newly inserted record or a map of the error if an error occurs.
Adding a film
We now have enough implementation to add a film to the database. If your repl is still running in a terminal session, then switch back to it. If not, start up a new repl. Then reset the application to reload the new code:
dev=> (reset)
:reloading (film-ratings.boundary.film film-ratings.handler.film film-ratings.main film-ratings.views.film film-ratings.handler.example dev user)
:duct.migrator.ragtime/applying :film-ratings.migrations/create-film#5fc9a814
:resumed
dev=>
If you now go to http://localhost:3000/
and click on the Add Film
button.
Go ahead and fill in the form and click Add.
We have added a lot of code. Let’s commit it and push to GitHub.
$ git add .
$ git commit -m "Add films functionality."
$ git push
We can check CircleCI to see if our build has run correctly by going to the dashboard.
List films
To finish off we just need to implement the config, handler, and view for listing films.
Change the ataraxy routing and add a new list handler key to the config.edn:
:duct.module/ataraxy
{[:get "/"] [:index]
"/add-film"
{:get [:film/show-create]
[:post {film-form :form-params}] [:film/create film-form]}
[:get "/list-films"] [:film/list]}
:film-ratings.handler/index {}
:film-ratings.handler.film/show-create {}
:film-ratings.handler.film/create {:db #ig/ref :duct.database/sql}
:film-ratings.handler.film/list {:db #ig/ref :duct.database/sql}
Add the following handler function to the film-ratings/src/film_ratings/handler/film.clj
file:
(defmethod ig/init-key :film-ratings.handler.film/list [_ {:keys [db]}]
(fn [_]
(let [films-list (boundary.film/list-films db)]
(if (seq films-list)
[::response/ok (views.film/list-films-view films-list {})]
[::response/ok (views.film/list-films-view [] {:messages ["No films found."]})]))))
We’ve already defined the list-films
function called in the handler, so no need to add that.
Add the new list-film-view
function to the film-ratings/src/film_ratings/views/film.clj
file:
(defn list-films-view
[films {:keys [messages]}]
(page
[:div.container.jumbotron.bg-light
[:div.row [:h2 "Films"]]
(for [{:keys [name description rating]} (doall films)]
[:div
(film-attributes-view name description rating)
[:hr]])
(when messages
(for [message (doall messages)]
[:div.row.alert.alert-success
[:div.col message]]))]))
Because this calls the previously defined film-attributes-view
function for every film, we need to make sure the list-films-view
is after the film-attributes-view
in the file.
Test that this works by resetting the repl again:
dev=> (reset)
:reloading (film-ratings.views.film film-ratings.handler.film)
:resumed
dev=>
Go to the index page http://localhost:3000/
and select the List Films
button and you should see a list of all the films you’ve added.
Finally, add and commit your changes to Git.
Summary
Congratulations you’ve just created a functional Duct web application! 🎉 Currently, this app only has a database defined for the development profile so the uberjar we created in the build won’t actually work as the production SQL module is an empty map :duct.module/sql {}
.
I’ll leave this as an exercise for the reader, but if you don’t feel confident enough, or if you struggle with this, I will be writing a subsequent blogs detailing how to add a production database to the app and how to package it using Docker, and how to get CircleCI to deploy it to AWS.
Read more: