I like using defdelegate
along with @behaviours
to make nice simple contract shim modules. The only gotcha is that defdelegate
is a compile-time construct, so the module that is delegated to is fixed per-env upon compile.
I’ve put together a little experiment that mirrors defdelegate
but enables runtime configuration of the delegated module…
It lets you define a contract module
defmodule SomeContract do
use RuntimeDelegate, default: DefaultImpl
@callback fun(binary, atom) :: term
defdelegate fun(foo, bar)
end
defmodule DefaultImpl do
@behaviour SomeContract
def fun(foo, bar) do
# Do the real thing
{__MODULE__, foo, bar}
end
end
defmodule AlternateImpl do
@behaviour SomeContract
def fun(foo, bar) do
# Do it another way
{__MODULE__, foo, bar}
end
end
And then you can control it at runtime:
assert {DefaultImpl, "foo", :bar} = SomeContract.fun("foo", :bar)
Application.put_env(:contract, SomeContract, AlternateImpl)
assert {AlternateImpl, "foo", :bar} = SomeContract.fun("foo", :bar)
It could be used to avoid hitting APIs in tests, or to do controlled rollout of new code, etc…
It’s not a lot of code, just a little macro that re-uses some of the code for Kernel.defdelegate
defmodule RuntimeDelegate do
defmodule DefDelegate do
defmacro defdelegate(fun) do
fun = Macro.escape(fun, unquote: true)
quote bind_quoted: [fun: fun] do
[{name, args, as, as_args}] = Kernel.Utils.defdelegate(fun, [])
def unquote(name)(unquote_splicing(args)) do
impl = Application.get_env(:contract, __MODULE__) || @default_impl
unless impl, do: raise(ArgumentError, "No implementation found")
impl.unquote(as)(unquote_splicing(as_args))
end
end
end
end
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@default_impl opts[:default]
import Kernel, except: [defdelegate: 2]
import RuntimeDelegate.DefDelegate
end
end
end
Thought I’d toss it out there to see what folks thought… I think of it in the spirit of Jose’s article about explicit contracts: http://blog.plataformatec.com.br/2015/10/mocks-and-explicit-contracts/