,

Dependency Injection in Clojure

Sign saying "Yes You Can Do It"

Dependency Injection seems like an Object-oriented technique.

Can you even do Dependency Injection in Clojure?

Yes, you can do Dependency Injection in Clojure.

Do you need a Dependency Injection library in Clojure?

No, you don’t need a DI library.

Instead of that…

In a single composition root function, you can pass all of the dependencies to functions and classes:

(defn comp-root [gw]
  (let [child-process-gateway (:child-process-gateway gw)
        editor-gateway        (:editor-gateway gw)
        fs-gateway            (:fs-gateway gw)
        os-gateway            (:os-gateway gw)
        build-agent-settings  (->BuildAgentSettings child-process-gateway os-gateway)
        command-decorators    (make-command-decorators editor-gateway)
        job!                  (make-job build-agent-settings command-decorators editor-gateway fs-gateway)]
    {:child-process-gateway child-process-gateway
     :editor-gateway        editor-gateway
     :fs-gateway            fs-gateway
     :os-gateway            os-gateway
     :build-agent-settings  build-agent-settings
     :command-decorators    command-decorators
     :job                   job!}))Code language: Clojure (clojure)

In this 1 place, you can see what depends on what.

There’s no magic.

And this isn’t a service container, which passes all services to all services.

In production, only 1 function calls comp-root: the -main function:

(defn -main []
  ((:job (comp-root (gateways)))))Code language: Clojure (clojure)

And that only calls a single function from the composition root: :job.

The only reason comp-root returns all of the functions is so you can unit test them.

Gateways

Gateways are what make Dependency Injection work.

They’re classes that interact with the outside world.

Dependency Injection In Clojure gateway files

For example, child-process-gateway spawns a child process on the host machine.

And editor-gateway opens a terminal in the code editor.

They have side-effects.

Without gateways, and their side-effects, there would be little reason for Dependency Injection. 1

Gateways are the only classes that are different in the production and test composition roots.

That’s why comp-root accepts a gw parameter: so you can pass different gateways for production and tests.

(defn comp-root [gw]
  (let [child-process-gateway (:child-process-gateway gw)
        editor-gateway        (:editor-gateway gw)
        fs-gateway            (:fs-gateway gw)
        os-gateway            (:os-gateway gw)
        build-agent-settings  (->BuildAgentSettings child-process-gateway os-gateway)
        command-decorators    (make-command-decorators editor-gateway)
        job!                  (make-job build-agent-settings command-decorators editor-gateway fs-gateway)]
    {:child-process-gateway child-process-gateway
     :editor-gateway        editor-gateway
     :fs-gateway            fs-gateway
     :os-gateway            os-gateway
     :build-agent-settings  build-agent-settings
     :command-decorators    command-decorators
     :job                   job!}))


Code language: Clojure (clojure)

For example, in the unit tests that call child-process-gateway, you don’t actually want to spawn a child process on the host machine.

That might interfere with other tests and require cleanup.

So this passes a stub child-process-gateway for tests.

Gateways For Production

(defn gateways []
  {:child-process-gateway (->ChildProcessGateway)
   :editor-gateway        (->EditorGateway)
   :fs-gateway            (->FsGateway)
   :os-gateway            (->OsGateway)})
Code language: Clojure (clojure)

Gateways For Testing

(defn gateways []
  {:child-process-gateway (->StubChildProcessGateway)
   :editor-gateway        (->StubEditorGateway)
   :fs-gateway            (->StubFsGateway)
   :os-gateway            (->StubOsGateway)})
Code language: Clojure (clojure)

When testing, the gateways start with Stub, like StubChildProcessGateway

They do the minimum possible.

They usually return nil, "", or false.

(ns functional-di.gateway.stub-child-process-gateway
  (:require [functional-di.gateway.child-process-gateway :refer [IChildProcessGateway]]))

(deftype StubChildProcessGateway []
  IChildProcessGateway
  (spawn! [_])
  (spawn-sync! [_]))Code language: Clojure (clojure)

The gateways are classes because they usually have multiple methods. #!It’d be possible to make them functions, but there would be a lot more functions in the composition root.!#

Gateways makes unit tests really easy.

With gateways, every function or class in your app can have unit tests.

No matter how much they interact with APIs, the filesystem, a database, etc…

For example, here’s a function with a lot of side-effects:

