Skip to content

Instantly share code, notes, and snippets.

@timruffles
Last active June 11, 2020 04:23
Show Gist options
  • Save timruffles/036b9782472e5dd0844d to your computer and use it in GitHub Desktop.
Save timruffles/036b9782472e5dd0844d to your computer and use it in GitHub Desktop.
Approaches to dependency-injection/dynamic dispatch in elixir

In many production systems you'll want to have one module capable of talking to many potential implementations of a collaborator module (e.g a in memory cache, a redis-based cache etc). While testing it's useful to control which module the module under test is talking to.

Here are the approaches I can see. The two points that seem to divide the approaches are their tool-ability (dialyzer) and their ability to handle stateful implementations (which need a pid).

Passing modules

Modules are first class, so you can pass them in. Used in EEx, where passed module must implement a behaviour.

defmodule Cache do
  use Behaviour
  defcallback cached?(any,any) :: boolean
  defcallback put(any,any) :: nil
end

defmodule Cache.Memory do
  def put(set,x) do: Set.add set, x
  def cached?(set,x) do: Set.member? map, x
end

defmodule Cache.Redis do
  def put(redis_pid,x) do 
    {:ok,1} = Redis.set redis_pid, x, 1
  end
  def cached?(redis_pid,x) do
    {:ok,x} = Redis.get(redis_pid,x)
    x != nil
  end
end

# usage
defmodule UsesCache do
  def start(cache,cache_pid) do
    cache.put(cache_pid,:hello)
    true = cache.cached?(cache_pid,:hello)
  end
end

UsesCache.start(Cache.Memory,HashSet.new)

Similar idea to duck-typing.

  • simple
  • dializer(?)
  • modules with state - you'd have to pass a pid too, e.g {module,pid} (eugh)

Protocols + values

Write a Protocol for the functionality. You can then pass in an opaque value to collaborators, and the implementation will be decided at runtime.

  • handles stateful and stateless implementations easily
  • dialyze-able
  • requires stub implementations for testing
defprotocol Cache do
  def cached?(id,item)
  def put(id,item)
end

defmodule Cache.Memory do
  defstruct set: nil
  alias __MODULE__, as: Mod
  defimpl Cache, for: Mod do
    def put(%Mod{set: set},x) do: Set.add set, x
    def cached?(%Mod{set: set},x) do: Set.member? map, x
  end
end

defmodule Cache.Redis do
  defstruct redis: nil
  alias __MODULE__, as: Mod
  defimpl Cache, for: Mod do
    def put(%Mod{redis: redis},x) do
      {:ok,1} = Redis.set redis, x, 1
    end
    def cached?(%Mod{redis: redis},x) do
      {:ok,x} = Redis.get(redis,x)
      x != nil
    end
  end
end

# usage
defmodule UsesCache do
  def start(cache) do
    Cache.put(cache,:hello)
    true = Cache.cached?(cache,:hello)
  end
end

UsesCache.start(%CacheMemory{set:HashSet.new})

Passing callbacks

For a single method, you could just pass a function. Then in tests you pass a stub method, and in production you can wrap up the real module behind it.

def start_link({some_callback}) do
  :gen_server.start_link(@name,SomeModule,{some_callback},[])
end
    
def init({some_callback}) do
  {:ok,%State{callback: some_callback})
end

Now the callback field of state can be used by functions of this gen_server module.

# usage
defmodule UsesCache do
  def start(put,cached) do
    put.(:hello)
    true = cached.(:hello)
  end
end

# can create callbacks from anything: stateful, stateless etc
  • works with any implementation, even ad-hoc
  • dialyze-able
  • pass many values for large APIs

Stateful

Create a stateful module that holds the module, refer to that.

  • doesn't work for use-cases with many instances (pass-the-pid)
  • dialyzer - need a Behaviour for the return type of the getter

Module generation

You could generate a module based on a run-time config.

  • moving parts
  • doesn't work for use-cases with many instances (pass-the-pid)
  • dialyzer - seems likely to throw it off
@pmarreck
Copy link

pmarreck commented Feb 6, 2015

You discuss a few approaches here, but have you found a solution that works for you yet?
As a person who's worked in large codebases and seen the benefits of both purity and isolated unit testing, I'm quite concerned about true isolation and DI and this sort of thing, as I explore Elixir.

@mbuhot
Copy link

mbuhot commented Jul 28, 2016

An experimental approach to dependency injection that runs at compile time: https://github.com/mbuhot/ex_module_params
Allows you to parameterize your modules by the names of the dependencies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment