Configuring Elixir Libraries

Some time ago I wrote an article on error handling in Elixir libraries. Today, I’d like to follow up with a piece on an equally important issue - proper handling of configuration.

The status quo

Mix configuration is great - it allows configuring libraries in a single and unified way. You just add couple config lines to config/config.exs and you’re done. Easy, right? Not so fast. There are couple caveats that you need to think about. One, the OTP application configuration (mix config is just an interface to OTP application configuration) is global for the whole application. The other, mix config requires mix to run (duh!) but there are situations where mix is not available - most notably releases.

When is mix config evaluated?

So here we arrive at the first misconception. I often hear that mix config is evaluated at compile-time. This is not true. Mix config is evaluated when mix starts - when you call mix phoenix.server or mix release. So what’s the problem? With a release, mix config will be evaluated when you run mix release and then serialised into a release-compatible sys.config file that contains plain data. This means any dynamic config (like System.get_env calls) has to be evaluated before the release is built and will be “frozen” inside the release itself.

Reading configuration at compile-time

By far, one of the biggest problems people are hitting with configuration in Elixir stems from reading it at compile-time. Most usually, this involves something similar to:

@foo Application.get_env(:foo_app, :foo)

This has several disadvantages. First, the configuration needs to be available at compile-time, but it will also be completely frozen and can’t be changed without recompiling the module in question. Even worse is assigning the result of System.get_env to module attributes. Mix will recompile the whole application when the application configuration changes, but that’s not the case with system environment since it would be awful to track.

Of course, there are situations where reading config at compile-time makes sense. Primarily if the configuration affects how a module is compiled - for example Ecto’s adapter or for performance reasons - for example in mime package. I would argue, though, that those cases are rather rare. Reading from application environment is really fast and shouldn’t be an issue in most cases - don’t assume it will be an issue for you without measuring.

The :system tuple

The community arrived at a solution quite quickly. Instead of passing the real value when dynamic configuration is needed, instead, you could just use a {:system, "VAR_NAME"} tuple and the library will call System.get_env("VAR_NAME") to get the real value for you at runtime. Sounds like a perfect solution, right? Unfortunately, once again we hit some issues:

  • every library that wants to support it has to implement the support for this pattern explicitly.
  • as a user of a library, you need to go spelunking in the docs to find out if the library you want to use supports it on the configuration key you actually need. If not - too bad.
  • The system environment can only return strings. Do you need an integer? Too bad.
  • this only works for configuration variables coming from system environment. What if you want to load your configuration from, for example, Hashicorp’s Vault? Now you have to scramble for a solution.

The command line and --erl

This is rarely used, in my experience, but is generally a very powerful tool. You can set application environment variables from the command line when starting the VM - be it in a release, though mix or even just running a script. For example:

elixir --erl "-app key value" -S mix phoenix.server

will make Application.get_env(:app, :key) return :value. This allows setting any variable to any value - not only to strings. The main problem? The syntax for value is obviously Erlang and not Elixir.

If you’re not sure how an elixir value would look like with Erlang syntax, you can use :io.format("~p~n", [value]) to print it - Erlang’s equivalent to our IO.inspect(value).

Distillery’s REPLACE_OS_VARS

Distillery (the tool for building OTP releases for Elixir applications) and its predecessor Exrm (through Erlang’s relx), offer an alternative solution. You can set any configuration value to the string "${VAR_NAME}" and when the application is started (and the “magic” REPLACE_OS_VARS environment variable is set), those config values will be replaced with the value of the appropriate system’s variable just before the release boots. While this solves the problem of universality, it still only accepts strings and only works with system environment.

Fortunately, we can link this technique with the previous one - passing variables through the command line. A distillery release includes a vm.args file that lists any command line arguments to the VM that is included whenever the release is started. This means we can add

-app key "${VAR_NAME}"

to the vm.args file of a release and the REPLACE_OS_VARS mechanism will use its magic to make the configuration available for us. And now we can use any type of variable we want - not just strings (we just need to remember and use Erlang syntax).

The way forward

Let’s get back for a moment from solutions specific to releases to the generic one and to the :system tuples. The primary libraries that supported it were Phoenix & Ecto, but they both started offering an alternative in their latest versions - the init/2 callback. This mechanism is also marked as preferred way of achieving dynamic configuration.

