Component Lifecycle

Contents

  1. Managing Component Lifecycle

Luminus encourages using the Clean Architecture style for writing web applications.

The workflows in web applications are typically driven by the client requests. Since requests will often require interaction with a resource, such as a database, we will generally have to access that resource from the route handling the request. In order to isolate the stateful code, we should have our top level functions deal with managing the side effects.

Consider a route that facilitates user authentication. The client will supply the username and the password in the request. The route will have to pull the user credentials from the database and compare these to the ones supplied by the client. Then a decision is made whether the user logged in successfully or not, and its outcome communicated back to the client.

In this workflow, the code that deals with the external resources should be localized to the namespace that provides the route and the namespace that handles the database access.

The route handler function will be responsible for calling the function that fetches the credentials from the database. The code that determines whether the password and username match represents the core business logic. This code should be pure and accept the supplied credentials along with those found in the database explicitly. This structure can be seen in the diagram below.

            pure code
+----------+
| business |
|  logic   |
|          |
+-----+----+
      |
------|---------------------
      |     stateful code
+-----+----+   +-----------+
|  route   |   |           |
| handlers +---+  database |
|          |   |           |
+----------+   +-----------+

Keeping the business logic pure ensures that we can reason about it and test it without considering the external resources. Meanwhile, the code that deals with side effects is pushed to a thin outer layer, making it easy for us to manage.

Managing Component Lifecycle

The management of stateful components, such as database connections, is handled by the mount library. The library handles the lifecycle of such resources within the application ensuring that any such resources are started and stopped as necessary.

Luminus encourages keeping related domain logic close together. Therefore, in cases where we have functions that reply on an external resource the management of the state for that resource should be handled in the same namespace where the functions using it are defined.

Stateful components belong to the namespace they're declared in. To create a component we need to reference the mount.core/defstate macro in the namespace definition and then use it as follows:

(ns myapp.resource
  (:require [mount.core :refer [defstate]]))

(defn connect []
  ;;open-a-remote-connection should return the connection instance
  {:state :connected})
  
(defn disconnect [conn]
  (assoc conn :state :disconnected))

(defstate conn
  :start (connect)
  :stop (disconnect conn))

When the component is started the function bound to the :start key will be called. It's result will be used as the value for the state. In the example above, the conn will contain the map returned by the connect function.

When the component is shut down then the function bound to the :stop key is called. This function must accept the current state of the var. The function is expected to clean up any external resources before the component is shutdown.

The component dependencies are inferred from the namespace hierarchy. If namespace a references namespace b then the component specified using defstate in namespace b will be started before the one specified in namespace a. When the system is shutdown the :stop functions for each state are called in the reverse order.

For example, we may have one namespace that loads the configuration and another that used the configuration to connect to a database. This could be expressed as follows:

(ns <app>.config
  (:require [cprop.core :refer [load-config]]
            [cprop.source :refer [from-resource]]
            [mount.core :refer [args defstate]]))

(defstate env :start (load-config :merge [(args)]))

The <app>.config namespace loads the config into the env state var. We can now access this config in a different namespace:

(ns app.db
  (:require [mount.core :refer [defstate]]
            [app.config :refer [env]]))

(defn connect! [config] ...)

(defn disconnect! [conn] ...)

(defstate conn :start (connect! env)
               :stop (disconnect! conn))

The component hierarchy is initialized by calling mount.core/start and stopped with mount.core/stop. This is done by the <app>.core/start-app and <app>.core/stop-app functions respectively.

Luminus provides an <app>.user namespace found in the env/dev/clj/user.clj file. This namespace provides convenience functions for starting and stopping the application states from the REPL:

(ns user
  (:require [mount.core :as mount]
            [<app>.config :refer [env]]
            <app>.core))

(defn start []
  (mount/start-without #'<<project-ns>>.core/repl-server))

(defn stop []
  (mount/stop-except #'<<project-ns>>.core/repl-server))

(defn restart []
  (stop)
  (start))

The states can be started selectively by explicitly providing the namespaces to be started and stopped to the start and stop functions:

(mount.core/start #'app.config #'app.db)

(mount.core/stop #'app.config #'app.db)

Alternatively, it's possible to specify the namespaces that should be omitted from the lifecycle using the start-without function:

(start-without #'app.db)

Finally, the states can be replaced by alternate ones such as mock states for testing using the start-with function:

(start-with #'app.db #'app.test.mock-db)

In the above example, the app.db will be replaced by the app.test.mock-db when the the components are loaded.

Please see the official documentation for further details on using mount.