Runtime configuration for a dependent library

We are in the process of moving some shared functionality (mail sending) into a separate application that’s a dependency of our main app. That is, a shared library.

The problem we’re running into is that the mail configuration currently lives inside config/runtime.exs in our main application. If we move it to config/runtime.exs inside the dependent library, then Application.get_env(:mailer, …) doesn’t find it anymore. It would seem that only the “main” runtime.exs file is being loaded by mix run.

I see some libraries use files like config/prod.exs, but I want the shared configuration to read environment variables at startup. Is there a pattern that will let me do this?

Not just runtime.exs. All config can only come from the top level mix project you’re on. Config of dependencies is ignored.

5 Likes

The simplest solution to your problem is to define a wrapper module that would explicitly pass runtime configuration to your library:

defmodule MyLibrary.Mailer do
  def send(from, to, subject, body, options) do
    api_key = Access.fetch!(options, :api_key)
    # send email
  end
end
defmodule MyApp.Mailer do
  def send(from, to, subject, body, options \\ []) do
    options = Keyword.merge([api_key: Application.fetch_env!(:my_app, :mailer_api_key)], options)

    MyLibrary.Mailer.send(from, to, subject, body, options)
  end
end

If you don’t mind using a little metaprogramming magic you can reduce your app’s wrapper module down to a single line at the cost of increased complexity in the library. Something like this:

defmodule MyApp.Mailer do
  use MyLibrary.Mailer, mailer_config: {:my_app, :mailer}
end
defmodule MyLibrary.Mailer do
  @callback send(from :: String.t(), to :: String.t(), subject :: String.t(), body :: String.t()) ::
              :ok | {:error, reason :: term()}

  defmacro __using__(opts) do
    quote bind_quoted: [behaviour_module: __MODULE__, opts: opts] do
      @behaviour_module behaviour_module
      @behaviour @behaviour_module

      @mailer_config Keyword.fetch!(opts, :mailer_config)

      def send(from, to, subject, body) do
        @behaviour_module.send(from, to, subject, body, @mailer_config)
      end
    end
  end

  def send(from, to, subject, body, {app, key}) do
    config = Application.fetch_env!(app, key)
    IO.inspect(config)
    # send email
  end
end

Not only that, but it is generally discouraged to use the config for libraries (see the infobox at Config — Elixir v1.16.2).

I personally consider one’s “internal” dependencies to be an exception, and so this is my approach to runtime config for such libraries:

in the main app’s runtime.exs:

[SubApp1.RuntimeConfig]
|> Enum.each(&apply(&1, :config))

in the dependency (at lib/runtime_config.exs):

defmodule SubApp1.RuntimeConfig do
  def config do
    import Config
    
    config :sub_app1,
    	key: System.get_env("SUB_APP1_key", "default_val")
1 Like

This approach is in no way better than manually providing config for the dependency. The info box wants people to not use the application enc at all: Design-related anti-patterns — Elixir v1.16.2

Yeah that’s why I only suggested it for “internal” libraries, with the advantage of keeping the config in the same repo as the code rather than copy/pasting it all in runtime.exs but YYMV…