Hi there! I’ve been playing with Elixir for a while and stumbled upon dependency injection. The topic comes naturally to me as having C# background where it’s pretty-well known, especially amongst professionals. Since we are dealing with callbacks and behaviors instead of abstraction and implementation while there is no such concept as an interface (which by the way, I consider if occurred, would probably be somewhere between protocol and behavior in terms of both complexity and (supposedly) ergonomics), I find it as cumbersome - an obstacle that effectively stops me from considering Elixir at production whether it is small or medium-size project., not even mentioning larger ones.
Some people pointed me to libraries that concentrate on facilitating tests (in particular test doubles - mocks) although dependency injection has nothing to do with that (admittedly we can bring them together under certain circumstances). I would love to write beautiful, cohesive, loosely-coupled programs - just not sure if that is currently possible.
I thought that maybe I can utilize what’s already existing and achieve what I want with metaprogramming - for demonstration let’s consider the following example:
- We start with an interface:
i_writer.ex
defmodule IWriter do
@callback write() :: atom()
@callback write(args :: any()) :: atom()
@optional_callbacks write: 0, write: 1
defmacro __using__(_) do
quote location: :keep do
@behaviour IWriter
@after_compile IWriter
defp do_write(), do: IWriter.do_write(__MODULE__)
defp do_write(write_args), do: IWriter.do_write(__MODULE__, write_args)
end
end
def do_write(module),
do: fn -> apply(module, :write, []) end
def do_write(module, write_args),
do: fn -> apply(module, :write, write_args) end
def __after_compile__(env, _bytecode) do
:functions
|> env.module.__info__()
|> Keyword.get_values(:write)
|> case do
[] -> raise "`write/0` _or_ `write/1` is required"
[0] -> :ok # no args
[1] -> :ok # with args
[_] -> raise "Arity `0` _or_ `1` please"
[_|_] -> raise "Either `write/0` _or_ `write/1` please"
end
end
end
- Followed by composition:
text_writer.ex
defmodule TextWriter do
defstruct [:writer]
@type wrt :: IWriter
@opaque t :: %__MODULE__{
writer: wrt
}
@spec new(writer :: IWriter) :: TextWriter.t()
def new(writer) do
%__MODULE__{
writer: writer
}
end
@spec write_with(writer :: IWriter) :: :ok
def write_with(writer), do: writer.write()
@spec write_with(writer :: IWriter, args :: any()) :: :ok
def write_with(writer, args), do: writer.write(args)
end
And a sample implementation:
dummy_writer.ex
defmodule DummyWriter do
use IWriter
@type t :: __MODULE__
def write(), do: :dummy
end
Assuming the following invocation:
nw = TextWriter.new(DummyWriter)
nw.writer.write()
The drawback, one amongst many, is that Dialyzer does not help us here.
Some questions:
- Interface type - are there any plans for supporting it when proper type-system occurs in Elixir?
- Are there any relevant approaches for compile-time dependency injection in Elixir as for now?
- Are there any libraries out there that support all 4 dependency injection types (constructor injection, parameter injection, method injection, ambient context)?
…or maybe I’m thinking/doing something wrong? Please let me know.