Wiring Clojure Web Apps with Aero, Pedestal, and Integrant
I've settled on a pattern for structuring Clojure web applications that I keep coming back to: combining Aero for configuration, Integrant for component lifecycle, and Pedestal for HTTP. The result is a fully declarative system where all wiring is explicit in configuration rather than scattered through code.
Common patterns
A common pattern is wiring dependencies through function calls, building some kind of "god context" that gets used everywhere.
(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}))
This context is shared liberally everywhere and away you go. It's simple - but you cannot ask "what depends on the database?" without reading every function. The dependency graph is implicit. It's not unusual in this pattern to see ctx being augmented at arbitrary points in code paths too. At that point, reasoning about dependencies becomes extremely hard.
(defn handle-request [ctx request]
(let [{:keys [db cache queue config redis stripe ...]} ctx]
...))
Which handler actually needs which dependencies? The only way to know is to read every handler body. Adding a dependency means updating call sites. Removing one means grep and hope.
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 - asserting on shapes at CI/CD for example, seeing in one place how dependencies have evolved over time.
Declaring Dependencies
Integrant inverts this. Dependencies are declared 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.
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, ie:
(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 examples).
Superficially, this looks similar, but rarely do functions stay quite so simple. Combining this with config, for example, means threading logic into this system definition - and the cognitive load increases. It is more flexible - given it's a function - but making runtime choices about shapes of systems introduces more branches which have to be accounted for when considering how the system may look.
That these choices may come from a config edn in the first place compounds the complexity - chasing our EDN tails through runtime function calls when that level of flexibility isn't always needed feels like unnecessary complexity.
Mount is fun and simple, but I'd argue it's really not a great fit for larger systems. It uses global state with defstate macros, and lifecycle is tied to namespace loading. We've been bitten by this: 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 just 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 {}
:mnp.components.datomic/db
{:env #ref [:env]
:uri "datomic:sql://..."}
:mnp.components.http/server
{:env #ref [:env]
:datomic #ig/ref :mnp.components.datomic/db
: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
[{:keys [profile] :as opts} tag value]
(ig/ref value))
Component Implementation
Each component implements ig/init-key and optionally ig/halt-key!:
(ns mnp.components.datomic
(:require [integrant.core :as ig]))
(defmethod ig/init-key ::db
[_k {:keys [env 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
I make routes an Integrant component because they may depend on system state - dynamic shortlinks, feature flags, multi-tenant routing. If routes were a plain def, they couldn't access the running database:
: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] :as pedestal}]
(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}]
(pi/interceptor
{:name ::add-env-to-request
:enter (fn [context]
(-> context
(assoc-in [:request :conn] conn)
(assoc-in [:request :db] (d/db conn))))}))
Handlers then access :conn and :db directly from the request map.
This is the part of the pattern I'm least satisfied with - it has a whiff of the god context problem, just pushed 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. Adding a component only requires config changes.
Adding a New Component
- Create the namespace with
ig/init-keyandig/halt-key! - Add the component key to
:ig/systemin config - Optionally inject into HTTP context by updating the server component
That's it. 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 now, and I keep reaching for it when starting something new.