Keathley

Runtime Configuration in Elixir Apps

January 9, 2020

I gave a talk last year about how to properly boot elixir applications. In the talk, I showed how to load configuration values into an ETS table on boot, and this was the same pattern that I used initially in Vapor. I now think that this is a bad idea.

The ideal way to configure all of your children processes looks like this:

defmodule MyApp do
  use Application

  def start(_type, _args) do
    children = [
      {Database, [db_host: "host", db_name: "blog_posts"]}, 
      {Api, port: 4000},
      {Cache, name: MyCache},
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    status = Supervisor.start_link(children, opts)
  end
end

The user passes all of the configuration values to each child process as arguments. The child processes are completely re-usable; I can choose to start as many of them as I want in whatever way I want.

If you followed my (bad) advice in the talk, then you would have ended up in a situation like this:

def start(_type, _args) do
  children = [
    ConfigStore,
    Database,
    Api,
    {Cache, name: MyCache},
  ]

  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
  status = Supervisor.start_link(children, opts)
end

The config won’t be loaded until the ConfigStore process has started. This delay means it’s not possible to pass configuration down as arguments, and each child needs to fetch configuration when it starts like so:

defmodule Database do
  def init(args) do
    db_port = ConfigStore.get(:db_port)
    db_name = ConfigStore.get(:db_name)

    {:ok, [db_port: db_port, db_name: db_name]}
  end
end

Fetching config in init only works if you have control over the modules init callback. If you wrote the module, then you can do what you want. If the process came from a library, then you’re probably limited. But, even if you could override init, you shouldn’t. Fetching config in the init callback couples the process to the configuration provider, which, in effect, couples the process to how you boot your application. None of this is good.

You could start your ConfigStore in the application start, which would allow you to pass arguments again.

def start(_type, _args) do
  ConfigStore.start()

  children = [
    {Database, [
      db_host: ConfigStore.get(:db_host),
      db_name: ConfigStore.get(:db_name)]},
    {Api, port: ConfigStore.get(:web_port)},
    {Cache, name: MyCache},
  ]

  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
  status = Supervisor.start_link(children, opts)
end

But now we’ve lost the ability to control the lifecycle of the config process. We’ve lost our ability to restart or recover from exceptions. If we make the mistake of linking the ConfigStore to our application process, then we could crash the entire app.

What’s the goal?

All of our children processes should be configurable by passing arguments to them. We shouldn’t couple them to any global configuration system (this includes Application env).

When loading configuration, we need to enforce that all of the required config values are present. If anything is missing or doesn’t conform correctly, the user should be free to halt the boot process or trigger an alarm. Those are the goals.

Vapor

Vapor is a library that I’ve been toying with to try to encapsulate these patterns. The latest version includes some breakages, but I think they’re for the better. Like most design problems, the real solution was to do fewer things. With the latest version of Vapor you’ll be able to do this:

defmodule MyApp do
  use Application

  def config!() do
    providers = [
      %Env{bindings: [
        db_host: "DB_HOST",
        db_name: "DB_NAME",
        web_port: "PORT",
      ]
    ]

    Vapor.load!(providers)
  end

  def start(_type, _args) do
    config = config!()

    children = [
      {Database, [db_host: config.db_host, db_name: config.db_name]}, 
      {Api, port: config.web_port},
      {Cache, name: MyCache},
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    status = Supervisor.start_link(children, opts)
  end
end

Vapor allows users to specify a list of providers, and the configuration values the provider should return. It then “loads” the configuration from each provider and returns a map. In the example above, if any of the values are missing, an exception is thrown. If throwing exceptions isn’t your jam, there is also load/2, which returns the standard ok-error-tuple. The user is free to do whatever they want with the map. They can configure their processes once and throw it away, store it in ETS, Application.put_env, or whatever else.

There are a bunch of other features in Vapor so check it out if it seems interesting to you.

Conclusion

Even if you don’t want to use Vapor, I hope that this at least showcases some useful patterns. Avoid coupling your processes to any global configuration. If you’re going to fetch application configuration at runtime, then it should enforce the values that you’ve specified. Finally, library authors: If you’re going to spawn processes, let me pass arguments to you. Don’t use application config unless there is no other option (side note: there’s always another option).

I have more to say about patterns for avoiding Application.get_env but that deserves a separate post.