Dependency injection & design by interface / SMI

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:

  1. 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
  1. 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.

1 Like

There are neither constructors nor methods in elixir, given there’s no objects. You can pass module names to functions, so parameter injection just works as far as I’m aware. No need for any library. Not sure what ambient context is meant to be.

Can you elaborate a bit more? It’s not exactly sure what your problem with behaviours is in the first place.

1 Like

I have a feeling that this looks like class reinvention from languages like c# and java.

What is the actual gain of having the IWriter call the actual functions from the modules you define? if that is only to enforce the IWriter type for specs there are easier methods to do that, putting your data in a struct of type IWriter.

That aside, by using apply/3 you lose all the compile-time power as apply is executed at runtime, so dialyzer cannot solve your types at compile-time.

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.

This is quite sad to hear, as I think getting rid of classes makes the code much more readable and maintainable in the long run. Maybe instead of trying to fit elixir to the way things are done in c#, you should take a step back and learn how things are done in functional languages.

3 Likes

This is quite sad to hear, as I think getting rid of classes makes the code much more readable and maintainable in the long run. Maybe instead of trying to fit elixir to the way things are done in c#, you should take a step back and learn how things are done in functional languages.

How so? I’m not sure why do you make such association. If treating interface as a concept specific to given paradigm or language is this the way to go then I’d rather see how one would describe the relation between abstraction and interfaces language-wise (since we’re on Elixir forum → in Elixir terms), but maybe that’s just me.

Once again, I will stick to the thing that I think that interfaces and the way classes inherit them is a prominent feature OOP languages like c# and java.

Now saying all of this, I’ve had a project where we would use behaviors to implement multiple modules with same functions, then call the modules dynamically (since the module name is just an atom) based on a configuration.

From your post, it seems that you want to enforce this thing at compile-time, but the question is why and how is this different from protocols (not as implementation but as a end result)? and the other question is if you were to remove your functions from data structures and use a more functional approach, would those interfaces even make sense?

1 Like

After reading your code and post, I am still not 100% sure what you are after. “Dependency injection” is just a tool, and tools are used to achieve goals. What’s your goal?

If you want to have a discrete set of implementors of a certain behaviour / protocol (the Elixir meanings of them) and then look them up / use them wherever the behaviour / protocol is needed then that can be done fairly easily and we can help you get there.

You do need to show an open mind however; insisting that Elixir must have what C# has is… not an interesting discussion. Elixir has the tools to achieve similar goals.

2 Likes

Golang for instance, whether we consider paradigm or not, has support for interfaces and of course, inheritance (more formally: generalization) is not the only relation.
When it comes to me, I tend to use interfaces as modelling technique that is somewhat useful in design - I’d even call it as fundamental building block.

Did this hinder your ability to analyze the solution using xref or similar tool, retrospectively?

This is rather about encapsulation levels - my ‘mental model’ consists of the following:

  • Protocol - extension anchor, allows you to add something without changing module contents
  • missing Interface - pure form, no side effects
  • Behavior (e.g. GenServer) - impure interface, possible side effects

My thinking was that in order to have the pure interface form I can either leverage protocol or ‘strip’ behavior.

I’m not sure whether the problem sits in behavior construct itself or the way it is used, e.g. consider this:

defmodule Sample do
  @callback some_callback() :: :ok
  @some_mod Application.get_env(:my_app, :some_mod)

  defdelegate some_callback(), to: @some_mod
end

It just seems odd to me.

If you could suggest some literature or articles to read then I’d be grateful - always happy to learn something new.

Agree - that’s why I don’t insist.

So behaviours are generally meant so you can customize the internals of a large whole. E.g. for a GenServer you can just deal with some well selected callbacks of “here I want your input”, while the bulk of the process loop and a lot of internals and implementation details are hidden from you.

Your example behaviour is so shallow I’m wondering if there’s any use to it besides a small bit of documentation.

It also still doesn’t show what you’re “missing”. Some feeling of odd might be expected dealing with a new way of handling things, but I’m still not sure what all the complexity you presented in your initial post is meant to bring/add to the table.

2 Likes

My initial reaction is that there’s something missing / inconsistent with the example:

  • the private do_write functions that are injected by use IWriter don’t appear to be called anywhere

  • the write_with functions in TextWriter aren’t called anywhere either

  • calling a function through this mechanism requires accessing the internals of an opaque type (with nw.writer)

There are some other parts that seem odd:

  • in TextWriter, @type wrt :: IWriter - this declares wrt as a type inhabited by exactly one member, the atom :"Elixir.IWriter". You’d typically see this kind of construct used to create an enum-like with multiple atoms (@type something :: :foo | :bar | :baz) but the compiler is perfectly fine with exactly one

  • similarly, TextWriter.t() is a struct where the only expected value of the writer field is that same atom. This is consistent with the typing on TextWriter.new/1 because it only promises to accept :"Elixir.IWriter" as an argument.

  • however, that means that this doesn’t satisfy the typespec: TextWriter.new(DummyWriter) since DummyWriter is not the expected atom


Regarding the overall concept, IMO it’s reimplementing OO awkwardly with “objects” like TextWriter needing to explicitly hold a vtable-like thing.

You may find the discussions around the removal of “tuple calls” from the BEAM back in 2017 interesting:

The IWriter/DummyWriter modules appears to be boilerplate from an elixir standpoint, so it’s quite difficult to understand your goal.

After reading this thru twice, I think what you’re after is “protocols”. They allow polymorphism of by dynamically dispatching based on the value type. I believe that you can also call modules dynamically which can allow an interface definition that can be verified by dialyzer.

2 Likes

Protocols (dynamic dispatch per object instance): https://elixir-lang.org/getting-started/protocols.html

Behaviors (not-exactly-dynamic dispatch per module, not per object instance): https://elixir-lang.org/getting-started/typespecs-and-behaviours.html

Just make a small project, open up iex -S mix when you cd into it and experiment by yourself. Elixir has a super-enabling REPL, use it to your full advantage. It’s a very freeing feeling and I bet you’ll miss it in the languages that don’t have it afterwards. :smiley:

1 Like

I’m too late in the discution but the simplest way of doing dependency injection in any functional language is just passing a function. there are ways to define a contract to be followed(behaviours, that some people already mentioned), but this is loosely restrict by the runtime.
Going with functions, you could have something like:

def write_with(writer) when is_function(writer, 0), do: writer.()
def write_with(writer, args) when is_function(writer, 1), do: writer.(args)
def write_with(writer, args) when is_function(writer), do: apply(wrter, args)

edit:
Usually I go with behaviours for stuff that I need a set of functions that need to be implemented and to be used together.

3 Likes