Do we need Dependency Injection with Clojure? That’s a slightly misplaced question — it does not even begin with the problem definition. Clojure is a functional programming language. In this post we would explore the “dependency problem” with Clojure programming and what dependency injection can bring to the table. Later, we discuss DIME, a Push-model based dependency injection library.

Functions are “Contract + Implementation”

Consider the following function:

(fn save-order [db-connection order-details]) ; caller passes two args

This function requires the caller to pass two arguments, which forms its API contract. It all sounds well until the caller is in conflict with this contract. What if the caller (say, web layer) doesn’t have (or if you find it inappropriate and burdensome to lug around) the database connection as an argument? A contract the caller is looking for may be different from the implementation. The caller may want to call (fn save-order’ [order-details]) — a subset of the above implementation, without being bothered about db-connection — in view of this caller contract, db-connection is a dependency of save-order.

Push versus Pull

The save-order function can either accept db-connection as an argument (Push model) or resolve it by looking up some var (Pull model) instead. The signature (fn save-order [db-connection order-details]) we saw in the previous section implies Push model because we push db-connection as an argument to the function.

On the other hand, an example of Pull model would be as follows:

(def ^:redef db-connection nil) ; mutated during initialization

(fn save-order [order-details]) ; uses db-connection

Unfortunately, the Pull model implies Place-oriented programming, involves mutation (potentially leading to ordering issues with other mutation) and is riddled with issues related to mocks during testing. These issues are fundamental in nature without a sound remedy. However, the Pull model manages to reduce the API contract down to what the caller may expect.

The Push model is not free of challenges either. The first issue that arises is cascading-push problem, where the caller must acquire the dependency before passing it on, and the caller’s caller must do the same thing and so on. That is turtles all the way down, right? The second is too-many-dependencies issue, where a large number of dependencies are required by the outermost caller to begin the invocation chain. With a large number of dependencies how do you manage the function arities? Even though one can adopt a convention to dedicate the first argument of every function to receiving dependencies as a map, it is unwieldy and it quickly turns into a “grab bag” anti-pattern (from my personal experience) when somebody is tempted to abuse the dependency-map to pass runtime parameters in order to avoid or delay code refactoring. The third issue is editor-inconvenience, wherein you cannot navigate to the source code of a dependency because it is passed as argument. Power users of Clojure code editors are often used to the convenience of quickly navigating to the source code of an invoked function, but passed arguments do not contain the metadata to make that feasible.

Though the challenges of Push model make it almost impractical, what if there was a way to automate the process and remove the elements of human error? Perhaps, using a dependency injection library/framework! We will discuss other aspects for the remainder of this post in light of the Push model.

Push-model benefits

So, why should anybody consider Push-model over a convenient Pull-model? Fundamentally, push-model forces you to decouple the implementation from the contract. This decoupling leads to several benefits, the first one being a simpler application initialization model that is easier to reason about. You do not need to worry about mutation (and the order and source of mutation) to prepare and store dependencies. (However, small mutations for the purpose of development i.e. REPL/Test mode are OK.) The second benefit is a derivative of the first — decoupling allows very flexible and pervasive mocking during tests, which means you can simulate success or failure of dependencies at any level and implement quite sophisticated test cases. The third benefit is that since there is no mutation, any kind of instrumentation, enhancement or wrapping of the dependencies becomes much easier. With automation of the Push-model overhead, the benefits start outweighing the associated challenges.

Comparison with Object-Oriented approach

If this were to be implemented using an OO language, probably the implementation would encapsulate db-connection as a private (dependency) member and expose only save(order-details) method as the contract. So, OO languages have this baked-in concept of dependency versus contract.

To achieve a similar separation of dependency versus contract, we need two variants of the save-order function we noticed above — one is the (current) implementation, and another that is a caller’s view of the implementation. The latter may be created from the former by encapsulating the dependency.

A case for SOLID

SOLID (makes sense for Clojure) represents five fundamental programming principles, listed below:

Single responsibility principle

Open/closed principle

Liskov substitution principle

Interface segregation problem

Dependency inversion principle

When you allow a dependency to be exposed in the API contract, you (a) make the caller responsible for procuring db-connection (violating Single-responsibility principle) and (b) pollute the API contract with implementation details (violating Interface-segregation principle).

Tests and Mocks

We test only implementations, not contracts. This makes unit testing with mocks quite easy with the Push model, i.e. passing dependencies as arguments. The test code can supply mock versions of the dependencies as arguments.

This scheme sounds kosher for unit tests, but what about integration tests? Integration tests can be done in a similar fashion as unit tests, except that fully constructed integration dependencies should be passed instead of mocks in the case of integration tests. We can also build upon unit tests and integration tests to design “scenario tests” with varying levels of simulation for various functionality and infrastructure components for an application.

Dependency lifecycle

Dependencies are not only ready-to-use values, sometimes you need to initialize them first and often those initialized things are stateful objects that require de-initialization. Let us revisit the original function with a helper:

(fn save-order [db-connection order-details])

(fn make-db-connection [host port db-name password options])

The db-connection argument accepted by save-order is created using the make-db-connection function, which depends on five arguments that are probably read from a configuration file. The password was probably encrypted and had to be decrypted before use. As you can see, there is an order of steps to initialize/resolve and pass dependencies. Similarly, there may be a certain sequence of steps involved for de-initialization as well.