dorgan

dorgan

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!

Showing Posts 1 to 3

fuelen

fuelen

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.

dorgan

dorgan OP

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?

fuelen

fuelen

No, dialyzer won’t help you here.

— All posts loaded —

Where Next? Top

Trending in Chat/Questions Top

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge & Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews