Grim's web corner

Notes, essays and ramblings

Basic dependency injection with objects

In his article Why I chose OCaml as my primary language, my friend Xavier Van de Woestyne presents, in the section Dependency injection and inversion, two approaches to implementing dependency injection: one using user-defined effects and one using modules as first-class values. Even though I’m quite convinced that both approaches are legit, I find them sometimes a bit overkill and showing fairly obvious pitfalls when applied to real software. The goal of this article is therefore to briefly highlight the ergonomic weaknesses of both approaches, and then propose a new encoding of inversion and dependency injection that I find more comfortable (in many cases). In addition, this gives an example of using objects in OCaml, which are often overlooked, even though in my view OCaml’s object model is very interesting and offers a lot of practical convenience.

This approach is by no means novel and is largely the result of several experiments shared with Xavier Van de Woestyne during multiple pair-programming sessions. However, a precursor of this encoding can be found in the first version of YOCaml (using a Left Kan Extension/Freer Monad rather than a Reader). It's also interesting we can find a similar approach in the recent work around Kyo, an effect system for the Scala language, which uses a similar trick based on subtyping relationships to type an environment.

Why use dependency injection?

There are plenty of documents that describe (sometimes a bit aggressively) all the benefits of dependency injection, which are sometimes extended into fairly formalized software architectures (such as hexagonal architecture). For my part, I find that dependency injection makes unit testing a program trivial, which I think is reason enough to care about it (For example, the time-tracker I use at work, Kohai, uses Emacs as its interface and the file system as its database. Thanks to inversion and dependency injection, we were able to achieve high test coverage fairly easily).

Effect system and dependency injection

There are many different ways to describe an effect handler, so in my view it is difficult to give a precise definition of what an Effect system is. However, in our modern interpretation, the goal is more to suspend a computation so it can be interpreted by the runtime (the famous IO monad), and an Effect system is often described as a systematic way to separate the denotational description of a program, where propagated effects are operationalholes” that are given meaning via a handler, usually providing the ability to control the program’s execution flow (its continuation), unlocking the possibility to describe, for example, concurrent programs. In this article, I focus on dependency injection rather than on building an effect system because that would be very pretentious (my article does not address performance or runtime concerns). Similarly to how exception handling can be seen as a special case of effect propagation and interpretation (where the captured continuation is never resumed), I also see dependency injection as a special case, this time, where the continuation is always resumed. It’s quite amusing to see that dependency injection and exception capturing can be considered two special cases of effect abstraction, differing only in how the continuation is handled.

The Drawbacks of Modules and User-Defined Effects

As I mentioned in the introduction, I believe that both approaches proposed by Xavier are perfectly legitimate. However, after using both approaches in real-world software, I noticed several small annoyances that I will try to share with you.

Using modules

Using modules (through functors or by passing them as values) seems to be the ideal approach for this kind of task. However, the module language in OCaml has a different type system, in which type inference is severely limited. From my point of view, these limitations can lead to a lot of verbosity whenever I want to imagine that my dependencies come from multiple different sources. For example, consider these two signatures:

The first one provides basic manipulation of the file system (very simple, with no handling of permissions or errors):

module type FS = sig
  val read_file : path:string -> string option
  val write_file : path:string -> content:string -> unit 
end

The second one simply allows logging to standard output (also very basic, without support for log levels):

module type CONSOLE = sig
  val log : string -> unit
end

The first way to depend on both signatures is to introduce a new signature that describes their combination:

module type FS_CONSOLE = sig 
  include FS
  include CONSOLE
end

let show_content (module H : FS_CONSOLE) = 
  match H.read_file ~path:"/foo/bar" with
  | None -> H.log "Nothing"
  | Some x -> H.log x

Or simply take the dependencies as two separate parameters:

let show_content (module F : FS) (module C : CONSOLE)  = 
  match F.read_file ~path:"/foo/bar" with
  | None -> C.log "Nothing"
  | Some x -> C.log x

Indeed, constructing the module on the fly, directly in the function definition, is not possible. Although very verbose, this expression is rejected by the compiler:

# let show_content (module H : sig 
        include FS
        include CONSOLE
      end) =
    match H.read_file ~path:"/foo/bar" with
    | None -> H.log "Nothing"
    | Some x -> H.log x ;;
