Dan Peddle

Wiring Clojure Web Apps with Aero, Pedestal, and Integrant

5 min read

I've settled on a pattern for structuring Clojure web applications: combining Aero for configuration, Integrant for component lifecycle, and Pedestal for HTTP. The result is a declarative system where the dependency graph is explicit in configuration rather than scattered through code.

Where the Graph Lives

The usual starting point is building the system in code:

(defn make-system [config]
  (let [db (make-db config)
        cache (make-cache config)
        http (make-http config db cache)]
    {:db db :cache cache :http http}))

It's simple. But the dependency graph only exists inside that function. You can't ask "what depends on the database?" without reading it and everything downstream. As things grow, the result tends to get passed along whole:

(defn handle-request [ctx request]
  (let [{:keys [db cache queue config redis stripe ...]} ctx]
    ...))

Which handler needs which dependencies? The only way to know is to read every handler body. Adding a dependency is invisible, and removing one means grep and hope. It's not unusual to see ctx augmented at arbitrary points along the way, too. Component's README warns against exactly this - no function should take the entire system as an argument - but nothing in the code stops it.

With Integrant, the dependency graph is data:

:ig/system {:app/db {}
            :app/cache {}
            :app/http {:db #ig/ref :app/db
                       :cache #ig/ref :app/cache}}

You can inspect it, query it, visualize it, validate it - all without running code. This matters as systems grow: you can assert on its shape in CI, or see in one place how dependencies have changed over time.

Declaring Dependencies

Rather than handlers pulling what they like from a shared map, Integrant declares dependencies at the component boundary in config:

:mnp.components.http/server
{:datomic #ig/ref :mnp.components.datomic/db
 :pool    #ig/ref :mnp.components.html-to-markdown/pool}

The HTTP component declares exactly what it needs. The config is the contract.

(Examples from here on come from a real project, so keys sit under its mnp namespace.)

Why Not Component or Mount?

Alessandra Sierra's Component solves similar problems but the dependency graph is built through functions - it's somewhat opaque until you run it. Changing the graph means editing functions, for example:

(defn example-system [config-options]
  (let [{:keys [host port]} config-options]
    (component/system-map
      :db (new-database host port)
      :scheduler (new-scheduler)
      :app (component/using
             (example-component config-options)
             {:database  :db
              :scheduler :scheduler}))))

(from the Component README).

Superficially, this looks similar, but rarely do functions stay that simple. Combining this with config means threading logic into the system definition, and the cognitive load increases. It's more flexible, being a function, but every runtime choice about the system's shape is another branch to hold in your head.

Aero makes choices too - #profile, #or - but they're settled when the config is read, before the graph exists. By the time Integrant sees the system, it's data. With a constructor function, those same choices become branches in code, and we end up chasing our EDN tails through runtime function calls to work out what the system will look like.

Mount's model is straightforward and elegant, but needs special care in larger systems. It uses global state with defstate macros, and lifecycle is tied to namespace loading. I've seen first hand how this can backfire: a Lambda function that only needed utility functions ended up trying to open a database connection on cold start because a transitive require pulled in a namespace with a defstate. The dependency wasn't in the Lambda's code; it was magic hidden in the require graph.

Integrant avoids this entirely. Requiring a component namespace does nothing - it loads multimethod definitions. The system only starts when you explicitly call ig/init.

The Configuration

Configuration lives in a single EDN file using Aero's reader tags:

{:env #keyword #or [#env CLJ_ENV :dev]

 :stripe {:public #or [#env STRIPE_PK "pk_test_..."]
          :secret #or [#env STRIPE_KEY "sk_test_..."]}

 :ig/system
 {:mnp.components.http.routes/routes
  {:db #ig/ref :mnp.components.datomic/db}

  :mnp.components.datomic/db
  {:env #ref [:env]
   :uri "datomic:sql://..."}

  :mnp.components.html-to-markdown/pool {}

  :mnp.components.http/server
  {:env         #ref [:env]
   :datomic     #ig/ref :mnp.components.datomic/db
   :pool        #ig/ref :mnp.components.html-to-markdown/pool
   :service-map
   {:io.pedestal.http/routes #ig/ref :mnp.components.http.routes/routes
    :io.pedestal.http/type   :jetty
    :io.pedestal.http/port   #long #or [#env PORT "8890"]}}}}

The #ig/ref tag is custom, bridging Aero and Integrant:

(defmethod aero/reader 'ig/ref
  [_ _ value]
  (ig/ref value))

Component Implementation

Each component implements ig/init-key and optionally ig/halt-key!:

(ns mnp.components.datomic
  (:require [datomic.api :as d]
            [integrant.core :as ig]))

(defmethod ig/init-key ::db
  [_k {:keys [uri]}]
  (let [conn (d/connect uri)]
    {:conn conn}))

(defmethod ig/halt-key! ::db
  [_k {:keys [conn]}]
  (d/release conn))

Component keys are namespaced keywords matching their implementation namespace - :mnp.components.datomic/db maps to src/mnp/components/datomic.clj. This convention enables ig/load-namespaces to automatically require component namespaces based on the config.

Routes as a Component

Where routing is based upon some kind of state - dynamic shortlinks, feature flags, multi-tenant routing - routes may also take part in the graph and receive the database. If routes were a plain def, they couldn't access it:

:mnp.components.http.routes/routes
{:db #ig/ref :mnp.components.datomic/db}

Injecting into Pedestal

The HTTP component builds the interceptor chain and injects dependencies into the request context:

(defmethod ig/init-key ::server
  [_ {:keys [service-map datomic pool]}]
  (let [service-map (-> service-map
                        (http/default-interceptors)
                        (update ::http/interceptors
                                #(into % [(add-env-to-context {:datomic datomic
                                                               :pool pool})])))]
    (http/start (http/create-server service-map))))

(defn add-env-to-context
  [{{:keys [conn]} :datomic pool :pool}]
  (pi/interceptor
    {:name  ::add-env-to-request
     :enter (fn [context]
              (-> context
                  (assoc-in [:request :conn] conn)
                  (assoc-in [:request :db] (d/db conn))
                  (assoc-in [:request :pool] pool)))}))

Handlers then access :conn, :db and :pool directly from the request map.

This is the part of the pattern I'm least satisfied with - it has a whiff of passing the system around, pushed down to the interceptor layer. Alternatives like closing over dependencies in route handlers, or making each interceptor its own Integrant component, have their own trade-offs. I'd be curious to hear how others approach this.

Startup

(defonce system! (atom nil))

(defn start []
  (let [config (config/config)
        system (:ig/system config)]
    (ig/load-namespaces system)
    (reset! system! (ig/init system))))

(defn stop []
  (when-let [system @system!]
    (ig/halt! system)
    (reset! system! nil)))

ig/load-namespaces is key - it reads the component keys and requires their namespaces automatically, so startup code never needs to know what's in the system.

Adding a New Component

  1. Create the namespace with ig/init-key and ig/halt-key!
  2. Add the component key to :ig/system in config
  3. Optionally inject into HTTP context by updating the server component

No touching startup code, no updating a system constructor function.

Trade-offs

This approach front-loads complexity into configuration and convention. If you're building something small, it's overkill. But for applications that grow, having the dependency graph as inspectable data rather than implicit code pays dividends in maintainability.

The pattern has worked well across several projects.

Filed under clojure, architecture, pedestal