Functional Bytes Clojure, Scala en Java specialist

Component-lite, did I finally crack the code?!

May 28, 2019 • Arnout Roemers

The reloaded workflow; I think every substantial Clojure projects needs it. There are various libraries available for this lifecycle management, such as Component, Integrant and mount-lite. While I’m a big fan of the easy approach by the latter, some projects are better suited for a more functional approach - passing the system to functions - such as Component.

However, I tried working with Component multiple times, but every time it didn’t feel quite right. On a recent project of mine, I experimented on how to deal with lifecycle management, and I stumbled upon a solution that seems to fix the issues I had.

My issues with Component

Firstly, you need a record to define your component. Having a Lifecycle protocol works fine for me, but with Component it also needs to be implemented on a record, as that’s how dependencies are injected. That’s already a bit of overhead, as opposed to using an inline reify for example. But, the started component also needs to stay/be an object implementing the Lifecycle protocol. Most likely, this is the updated record itself, so the component can access its dependencies when stopping.

The downside of this is that the actual component might very well be inside the record. Think of a JDBC connection pool component for example. To be able to stop such a component, it needs to implement the Lifecycle protocol. You either need to wrap the connection pool inside the record, or you need to extend-type it. Most likely the first option is chosen. For me this is a major downside, as now all the other components need to know that the actual connection pool must be retrieved from somewhere inside the component. It feels like having an abstraction where you don’t need or want one. By the way, Integrant improves upon this, but in some cases it has the same issue.

Secondly, the recommended way of creating a component composition in Component is by using its system-map function. This takes key-value pairs, together with dependency declarations with using or system-using. In the end, the result is just a map with metadata on its values. But that was not clear from the API; I learned this by reading the source code. Knowing this, I was able to define different parts of the system map at different places - something I wanted for my current Clojure project - as you can simply merge the system maps. Still, this merging must be done explicitly, tightly coupling those places.

Thirdly, I don’t really care for defining the exact dependencies between components up-front. Often I am just fiddling in the system-map, to get everything to work together. Since the component composition is defined in one place, it is clear I already know the dependencies. In that case I can just as easily put the components in the correct order myself, right?

My solution

So, let’s change a small thing about how Component and its Lifecycle work. Instead of having the library build up the system map when starting the composition, have the components do it. A component takes the system map built so far, starts itself, and adds itself to the system map. Now the next component can be started. And when stopping the component, the system map is passed in as well.

Firstly, this allows you to add any type of component to the system map. A function, a JDBC connection pool, a protocol implementation, anything.

Secondly, a component gets to add multiple entries to the system map, if it so desires. This way, one “component” could add multiple other components, transparently. Or, if the component needs to store something it needs for its stopping logic (other then its dependencies), you could also add this. No wrapping of the actual component needed.

Thirdly, this approach does not need a dependency tree. Starting the system is simply a reduction over the start functions.

The source code

Below is the source code of everything that’s needed for this approach. Note that the namespaces are fictitious.

;;------ lifecycle.clj
(ns lifecycle)

(defprotocol Lifecycle
  :extend-via-metadata true
  (start [this system])
  (stop [this system]))

(defn combine
  [& lifecycles]
  (reify Lifecycle
    (start [_ system]
      (reduce #(merge %1 (start %2 %1)) system lifecycles))
    (stop [_ system]
      (doseq [lifecycle (reverse lifecycles)]
        (stop lifecycle system)))))

The combine function above is a utility function, starting/stopping multiple Lifecycle implementations in the order the are passed in. Below is an example application on how to use it.

;;------ jdbc.clj, a component
(ns jdbc
  (:require [hikari-cp.core :as hikari]
            [lifecycle :refer [Lifecycle]]))

(defn component [config]
  (reify Lifecycle
    (start [_ {:keys [logger]}]
      (logger "starting JDBC pool...")
      {:jdbc (hikari/make-datasource config)})
    (stop [_ {:keys [logger jdbc]}]
      (logger "stopping JDBC pool...")
      (hikari/close-datasource jdbc))))

;;------ app.clj, a component combining other components
(ns app
  (:require [lifecycle :refer [combine]]))

(defn components [app-config]
  (combine (users-db/component)
           (tasks-db/component)
           (rest-api/component app-config)))

;;------ core.clj, the entrypoint
(ns core
  (:require [lifecycle :refer [start stop combine]]))

(defn -main [& args]
  (let [component (combine (logger/component)
                           (jdbc/component jdbc-config)
                           (app/components app-config))
        system (start component {})]
    ;; bonus: the empty map above could be filled by another
    ;; lifecycle library/approach.
    ...
    (stop component system)))

Because the components are in charge of how to add entries to the system map, it can also easily be extended with other behaviour. For example, another utility function could have parts of the system start up in parallel:

;;------ lifecycle.clj
(defn parallel
  [& lifecycles]
  (reify Lifecycle
    (start [_ system]
      (->> (mapv #(future (start % system)) lifecycles)
           (map deref)
           (apply merge)))
    (stop [_ system]
      (->> (mapv #(future (stop % system)) lifecycles)
           (mapv deref)))))

Now imagine that both the DB components from the example app are independent of each other. And let’s say that they perform migrations on startup. In that case we may improve our startup time by using this parallel function as follows:

;;------ app.clj, updated with parallel
(defn components [app-config]
  (combine (parallel (users-db/component)
                     (tasks-db/component))
           (rest-api/component app-config)))

Wrapping up

Well, there you have it. It turns out the small lifecycle namespace above is all I need for the project, and it feels right. It does not have features like only starting/stopping parts of the system, or declaring the component composition using a datastructure. I currently don’t need those features, yet they are easily done in a REPL.

Keep in mind, all this is very much my own opinion. I don’t want to pick upon the great work that was - and still is - being done by the community on this subject. I just wrote it down for those who might have had the same issues and may find my solution useful.

As always, have fun!

P.S. Here’s a link to the source code


Clojure - Scala - Java - JavaEE - Datomic - Reagent - Figwheel - HugSQL - JavaScript - Node.js - Maven - SBT - XML - XSD - XSLT - JSON - jQuery - HTML - HTMX - React - Redux - OAuth - REST - GraphQL - ZooKeeper - Kafka - Akka HTTP - PostgreSQL - ElasticSearch - Cassandra - Redis - Mule - RabbitMQ - MQTT - SOAP - Linux - macOS - Git - Scrum - Emacs - Docker - Kubernetes - Ansible - Terraform - Jenkins - GitHub - GitLab - Devops - Raspberry Pi - Event Sourcing - Functional Reactive Programming - Ports and Adapters (Hexagonal)


Functional Bytes, 2013-2024

Boekelo

06 267 145 02

KvK: 59562722

Algemene voorwaarden