Lines 1-4, characters 30-10:
Error: Syntax error: invalid package type: only module type identifier and with type constraints are supported

Moreover, beyond the syntactic heaviness, I find the loss of type inference quite restrictive. While in situations where you don’t need to separate and diversify dependency types this isn’t a big deal, I find that this approach sometimes forces unnecessary groupings. I often ended up in situations where, for all functions that had dependencies, I had to provide a single module containing them all, which occasionally forced me, in testing scenarios, to create dummy functions. A very frustrating experience!

Using User-Defined-Effects

I proudly claimed that dependency injection is a special case of using an Effect System, so one might wonder: could using OCaml’s effect system be a good idea? From my understanding, the integration of effects was primarily intended to describe interactions with OCaml’s new multi-core runtime. In practice, the lack of a type system tracking effects makes, in my view, their use for dependency injection rather cumbersome. Indeed, without a type system, it becomes, once again in my view, difficult to mentally keep track of which effects have been properly handled. In YOCaml, we recorded effects in a module called Eff that encodes programs capable of propagating effects in a monad (a kind of IO monad). This allows us to handle programs a posteriori (and thus inject dependencies, of course) but restricts us in terms of handler modularity. Indeed, it assumes that in all cases, all effects will be interpreted. And, in the specific case of YOCaml, we usually only want to either continue the program or discard the continuation (which can be done trivially using an exception). Control-flow management is therefore a very powerful tool, for which we have very little use.

In practice, there are scenarios where using OCaml’s effects seems perfectly legitimate (and I think I have fairly clear ideas about why introducing a type system for effects is far from trivial, particularly in terms of user experience):

So, in the specific case of dependency injection, I have the intuition that using OCaml’s effects gives too much power, while putting significant pressure on tracking effects without compiler assistance, making them not entirely suitable.

Using objects

It’s not very original to use objects to encode a pattern typically associated with object-oriented programming. However, in functional programming, it’s quite common to encounter encodings of dependency injection sometimes referred to as Functional Core, Imperative Shell. Although relatively little used and sometimes unfairly criticized, OCaml’s object model is actually very pleasant to work with (its theoretical foundation is even extensively praised in Xavier’s article, in the section Closely related to research). To my knowledge, OCaml is one of the rare mainstream languages that draws a very clear separation between objects, classes, and types. Objects are values, classes are definitions for constructing objects, and objects have object types, which are regular types, whereas classes also have types that are not regular, since classes are not regular expressions but rather expressions of a small class language.

In order to integrate coherently into OCaml and with type inference, the object model relies on four ingredients: structural object types (object types are structural, their structure is transparent), row variables, equi-recursive types, and type abbreviations.

In practice, an object type is made up of the row of visible members (associated with their types) and may end with a row variable (to characterize closed/open object types). This row variable can serve as the subject of unification as objects are used together with their types.

