Dependency Injection with Higher-Order Functions

Inject behavior explicitly in Clojure with higher-order functions, closures, and system maps instead of hidden containers.

Dependency injection means a component receives the collaborators it needs from the outside instead of creating them internally. In Clojure, that is usually much simpler than in container-heavy object-oriented systems because functions are first-class and data is easy to pass around.

The practical consequence is important: you usually do not need a framework to “inject dependencies.” You need explicit parameters, closures, or a small system map that makes runtime wiring visible.

The Core Clojure Idea

If a function reaches into global state or directly constructs its own logger, clock, database function, or HTTP client, it becomes harder to test and harder to reuse. If that same function receives those collaborators explicitly, the behavior becomes easier to swap and easier to reason about.

That is the heart of the pattern in Clojure:

  • pass dependencies as ordinary function arguments
  • return functions that close over configured collaborators
  • store related runtime pieces in maps when that is clearer

A Practical Example

Suppose a signup workflow needs three external behaviors:

  • generate an id
  • persist a user
  • send a welcome email
 1(ns app.signup)
 2
 3(defn make-signup-service
 4  [{:keys [next-id save-user! send-welcome-email!]}]
 5  (fn signup! [{:keys [email] :as input}]
 6    (when-not (seq email)
 7      (throw (ex-info "Email is required" {:type ::invalid-input})))
 8    (let [user {:user/id (next-id)
 9                :user/email email
10                :user/state :pending}]
11      (save-user! user)
12      (send-welcome-email! email)
13      user)))

The constructed function closes over its dependencies, but nothing is hidden. The caller still decides what next-id, save-user!, and send-welcome-email! mean in a given environment.

1(def signup!
2  (make-signup-service
3    {:next-id random-uuid
4     :save-user! (fn [user] (println "Persisting" user))
5     :send-welcome-email! (fn [email] (println "Emailing" email))}))

This is dependency injection, just without ceremony.

Why Higher-Order Functions Fit So Well

Higher-order functions make injection natural because a configured component is often just “a function with some collaborators already supplied.” That means:

  • tests can substitute tiny fakes
  • production wiring can stay near startup or system assembly
  • the business function stays focused on domain logic

You can also inject narrower dependencies than an OO container usually would. Instead of passing an entire service object, you can pass one function with exactly the behavior you need.

Map-Based Injection

Passing a map of named operations is often clearer than passing a long positional parameter list.

1(defn charge-order!
2  [{:keys [authorize! record-payment! now]} order]
3  (let [payment {:order/id (:order/id order)
4                 :charged-at (now)}]
5    (authorize! order)
6    (record-payment! payment)
7    payment))

That style works well in Clojure because data shapes are already the normal way to communicate structure. It also makes testing easy because the test can provide only the keys the function actually uses.

When To Use This Pattern

Use explicit injection when:

  • the logic should be easy to test without patching globals
  • behavior changes by environment or configuration
  • the code depends on time, I/O, storage, messaging, or other effects
  • you want assembly at the edge and domain logic in the middle

Do not overdo it. If a helper function is tiny and purely local, passing five layers of injected dependencies may make the code harder to read than simply calling the helper directly.

Common Mistakes

The first mistake is hiding the dependencies anyway. A function that receives a dependency map but then also reads globals has not really become explicit.

The second mistake is injecting entire worlds. If every function receives a giant application map when it only needs one collaborator, the API becomes noisy and vague.

The third mistake is assuming DI requires a framework. In Clojure, that assumption often adds complexity for very little gain.

Better Than A Singleton By Default

This page connects directly to the singleton discussion in the same chapter. Many singleton-like designs are really failed dependency injection. If a component only uses a global because it was easier than threading a dependency through one level of composition, explicit injection is usually the better answer.

That does not mean every runtime resource should be recreated on every call. It means the ownership of that resource should stay explicit, even when it is shared.

Design Review Questions

When reviewing Clojure dependency injection, ask:

  • Can the dependency be passed as a function instead of a larger object?
  • Does the function signature make required collaborators obvious?
  • Would a small map of named operations be clearer than positional arguments?
  • Is the design actually explicit, or is it still leaning on global state?

If those answers are good, the pattern is doing its job.

Loading quiz…
Revised on Thursday, April 23, 2026