Refactoring to Components

Before we add the next bit of functionality to our application, it’s time to take a small detour, into the use of Sandra Sierra’s Component library. [1]

As Clojure programs grow, the namespaces, and relationships between those namespaces, grow in number and complexity. In our previous example, we saw that the logic to start the Jetty instance was strewn across the user namespace.

This isn’t a problem in our toy application, but as a real application grows, we’d start to see some issues and concerns:

  • A single ‘startup’ namespace (maybe with a -main method) imports every single other namespace.
  • Potential for duplication or conflict between the real startup code and the test startup code. [2]
  • Is there a good way to stop things, say, between tests?
  • Is there a way to mock parts of the system (for testing purposes)?
  • We really want to avoid a proliferation of global variables. Ideally, none!

Component is a simple, no-nonsense way to achieve the above goals. It gives you a clear way to organize your code, and it does things in a fully functional way: no globals, no update-in-place, and easy to reason about.

The building-block of Component is, unsurprisingly, components. These components are simply ordinary Clojure maps – though for reasons we’ll discuss shortly, Clojure record types are more typically used.

The components are formed into a system, which again is just a map. Each component has a unique, well-known key in the system map.

Components may have dependencies on other components. That’s where the true value of the library comes into play.

Components may have a lifecycle; if they do, they implement the Lifecycle protocol containing methods start and stop. This is why many components are implemented as Clojure records … records can implement a protocol, but simple maps can’t.

Rather than get into the minutiae, let’s see how it all fits together in our Clojure Game Geek application.

Add Dependencies

deps.edn
{:paths ["src" "resources"]
 :deps  {org.clojure/clojure              {:mvn/version "1.11.1"}
         com.walmartlabs/lacinia          {:mvn/version "1.2-alpha-4"}
         com.walmartlabs/lacinia-pedestal {:mvn/version "1.1"}
         com.stuartsierra/component       {:mvn/version "1.1.0"}
         io.aviso/logging                 {:mvn/version "1.0"}}
 :aliases
 {:run-m {:main-opts ["-m" "my.clojure-game-geek"]}
  :run-x {:ns-default my.clojure-game-geek
          :exec-fn    greet
          :exec-args  {:name "Clojure"}}
  :build {:deps       {io.github.seancorfield/build-clj
                       {:git/tag   "v0.8.2" :git/sha "0ffdb4c"
                        ;; since we're building an app uberjar, we do not
                        ;; need deps-deploy for clojars.org deployment:
                        :deps/root "slim"}}
          :ns-default build}
  :dev   {:extra-paths ["dev-resources"]}
  :test  {:extra-paths ["test"]
          :extra-deps  {org.clojure/test.check {:mvn/version "1.1.1"}
                        io.github.cognitect-labs/test-runner
                        {:git/tag "v0.5.0" :git/sha "48c3c67"}}}}}

We’ve added the component library.

System Map

We’re starting quite small, with just two components in our system:

digraph {

  server [label=":server"]
  schema [label=":schema-provider"]

  server -> schema

}

The :server component is responsible for setting up the Pedestal service, which requires a compiled Lacinia schema. The :schema-provider component exposes that schema as its :schema key.

Later, we’ll be adding additional components for other logic, such as database connections, thread pools, authentication/authorization checks, caching, and so forth. But it’s easier to start small.

What does it mean for one service to depend on another? Dependencies are acted upon when the system is started (and again when the system is stopped).

The dependencies influence the order in which each component is started. Here, :schema-provider is started before :server, as :server depends on :schema-provider.

Secondly, the started version of a dependency is assoc-ed into the dependant component. After :schema-provider starts, the started version of the component will be assoc-ed as the :schema-provider key of the :server component.

Once a component has its dependencies assoc-ed in, and is itself started (more on that in a moment), it may be assoc-ed into further components.

The Component library embraces Clojure’s core concept of identity vs. state; the identity of the component is its key in the system map … its state is a series of transformations of the initial map.

:schema-provider component

The my.clojure-game-geek.schema namespace has been extended to provide the :schema-provider component.

src/my/clojure_game_geek/schema.clj
(ns my.clojure-game-geek.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require [clojure.java.io :as io]
            [com.stuartsierra.component :as component]
            [com.walmartlabs.lacinia.util :as util]
            [com.walmartlabs.lacinia.schema :as schema]
            [clojure.edn :as edn]))

(defn resolve-game-by-id
  [games-map context args value]
  (let [{:keys [id]} args]
    (get games-map id)))

(defn resolve-board-game-designers
  [designers-map context args board-game]
  (->> board-game
    :designers
    (map designers-map)))