A quick way to become aware of the presence of the row variable is to simply write a function that takes an object as an argument and sends it a message (in OCaml, sending a message is written with the syntax obj # message):

# let f obj = (obj # foo) + 10 ;;
val f : < foo : int; .. > -> int = <fun>

In the return type, the row variable indicating that the object type is open is represented by <..>. It is thanks to this variable that we can perform dependency injection, finely tracked by the type system and guided by inference.

Simple example

To keep things simple, let’s just revisit the classic teletype example, which we could write in direct style like this:

# let teletype_example () = 
    print_endline "Hello, World!";
    print_endline "What is your name?";
    let name = read_line () in 
    print_endline ("Hello " ^ name)
val teletype_example : unit -> unit = <fun>

Let’s rewrite our example by taking an object as an argument, which will serve as the handler:

# let teletype_example handler = 
    handler#print_endline "Hello, World";
    handler#print_endline "What is your name?";
    let name = handler#read_line () in
    handler#print_endline ("Hello " ^ name)
val teletype_example :
  < print_endline : string -> 'a; read_line : unit -> string; .. > -> 'a =
  <fun>

To convince ourselves of the compositionality of our dependency injection, let’s imagine the following function, whose role is to simply log traces of the execution:

# let log handler ~level message = 
    handler#do_log level message
val log : < do_log : 'a -> 'b -> 'c; .. > -> level:'a -> 'b -> 'c = <fun>

By using the teletype_example and log functions, we can directly observe the precision of the elision, showing that our handler object was open each time:

# let using_both handler = 
    let () = log handler ~level:"debug" "Start teletype example" in
    teletype_example handler
val using_both :
  < do_log : string -> string -> unit; print_endline : string -> 'a;
    read_line : unit -> string; .. > ->
  'a = <fun>

All the requirements of our using_both function are (logically) correctly tracked. We can now implement a dummy handler very simply, using immediate object syntax:

# using_both object
    method do_log level message = 
      print_endline ("[" ^ level ^ "] " ^ message)

    method print_endline value = print_endline value
    method read_line () = 
      (* Here I should read the line but hey, 
        it is not interactive, let's just return my name. *)
      "Pierre"
  end;;
[debug] Start teletype example
Hello, World
What is your name?
Hello Pierre

- : unit = ()

This approach is extremely similar to using polymorphic variants for composable error handling, which share many features with objects (structural subtyping, rows).

Typing, sealing and reusing

In our examples, we were guided by type inference. However, in OCaml, it is common to restrict the generality of inference using explicit signatures. As we have seen, some of our inferred signatures are too general and arguably not very pleasant to write:

  < do_log : string -> string -> unit; 
    print_endline : string -> 'a;
    read_line : unit -> string; 
    .. 
  >

Fortunately, type abbreviations allow us to simplify this notation. By using class types and certain abbreviations, we can simplify the type expressions of our functions that require injection:

First, we will describe our types using dedicated class types, let's start with console:

class type console = object
  method print_endline : string -> unit
  method read_line : unit -> string
end

Now let's write our loggable interface:

class type loggable = object
  method do_log : level:string -> string -> unit
end

Now, let's rewrite our three functions inside a submodule (to make their signatures explicit):

module F : sig 
  val teletype_example : #console -> unit
  val log : #loggable -> level:string -> string -> unit
end = struct

  let teletype_example handler = 
    handler#print_endline "Hello, World";
    handler#print_endline "What is your name?";
    let name = handler#read_line () in
    handler#print_endline ("Hello " ^ name)
    
  let log handler ~level message = 
    handler#do_log ~level message
end

And now, we can describe our using_both function as taking an object that is the conjunction of loggable and console, like this:

module G : sig 
  val using_both : <console; loggable; ..> -> unit
end = struct
  let using_both handler = 
    let () = F.log handler ~level:"debug" "Start teletype example" in
    F.teletype_example handler
end

At the implementation level, the separation between inheritance (as a syntactic action) and subtyping (as a semantic action) allows us to benefit from this kind of mutualization directly at the call site. For example, let's implement a handler for console and loggable:

class a_console = object
  method print_endline value = print_endline value
  method read_line () = 
    (* Here I should read the line but hey, 
       it is not interactive, let's just return my name. *)
    "Pierre"
end

class a_logger = object
  method do_log ~level message = 
    print_endline ("[" ^ level ^ "] " ^ message)
end

Even though it would be possible to constrain our classes by the interfaces they implement (using class x = object (_ : #interface_)), it is not necessary because structural subtyping will handle the rest (and it also allows us to potentially introduce intermediate states easily). We can now use inheritance to share functionality between our two handlers:

# G.using_both object
    inherit a_console
    inherit a_logger
  end ;;
[debug] Start teletype example
Hello, World
What is your name?
Hello Pierre

- : unit = ()

One could argue that it’s still a bit verbose, but in my view, this approach offers much more convenience. We know statically the capabilities that need to be implemented, we retain type inference, and we have very simple composition tools. At this point, I have, subjectively, identified scenarios for using the different approaches discussed in this article:

However, the approach based on first-class modules or objects can heavily pollute a codebase, whereas effect interpretation and functor instantiation can remain at the edges of the program. Let’s look at the final part of this article, which explains how to reduce the aggressive propagation of handlers.

Injected dependencies as part of the environment

Currently, our approach forces us to pass our handler explicitly from call to call, which can drastically bloat business logic code. What we would like is the ability to only worry about the presence of dependencies when it is actually necessary. To achieve this, we’ll use a module whose role will be to pass our set of dependencies in an ad-hoc manner:

module Env : sig
  type ('normal_form, 'rows) t

  val run : 'rows -> ('normal_form, 'rows) t -> 'normal_form
  val perform : ('rows -> 'normal_form) -> ('normal_form, 'rows) t
  val return : 'normal_form -> ('normal_form, 'rows) t
  val ( let* ) : ('a, 'rows) t -> ('a -> ('b, 'rows) t) -> ('b, 'rows) t
end = struct
  type ('normal_form, 'rows) t = 'rows -> 'normal_form

  let run env comp = comp env
  let perform f = f
  let return x _ = x
  let ( let* ) r f x = (fun y -> (f y) x) (r x)
end

Our module allows us to separate the description of our program from the passing of the environment. The only subtlety lies in the (let*) operator, a binding operator that lets us simplify the writing of programs. Let's define some helpers based on our previous interfaces:

module U : sig 
  val print_endline : string -> (unit, #console) Env.t
  val read_line : unit -> (string, #console) Env.t
  val log : level:string -> string -> (unit, #loggable) Env.t
end = struct 
  let print_endline str = 
    Env.perform (fun h -> h#print_endline str)
  
  let read_line () = 
    Env.perform (fun h -> h#read_line ())
  
  let log ~level message = 
    Env.perform (fun h -> h#do_log ~level message)
end

And now, we can describe our previous programs using our let* syntax shortcut:

let comp = 
  let open Env in 
  let* () = U.log ~level:"Debug" "Start teletype example" in
  let* () = U.print_endline "Hello, World!" in 
  let* () = U.print_endline "What is your name?" in 
  let* name = U.read_line () in 
  U.print_endline ("Hello " ^ name)

And we can handle it using Env.run:

# Env.run object 
    inherit a_console
    inherit a_logger
  end comp ;;
[Debug] Start teletype example
Hello, World!
What is your name?
Hello Pierre

- : unit = ()

Some prefer to pass handlers explicitly to avoid relying on binding operators. Personally, I really like that the operators make it explicit whether I’m dealing with a pure expression or one that requires dependencies, and I find that binding operators make the code readable and easy to reason about.

Under the hood

Looking at the signature of the Env module, many will notice that it is a Reader Monad (a specialization of ReaderT transformed with the Identity monad), which is sometimes also called a Kleisli. In practice, this allows us to be parametric over the normal form of our expressions. For simplicity in the examples, I’ve minimized indirection, but it is entirely possible to project our results into a more refined monad, defining a richer runtime (and potentially supporting continuation control, effect interleaving, etc.).

To conclude

Some might be surprised—indeed, I’ve fairly closely applied the Boring Haskell philosophy. I literally used objects for dependency injection (the extra-classical approach to DI) and rely on a ReaderT to access my environment on demand, which is a very common approach in the Haskell community.

The only idea here, which isn’t particularly novel, is to use subtyping relationships to statically track the required dependencies, and rely on inheritance to arbitrarily compose handlers. From my point of view, this drastically reduces the need to stack transformers while still keeping modularity and extensibility. By relying on a different normal form than the identity monad, it’s possible to achieve results that are surprisingly pleasant to use and safe, as demonstrated by Kyo in the Scala world. In OCaml, the richness of the object model (and its structural capabilities) is a real advantage for this kind of encoding, allowing a drastic reduction of the boilerplate needed to manage multiple sets of dependencies.

In practice, I’ve found this approach effective for both personal and professional projects (and, importantly, very easy to explain). One limitation is the inability to eliminate dependencies when they are partially interpreted in ways other than by partial function application. Still, the encoding remains neat for at least three reasons:

In the not-so-distant future, we might even imagine providing handlers more lightly using Modular Implicits.

Thank you for reading (if you made it this far), and a special thanks to Xavier Van de Woestyne for his article that inspired me to write this one, and to Jonathan Winandy for showing me Kyo and helping me rephrase some sentences.

Bibliography

  1. Why I chose OCaml as my primary language
    • Xavier Van de Woestyne
  2. Kyo: Toolkit for Scala Development
    • Flavio Brasil
    • and contributors
  3. Free and Freer Monads: Putting Monads Back into Closet
    • Oleg Kiselyov
  4. Using, Understanding, and Unraveling The OCaml Language: The object layer
    • Didier Remy
  5. Composable Error Handling
    • Vladimir Keleshev
  6. Boring Haskell Manifesto
    • Michael Snoyman