Pedestal with Component (Redux)

As your Component-based Pedestal application grows, you might see some growing pains. The theory and practice of Component is that each component gets exactly the dependencies it specifically needs …​ no more.

And yet, we have this :components component that, in a fully-featured application, can be expected to grow into a big grab-bag of dependencies. This is a "Big Ball of Mud" - a design antipattern that Component was created to avoid.

Pedestal has optional capabilities to define interceptors and handlers as components, each with individual dependencies. By embracing this approach, we end up with a more focused system map:

Diagram

Again, with this tiny toy of an application, the structure is quite flat.

Before we get started on how this is implemented, there are some tradeoffs to consider:

  • Greater complexity: more components and more dependencies

  • Reloading is for functions not objects

  • Debugging into methods is harder [1]

To expand on that reloading issue: when you reload namespace changes, everything does get replaced. For functions, the fully qualified function name will be shared between the old code and the new.

For Clojure records, after reloading namespaces, the old record instance (in the system map) will still be in place, with the old JVM class for that instance. So, changing the implementation of a method of a record will not see any effect, at least until the system map is rebuilt.

So, keep those concerns in mind while we describe the revised application. This time, we can build from the bottom up, starting with the greeter component.

:greeter component

src/app/components/greeter.clj
(ns app.components.greeter
  (:require [com.stuartsierra.component :as component]
            [clj-commons.humanize :as h]))

(defrecord Greeter [*count]

  component/Lifecycle

  (start [this]
    (assoc this :*count (atom 0)))

  (stop [this]
    (assoc this :*count nil)))

(defn new-greeter
  []
  (map->Greeter {}))

(defn generate-message!
  [component]
  (let [n (-> component :*count (swap! inc))]
    (format "Greetings for the %s time\n"
            (h/ordinal n))))

This namespace is unchanged from the prior guide.

handlers namespace

A new namespace, for components that are interceptors or handlers, has been created.

src/app/components/handlers.clj
(ns app.components.handlers
  (:require [io.pedestal.interceptor.component :as c]
            [app.components.greeter :as greeter]))

(c/definterceptor get-greeting                              (1)
  [greeter]                                                 (2)
  
  (handle [_ _request]                                      (3)
    {:status 200
     :body   (greeter/generate-message! greeter)}))         (4)

(defn new-get-greeting                                      (5)
  []
  (map->get-greeting {}))
1 definterceptor extends the behavior of defrecord.
2 As with a record, we start with fields of the record.
3 The handle method is allowed and definterceptor automatically adds the correspondng protocol.
4 The implementation can directly reference the greeter field.
5 It’s always good form to provide a function to create a new component instance.

The definterceptor macro streamlines the process of creating a record type for a component; it automatically adds the protocol (Handler, OnEnter, OnLeave, or OnError).

It also quietly adds an implementation of IntoInterceptor.

routes namespace

Next up the component heirarchy is the component used to generate the application’s routes. It depends on the handlers and other interceptor components that are referenced in the routes.

src/app/routes.clj
(ns app.routes)                                             (1)

(defrecord RoutesSource [get-greeting])

(defn routes
  [component]
  (let [{:keys [get-greeting]} component]                   (2)
    #{["/api/greet" :get get-greeting]}))                   (3)

(defn new-routes-source
  []
  (map->RoutesSource {}))
1 Amazingly, no dependencies are needed for this namespace.
2 The routes function is provided with the RoutesSource component and can extract the get-greeting dependency.
3 The :route-name option is now omitted, and the name of the interceptor is used as the route name.

pedestal namespace

src/app/pedestal.clj
(ns app.pedestal
  (:require [com.stuartsierra.component :as component]
            [io.pedestal.connector :as conn]
            [io.pedestal.http.http-kit :as hk]
            [app.routes :as routes]))

(defrecord Pedestal [route-source connector]                (1)
  component/Lifecycle

  (start [this]
    (assoc this :connector
           (-> (conn/default-connector-map 8890)
               (conn/optionally-with-dev-mode-interceptors)
               (conn/with-default-interceptors)
               (conn/with-routes (routes/routes route-source)) (2)
               (hk/create-connector nil)
               (conn/start!))))

  (stop [this]
    (conn/stop! connector)
    (assoc this :connector nil)))

(defn new-pedestal
  []
  (map->Pedestal {}))
1 The component has a dependency on route-source, and manages the connector field.
2 This is where the RouteSource component is used.

system namespace

This all comes together in the app.system namespace where all components and dependencies get declared:

src/app/system.clj
(ns app.system
  (:require [com.stuartsierra.component :as component]
            [app.components.greeter :as greeter]
            [app.components.handlers :as handlers]
            app.routes
            app.pedestal))

(defn new-system
  []
  (component/system-map
    :greeter
    (greeter/new-greeter)

    :handler/get-greeting
    (component/using
      (handlers/new-get-greeting)
      [:greeter])

    :route-source
    (component/using
      (app.routes/new-routes-source)
      {:get-greeting :handler/get-greeting})                (1)

    :pedestal
    (component/using
      (app.pedestal/new-pedestal)
      [:route-source])))
1 A map can be used when the local field does not match the system map key.

Other code

All the other namespaces (primariy, the tests) are unchanged between the two versions of the application.

The Path So Far

In the guide we extended the prior application to fully leverage the capabilities of the Component library. We demonstrated how the definterceptor macro streamlines creating components that acts as both components and interceptors (or handlers), and we saw how each component had only explicit dependencies on exactly what other components it directly interacts with.


1. This is my personal experience using Cursive and IntelliJ. Often, it is not possible to set breakpoints inside methods.