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
).
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.
pid
too, e.g {module,pid}
(eugh)Write a Protocol
for the functionality. You can then pass in an opaque value to collaborators, and the implementation will be decided at runtime.
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})
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
Create a stateful module that holds the module, refer to that.
You could generate a module based on a run-time config.