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:

  1. 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.
  2. Leiningen - Leiningen, usually referred to as lein (pronounced ‘line’), is the most commonly used Clojure build tool.
  3. 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:


Chris Howe-Jones is a consultant CTO, Software Architect, Lean/Agile Coach, Developer, and Technical Navigator for DevCycle. He works mainly in Clojure/ClojureScript, Java, and Scala with clients from multi-national organizations down to small startups.

Read more posts by Chris Howe-Jones