Yet another Component alternative

First of all, I wish I knew what to call the class of libraries that includes Component, Integrant and Mount. “Backend state management”? Maybe “Component-alternative” is actually the best name.

Anyway, I made one of these. It’s lightweight and highly experimental. I think it’s a good idea, but I wouldn’t bet money on it yet. I’ll give you the “what” first and then the “why.” Here’s the whole thing:

(ns trident.system
  (:require
    [com.stuartsierra.dependency :as dep]))

(defn sort-components [components]
  (let [name->component (->> components
                          (map (juxt :name identity))
                          (into {}))]
    (->> (for [{this-name :name :as component} components
               relationship [:requires :required-by]
               other-name (get component relationship)]
           (if (= relationship :requires)
             [this-name other-name]
             [other-name this-name]))
      (reduce (fn [graph [a b]]
                (dep/depend graph a b))
        (dep/graph))
      (dep/topo-sort)
      (map name->component))))

(defn start-system [components]
  (let [components (sort-components components)
        _ (apply println "Starting" (map :name components))
        system (->> components
                 (map :start)
                 reverse
                 (apply comp)
                 (#(% {::stop '()})))]
    (println "System started.")
    system))

(defn stop-system [{::keys [stop]}]
  (doseq [f stop]
    (f)))

Here’s some simple example usage, adapted from Integrant’s example:

(require '[trident.system :as ts])
(require '[ring.adapter.jetty :as jetty])
(require '[clojure.tools.namespace.repl :as tn-repl])

(def components
  [{:name :adapter/jetty
    :start (fn [{:adapter.jetty/keys [handler port]
                 :or {port 8080} :as sys}]
             (let [opts {:port port :join? false}
                   server (jetty/run-jetty handler opts)]
               (update sys ::ts/stop conj #(.stop server))))}
   {:name :handler/greet
    :required-by [:adapter/jetty]
    :start (fn [{greet-name :handler.greet/name
                 :or {greet-name "Alice"} :as sys}]
             (assoc sys :adapter.jetty/handler
               (fn [_]
                 {:status 200
                  :body (str "Hello " greet-name)
                  :headers {"Content-Type" "text/plain"}})))}])

(defonce system (atom {}))

(defn start []
  (reset! system (ts/start-system components)))

(defn refresh []
  (ts/stop-system @system)
  (tn-repl/refresh :after `start))

So what’s going on here? Basically, we’re treating the system kind of like a Ring request. Everything goes in a single flat map, and then we thread it through each of the component functions.

That was the motivating reason for making this. I was using Mount and then Integrant for my recent work (I’m making a web framework), but I was having trouble holding the codebase in my head. In particular, I’m trying to provide lots of configuration options while providing defaults whenever possible. It was getting messy. So far this approach seems to be helping—it lets me think of the system (i.e. the collection of components) as more of a linear structure instead of a DAG.

It works better with a few helper functions for working with flat, namespaced maps. For example, I’ve written select-ns which is like select-keys but it takes a namespace instead of a collection of keys:

(select-ns
  {:foo/bar 1
   :foo.bar/baz 2
   :spam/eggs 3}
  'foo)
=> {:foo/bar 1, :foo.bar/baz 2}

There’s also select-as which does the same thing but then also renames the namespace in each key:

(select-as {:foo/bar 1} 'foo 'baz)
=> {:baz/bar 1}

In the example above, we could’ve used (select-as sys 'adapter.jetty nil) to pass arbitrary options on to jetty without having to name them explicitly.

Adding a configuration file is pretty simple, just:

(require '[clojure.edn :as edn])

(def config-component
  {:name :config
   :start (fn [sys]
            (->> (slurp "config.edn")
              edn/read-string
              (merge sys)))})

You’ll have to add {:requires [:config] …} to your other components. I also like to add a defaults component that sets default and derived config values in one place for all the other components.

In the web framework, I use the following function to load components at run-time:

(require '[clojure.java.classpath :as cp])
(require '[clojure.tools.namespace.find :as tn-find])

(defn get-components []
  (->> (cp/classpath)
    tn-find/find-ns-decls
    (map second)
    (filter (comp :biff meta))
    (mapcat #(some-> %
               name
               (symbol "components")
               requiring-resolve
               deref))))

This way, I can define components in any namespace by simply adding ^:biff metadata:

(ns ^:biff my-namespace.core)

(def components [{:name :some-component ...}])

These components (plugins for the framework) can then be installed by simply adding their containing library to your project’s dependencies.

I’m looking forward to releasing this along with the rest of the web framework soon. Of course, if you want to try this state management code out now, you can just copypaste it since it’s all here.

Miscellaneous

This month’s Clojure-Provo meetup is tonight at 7 MDT (over Zoom).