(defn resolve-designer-games
  [games-map context args designer]
  (let [{:keys [id]} designer]
    (->> games-map
      vals
      (filter #(-> % :designers (contains? id))))))

(defn entity-map
  [data k]
  (reduce #(assoc %1 (:id %2) %2)
    {}
    (get data k)))

(defn resolver-map
  []
  (let [cgg-data (-> (io/resource "cgg-data.edn")
                   slurp
                   edn/read-string)
        games-map (entity-map cgg-data :games)
        designers-map (entity-map cgg-data :designers)]
    {:Query/gameById      (partial resolve-game-by-id games-map)
     :BoardGame/designers (partial resolve-board-game-designers designers-map)
     :Designer/games      (partial resolve-designer-games games-map)}))

(defn load-schema
  []
  (-> (io/resource "cgg-schema.edn")
    slurp
    edn/read-string
    (util/inject-resolvers (resolver-map))
    schema/compile))

(defrecord SchemaProvider [schema]

  component/Lifecycle

  (start [this]
    (assoc this :schema (load-schema)))

  (stop [this]
    (assoc this :schema nil)))

The significant changes are at the bottom of the namespace. There’s a new record, SchemaProvider, that implements the Lifecycle protocol.

Lifecycle is optional; trivial components may not need it. In our case, we use the start method as an opportunity to load and compile the Lacinia schema.

When you implement a protocol, you must implement all the methods of the protocol. In Component’s Lifecycle protocol, you typically will undo in stop whatever you did in start. For example, a Component that manages a database connection will open it in start and close it in stop.

Here we just get rid of the compiled schema, [3] but it is also common and acceptable for a stop method to just return this if the component doesn’t have external resources, such as a database connection, to manage.

:server component

Next well add the my.clojure-game-geek.server namespace to provide the :server component.

src/my/clojure_game_geek/server.clj
(ns my.clojure-game-geek.server
  (:require [com.stuartsierra.component :as component]
            [com.walmartlabs.lacinia.pedestal2 :as lp]
            [io.pedestal.http :as http]))

(defrecord Server [schema-provider server]

  component/Lifecycle

  (start [this]
    (assoc this :server (-> schema-provider
                            :schema
                            (lp/default-service nil)
                            http/create-server
                            http/start)))

  (stop [this]
    (http/stop server)
    (assoc this :server nil)))

Much of the code previously in the user namespace has moved here.

You can see how the components work together, inside the start method. The Component library has assoc-ed the :schema-provider component into the :server component, so it’s possible to get the :schema key and build the Pedestal server from it.

start and stop methods often have side-effects. This is explicit here, with the call to http/stop before clearing the :server key.

stop is especially important in this component, as it calls http/stop; without this, the system will shut down, but the Jetty instance will continue to listen on port 8080. This kind of side-effect is exactly what the Lifecycle protocol is used for.

system namespace

We’ll create a new my.clojure-game-geek.system namespace just to put together the Component system map.

src/my/clojure_game_geek/system.clj
(ns my.clojure-game-geek.system
  (:require [com.stuartsierra.component :as component]
            [my.clojure-game-geek.schema :as schema]
            [my.clojure-game-geek.server :as server]))

(defn new-system
  []
  (assoc (component/system-map)
    :server (component/using (server/map->Server {})
              [:schema-provider])
    :schema-provider (schema/map->SchemaProvider {})))

The call to component/using establishes the dependency between the components.

You can imagine that, as the system grows larger, so will this namespace. But at the same time, the namespaces for the individual components will only need to know about the namespaces of components they directly depend upon.

user namespace

Next, we’ll look at changes to the user namespace:

dev-resources/user.clj
(ns user
  (:require [com.stuartsierra.component :as component]
            [my.clojure-game-geek.system :as system]
            [com.walmartlabs.lacinia :as lacinia]
            [clojure.java.browse :refer [browse-url]]
            [clojure.walk :as walk])
  (:import (clojure.lang IPersistentMap)))

(defn simplify
  "Converts all ordered maps nested within the map into standard hash maps, and
   sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
  [m]
  (walk/postwalk
    (fn [node]
      (cond
        (instance? IPersistentMap node)
        (into {} node)

        (seq? node)
        (vec node)

        :else
        node))
    m))

(defonce system (system/new-system))

(defn q
  [query-string]
  (-> system
    :schema-provider
    :schema
    (lacinia/execute query-string nil nil)
    simplify))

(defn start
  []
  (alter-var-root #'system component/start-system)
  (browse-url "http://localhost:8888/ide")
  :started)

(defn stop
  []
  (alter-var-root #'system component/stop-system)
  :stopped)

The user namespace has shrunk; previously it was responsible for loading the schema, and creating and starting the Pedestal service; the code for all that has shifted to the individual components.

Instead, the user namespace uses the my.clojure-game-geek.system/new-system function to create a system map, and can use start-system and stop-system on that system map: no direct knowledge of loading schemas or starting and stopping Pedestal is present any longer.

The user namespace previously had vars for both the schema and the Pedestal service. Now it only has a single var, for the Component system map.

Interestingly, as our system grows in later chapters, the user namespace will likely not change at all, just the system map it gets from new-system will expand.

The only wrinkle here is in the q function; since there’s no longer a local schema var it is necessary to pull the :schema-provider component from the system map, and extract the schema from that component.

Summary

Even with just two components, using the Component library simplifies our code, and lays the groundwork for rapidly expanding the behaviour of the application.

In the next chapter, we’ll look at adding new queries and types to the schema, in preparation to adding our first mutations.

[1]Sandra provides a really good explanation of Component in their Clojure/West 2014 talk.
[2]We’ve been sloppy so far, in that we haven’t even thought about testing. That will change later in the tutorial.
[3]You might be tempted to use a dissoc here, but if you dissoc a declared key of a record, the result is an ordinary map, which can break tests that rely on repeatedly starting and stopping the system.
[4]This is just one approach; another would be to provide a function that assoc-ed the component into the system map.