Both Phoenix’ Endpoint module and Ecto’s Repo module gained a new callback - init/2 that is called just before the endpoint or repo is started. It works very much like an init/1 callback in a GenServer. Its purpose? Provide configuration and establish the initial state of the subsystem. The callback is capable of executing any code and thus allows you to read the configuration from wherever you please - system variables, Vault, file system, you name it. You can also do any required type conversions. Both Ecto and Phoenix still defaults to reading from the application environment - the new mechanism is preferred if you need to make your configuration a bit more dynamic. This means the default experience is just as streamlined as before, but when you need more flexibility, it’s there.

Recommendations for library authors

When designing a library, very often you’ll need some sort of configuration. It might be the number of processes you’re going to start or an API key or something completely different. We can discern three different kinds of libraries:

Stateless libraries

This is probably the most common example. These are the libraries that don’t deal with processes at all or only start some short-lived processes (like tasks). Some examples of this category are bag-of-functions modules, API wrappers, and similar.

The simplest way to do configuration, in this case, is most of the time also the best one - just accept it as arguments to the functions. This gives all the power to choose the right configuration approach to the consumer of the library. They can easily read it from the application environment, system environment, or some third party config store. It’s also easy to make it not-too-verbose by wrapping the library functions in their own functions.

If you’re afraid that this will make your library verbose, you could leverage application environment for a default configuration. But I’m convinced accepting configuration for plain functions through plain arguments is a superior approach. It easily allows the library to be used “multiple times” in an application - with a different configuration in different subsystems. It can also make testing much easier.

Let’s take a look at an example interface of an API client library that supports default options in application environment that can be easily changed:

  • The library exports a client/1 function that reads default configuration from application env and accepts overrides and we wrap it in our own client/0 function:

    def client() do
      FancyAPI.client(api_key: System.get_env("FANCY_API_KEY"))
    end
    
  • We set defaults or values that are not dynamic in config/config.exs:

    config :fancy_api, adapter: FancyAPI.FooAdapter
    
  • Later when calling the API functions, we call our client function and pass it as an argument. If needed we could further customise it to support passing some other options directly at callsite.

    FancyAPI.Data.get("some-id", client())
    
  • If needed we can define multiple functions like client/0 in our application or even have a client/1 that would accept further overrides from the final call-site.

Using an approach similar to what I presented above gives us the best of both worlds - keeps the defaults simple and yet when more flexibility is needed, it’s trivial to achieve because we’re just calling functions.

We also solve one of the biggest problems of configuring libraries globally through application environment - if your application requires using the library in two different contexts (with separate API keys). When all the configuration is loaded directly from application env, you can’t do anything about it, but with an approach similar to what I showed above, you could easily define multiple “client” functions.

Callback-module stateful libraries

The second kind of libraries is similar to Phoenix or Ecto. They deal with processes, but instead of starting them in their own supervision tree, the processes are started in the end application’s supervision tree. This means there’s usually some sort of callback module that can contain an init callback or any required configuration can be accepted through a start_link function (this is, for example, what Plug does).

Once again, application environment can be leveraged for defaults but overall, the primary configuration mechanism should be passing arguments to functions. This also guarantees maximum of flexibility and power for the user of the library.

Full-blown applications

The last, and arguably hardest to configure, kind of libraries out there are full-blown applications. They start all of the processes in their own supervision tree and as a user, you have very little influence over what the application does. Fortunately, those libraries are very rare.

What makes them hard to configure is the fact they all of the configuration needs to be already there once they start, and if they are a dependency of some other application, all of the initialization code will be executed before the “master” application can even run. Because of those inherent issues, I would generally recommend converting the “full-blown” application libraries into a callback-module style discussed above.

Conclusion

Even though Elixir features a unified and pleasant configuration mechanism, there are some use cases that call for more flexibility. Fortunately, there are usually some solutions that allow achieving the desired outcome.

As to the libraries, my basic recommendation for authors of libraries is very similar to the one I gave in the error handling in Elixir libraries post - leave as much control as possible to the consumer of your library. Don’t assume how it will be used. This will save time both for the users and you with fewer support questions in the future.

Thanks to Wojtek Mach and José Valim for reviewing the draft version of this post.

Where is my comment box!?

I don't do traditional comments, but you're welcome to send me an email to michal at muskala dot eu and I'll publish it at the bottom of the article as a comment.