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.
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.
- 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.[↩]
Leave a Reply