Once an Elixir project is large enough, maintaining config files and configuration variables becomes a nightmare:
- Configuration variables are scattered throughout the code so it's very easy to forget a configuration setting.
- OS environment variables must be casted to the correct type as they are always strings.
- Required variables must be checked by hand.
- Setting defaults can sometimes be a bit cumbersome.
- No type safety.
Ideally though, configurations should be:
- Documented.
- Easy to find.
- Easy to read.
- Declarative.
In summary: easy to maintain.
The problem
We'll elaborate using the the following example:
config :myapp,
hostname: System.get_env("HOSTNAME") || "localhost",
port: String.to_integer(System.get_env("PORT") || "80")
The previous code is:
- Undocumented:
hostname
andport
of what? - Hard to read: Too many concerns in a single line.
- Hard to find: where are these
hostname
andport
used? - Not declarative: we're telling Elixir how to retrieve the values instead of what are the values we want.
Conclusion: it's hard to maintain.
Writing a config module
We could mitigate some of these problems with one simple approach:
- Create a module for your configs.
- Create a function for every single configuration parameter you app has.
The following, though a bit more verbose, would be the equivalent to the previous config:
defmodule Myapp.Config do
@moduledoc "My app config."
@doc "My hostname"
def hostname do
System.get_env("HOSTNAME") || "localhost"
end
@doc "My port"
def port do
String.to_integer(System.get_env("PORT") || "80")
end
end
Unlike our original code, this one is:
- Documented: Every function has
@doc
attribute. - Easy to find: We just need to to look for calls to functions defined in this module.
However, we still have essentially the same code we had before, which is:
- Hard to read.
- Not declarative.
There's gotta be a better way!
There is a better way - Meet Skogsrå
Skogsrå is a library for loading configuration variables with ease, providing:
- Variable defaults.
- Automatic type casting of values.
- Automatic docs and spec generation.
- OS environment template generation.
- Run-time reloading.
- Setting variable's values at run-time.
- Fast cached values access by using
:persistent_term
as temporal storage. - YAML configuration provider for Elixir releases.
The previous example can be re-written as follows:
defmodule Myapp.Config do
@moduledoc "My app config."
use Skogsra
@envdoc "My hostname"
app_env :hostname, :myapp, :hostname,
default: "localhost",
os_env: "HOSTNAME"
@envdoc "My port"
app_env :port, :myapp, :port,
default: 80,
os_env: "PORT"
end
This module will have these functions:
-
Myapp.Config.hostname/0
for retrieving the hostname. -
Myapp.Config.port/0
for retrieving the port.
With this implementation, we end up with:
- Documented configuration variables: Via
@envdoc
module attribute. - Easy to find: Every configuration variable will be in
Myapp.Config
module. - Easy to read:
app_env
options are self explanatory. - Declarative: we're telling Skogsrå what we want.
- Bonus: Type-safety (see Strong typing section).
How it works
Calling Myapp.Config.port()
will retrieve the value for the port in the following order:
- From the OS environment variable
$PORT
. -
From the configuration file e.g. our test config file might look like:
# file config/test.exs use Mix.Config config :myapp, port: 4000
From the default value, if it exists (In this case, it would return the integer
80
).
The values will be casted as the default values' type unless the option type
is provided (see Explicit type casting section).
Though Skogsrå has many options and features, we will just explore the ones I use the most:
Explicit type casting
When the types are not any
, binary
, integer
, float
, boolean
or atom
, Skogsrå cannot automatically cast values solely by the default value's type. Types then need to be specified explicitly using the option type
. The available types are:
-
:any
(default). -
:binary
. -
:integer
. -
:float
. -
:boolean
. -
:atom
. -
:module
: for modules loaded in the system. -
:unsafe_module
: for modules that might or might not be loaded in the system. -
Skogsra.Type
implementation: abehaviour
for defining custom types.
Defining custom types
Let's say we need to read an OS environment variable called HISTOGRAM_BUCKETS
as a list of integers:
export HISTOGRAM_BUCKETS="1, 10, 30, 60"
We could then implement Skogsra.Type
behaviour to parse the string correctly:
defmodule Myapp.Type.IntegerList do
use Skogsra.Type
@impl Skogsra.Type
def cast(value)
def cast(value) when is_binary(value) do
list =
value
|> String.split(~r/,/)
|> Stream.map(&String.trim/1)
|> Enum.map(String.to_integer/1)
{:ok, list}
end
def cast(value) when is_list(value) do
if Enum.all?(value, &is_integer/1), do: {:ok, value}, else: :error
end
def cast(_) do
:error
end
end
And finally use Myapp.Type.IntegerList
in our Skogsrå configuration:
defmodule Myapp.Config do
use Skogsra
@envdoc "Histogram buckets"
app_env :buckets, :myapp, :histogram_buckets,
type: Myapp.Type.IntegerList,
os_env: "HISTOGRAM_BUCKETS"
end
Then it should be easy to retrieve our buckets
from an OS environment variable:
iex(1)> System.get_env("HISTOGRAM_BUCKETS")
"1, 10, 30, 60"
iex(2)> Myapp.Config.buckets()
{:ok, [1, 10, 30, 60]}
or if the variable is not defined, from our application configuration:
iex(1)> System.app_env(:myapp, :histogram_buckets)
[1, 10, 30, 60]
iex(2)> Myapp.Config.buckets()
{:ok, [1, 10, 30, 60]}
Required variables
Skogsrå provides an option for making configuration variables mandatory. This is useful when there is no default value for our variable and Skogsrå it's expected to find a value in either an OS environment variable or the application configuration e.g. given the following config module:
defmodule MyApp.Config do
use Skogsra
@envdoc "Server port."
app_env :port, :myapp, :port,
os_env: "PORT",
required: true
end
The function Myapp.Config.port()
will error if PORT
is undefined and
the application configuration is not found:
iex(1)> System.get_env("PORT")
nil
iex(2)> Application.get_env(:myapp, :port)
nil
iex(3)> MyApp.Config.port()
{:error, "Variable port in app myapp is undefined"}
Strong typing
All the configuration variables will have the correct function @spec
definition e.g. given the following definition:
defmodule Myapp.Config do
use Skogsra
@envdoc "PostgreSQL hostname"
app_env :db_port, :myapp, [:postgres, :port],
default: 5432
end
The generated function Myapp.Config.db_port/0
will have the following @spec
:
@spec db_port() :: {:ok, integer()} | {:error, binary()}
The type is derived from:
- The
default
value (in this case the integer5432
) - The
type
configuration value (see the previous Explicit type casting section).
Conclusion
Skogsra provides a simple way to handle your Elixir application configurations in a type-safe and organized way. Big projects can certainly benefit from using it.
Hope you found this article useful. Happy coding!
This article is also available here: https://thebroken.link/skogsra-simplifying-your-elixir-configuration/.
Cover image by Lukasz Szmigiel
Top comments (2)
Wow, this is timely! I'm actually just now cleaning up some config and was wondering if there was a nicer way of handling it. I think Skogsrå might be it.
I'm glad you find it useful :)