(defn make-job [build-agent-settings
                command-decorators
                editor-gateway
                fs-gateway]
  (fn job! []
    (let [command (command-decorators)]
      (when
       (not (.exists? fs-gateway "/tmp/log"))
        (.mkdir! fs-gateway "/tmp/log"))
      (.set! build-agent-settings)
      (.make-terminal!
       editor-gateway
       (trim
        (join
         " "
         [(:pre-command command)
          (:post-command command)
          (:eval-pre-command command)
          "local-bin run;"
          (:eval-post-command command)]))))))
Code language: Clojure (clojure)

It has 4 parameters that interact with the outside world.

This would normally need an integration test.

Or a mocking library.

But here, it’s really easy to unit test:

(deftest make-job-test
  (testing "mkdir is called when directory does not exist"
    (let [mkdir-called? (atom false)]
      ((:job (comp-root (merge
                         (gateways)
                         {:fs-gateway
                          (reify
                            IFsGateway
                            (exists? [_ _] false)
                            (mkdir! [_ _]
                              (swap! mkdir-called? (fn [_] true))))}))))
      (is (= @mkdir-called? true))))
Code language: Clojure (clojure)

On line 4, it gets the :job function from the composition root.

On line 7, it creates a spy of :fs-gateway with reify.

Line 11 spies on whether the test called mkdir! .

And line 12 asserts that the test called mkdir!.

Even though make-job has 4 arguments, the test setup is really easy.

That’s because on line 5, it passes (gateways) to the composition root.

That has all of the stub gateways for tests.

But what if you pass arguments in the wrong order?

For example, make-job has 4 arguments:

(defn make-job [build-agent-settings
                command-decorators
                editor-gateway
                fs-gateway]Code language: Clojure (clojure)

It’d be easy to pass it 2 arguments in the wrong order.

But unit tests should catch that.

For example, changing the order of the first 2 arguments:

diff --git a/src/functional_di/composition_root.clj b/src/functional_di/composition_root.clj
index b496d19..e17c369 100644
--- a/src/functional_di/composition_root.clj
+++ b/src/functional_di/composition_root.clj
@@ -20,7 +20,7 @@
         os-gateway            (:os-gateway gw)
         build-agent-settings  (->BuildAgentSettings child-process-gateway os-gateway)
         command-decorators    (make-command-decorators editor-gateway)
-        job!                  (make-job build-agent-settings command-decorators editor-gateway fs-gateway)]
+        job!                  (make-job command-decorators build-agent-settings editor-gateway fs-gateway)]
     {:child-process-gateway child-process-gateway
      :editor-gateway        editor-gateway
      :fs-gateway            fs-gateway
diff --git a/test/functional_di/composition_root_test.clj b/test/functional_di/composition_root_test.clj
index bcd79ad..b8cd15c 100644
Code language: Diff (diff)

…makes the unit tests fail with:

$ lein test
ERROR in (make-job-test) (make_job.clj:9)
Uncaught exception, not in assertion.
expected: nil
  actual: java.lang.ClassCastException: class functional_di.build_agent_settings.BuildAgentSettings cannot be cast to class clojure.lang.IFn (functional_di.build_agent_settings.BuildAgentSettings and clojure.lang.IFn are in unnamed module of loader 'app')Code language: Bash (bash)

And if unit tests don’t fail when the order is wrong, you can probably add a test that will fail.

But what about circular dependencies?

Dependency Injection libraries sometimes resolve circular dependencies for you.

And this composition root won’t.

But you might not need circular dependencies.

For example, does a function have to depend on an entire class, just to call a 2-line method?

You might be able to copy-paste that method, without depending on the entire class. #!This would violate DRY, but that’s a smaller problem than circular dependencies.!#

Or maybe you could extract that method into a standalone function to break the chain of circular dependencies.

But why not just move most of the logic to pure functions that are easy to test, and not test the side-effect functions?

Yes, that can be the right approach for apps with less side effects.

But extracting to pure functions can also work alongside this composition root approach.

So you can move some logic in side-effect functions to pure functions.

And you can still unit test side-effect functions, just a little less.

Even if you’re using a composition root…

Many of your functions will still be pure, and won’t need to be in the composition root.

Dependency Injection in Clojure

Using a composition root makes dependencies easy to understand.

If you edit a function…

You can see what other functions call it.

If a new team member joins your project…

No need to explain a DI library to them, or document it.

And unit tests become really easy.

  1. The only other reason for Dependency Injection would be to inject mutable state. But even then, maybe the state doesn’t have to be mutable, and Dependency Injection might not be the solution.[]

One response to “Dependency Injection in Clojure”

  1. Ryan Kienstra Avatar

    At first, I thought it was crazy to do Dependency Injection without a DI library.

Leave a Reply

Your email address will not be published. Required fields are marked *