Trying to understand how to pick a Behaviour implementation

I’m trying to figure out which is the “best” way to pick a behaviour implementation. I’ve found a couple of examples in other libraries but I haven’t found the rationale behind the design choices. These are the ideas I’ve found so far, and I would like to make sure I understood everything right before continuing to write any code.

Say I have a behaviour FileStorage.Store, and I have multiple implementations for that behaviour: Store.Local, Store.S3, Store.GCS, etc. The module FileStorage is the public API of the feature, and it somehow has to select an implementation.

Reading a bunch of libraries’s source code I’ve found two or three general approaches to this:

The first one is using a private function to get the implementation from the application env:

def action(args) do
  impl = get_impl_from_application_env()
  impl.callback(args)
end

This means that there is a global configuration for the module, something in the lines of:

config :my_app, FileStorage, store: FileStorage.Store.Local

An example of this approach is the Arc library.
This works, but has the limitation that if I need to add a secondary storage, I have to reimplement the public API module with a different config.

The second approach seems to mitigate the limitations of the previous one:

def action(impl, args) do
  impl.callback(args)
end

Now that the implementation is passed to the API function, it’s easy to use different implementations in different places. It enables you to do something like this:

defmodule ModuleA do
  alias FileStorage

  @store FileStorage.Store.Local
  def action(args) do
    FileStorage.save(@store, args)
  end
end

defmodule ModuleB do
  alias FileStorage

  @store FileStorage.Store.S3
  def action(args) do
    FileStorage.save(@store, args)
  end
end

Now the problem is that there’s a lot of repeated code since each module that wants to use FileStorage functions has to pass it the implementation it wants it to use.

I’ve seen libraries use a bit of metaprogramming mixed with a new behaviour to avoid this. The most notable example is Ecto’s Repo. So you’d have something like this:

defmodule FileStorage do
  @callback action(args :: any()) :: :ok | {:error, any()}

  defmacro __using__(opts) do
    quote do
      @behaviour unquote(__MODULE__)

      store = get_store(unquote(opts))
      @store store

      def action(args) do
        FileStorage.action(@store, args)
      end
    end
  end

  def action(impl, args) do
    impl.some_function(args)
  end
end

defmodule ModuleA do
  use FileStorage, store: FileStorage.Store.Local
end

defmodule ModuleB do
  use FileStorage, store: FileStorage.Store.S3
end

defmodule ModuleC do
  def some_function(args) do
    FileStorage.action(FileStorage.Store.GCS, args)
  end
end

Now I can have multiple modules that can configure the way FileStorage is used, both by useing it and by calling it’s functions directly with some specific implementation.

So in short, one can pick an implementation by:

  • Having a private function fetch it from the application’s configuration
  • Having many modules call the api module passing it the implementation to use
  • Use metaprogramming to let other modules configure the way the api is used

Is this how it’s usually done, or I am misinterpreting something?
Thanks!

1 Like

I think store configuration for each module should be moved to config too, to have an ability to change it in tests to some mock. Actually, Ecto works this way.

2 Likes

I think I get it.
So instead of

use FileStorage, store: FileStorage.Store.Local

would be

use FileStorage, otp_app: :my_app

the get_store/1 function(inside the __using__ macro) would fetch the implementation from config like this:

def get_store(opts) do
  otp_app = Keyword.fetch!(opts, :otp_app)
  Application.get_env(otp_app, __MODULE__)
  |> Keyword.fetch!(:store)
end

and in config:

config :my_app, ModuleA,
  store: FileStorage.Store.Local

That would be similar to the config/0 function injected by useing Ecto.Repo

I’m wondering if dialyzer can catch bugs when you use a module this way?

No, dialyzer won’t help you here.

1 Like