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 use
ing 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!