Elixir Configuration Management Patterns
This document outlines patterns and examples for migrating from Ruby's configuration management approach to Elixir's Application-based configuration system.
Table of Contents
- Introduction
- Configuration System Comparison
- Basic Configuration Patterns
- Environment-Specific Configuration
- Runtime vs. Compile-Time Configuration
- Configuration Validation
- Accessing Configuration
- Organizing Configuration
- Handling Sensitive Configuration
- Testing Configuration-Dependent Code
- Migration Examples
Introduction
Elixir's configuration system is built around the concept of OTP Applications. Unlike Ruby's approach of loading YAML files or using environment variables directly, Elixir provides a structured configuration system through its Application environment.
This document provides patterns and examples for migrating from our Ruby configuration approach to Elixir's Application-based configuration.
Configuration System Comparison
Ruby Configuration (Current Approach)
Our current Ruby configuration system uses:
- YAML files for structured configuration
- Environment variables for environment-specific settings
BookProcessing::Configurationfor loading and accessing configuration- Runtime validation of configuration values
# Example of our current Ruby configuration approach
module BookProcessing
class Configuration
def self.load(config_path)
config = YAML.load_file(config_path)
config = apply_environment_overrides(config)
validate_configuration(config)
config
end
def self.apply_environment_overrides(config)
# Override with environment variables
config['output_dir'] = ENV['OUTPUT_DIR'] if ENV['OUTPUT_DIR']
# ...
config
end
def self.validate_configuration(config)
raise "Missing output_dir" unless config['output_dir']
# ...
end
end
end
# Usage
config = BookProcessing::Configuration.load('config/book.yml')
output_dir = config['output_dir']
Elixir Configuration (Target Approach)
Elixir's configuration system uses:
- Configuration files in
config/directory - Application environment for storing and retrieving configuration
- Mix environment for environment-specific configuration
- Compile-time and runtime configuration options
# Example of Elixir configuration approach
# In config/config.exs
import Config
config :elib,
output_dir: "output",
default_format: :pdf
# In config/dev.exs
import Config
config :codexis,
debug_mode: true
# In lib/elib/config.ex
defmodule Codexis.Config do
@moduledoc """
Configuration management for Codexis.
"""
@doc """
Get a configuration value with optional default.
"""
def get(key, default \\ nil) do
Application.get_env(:elib, key, default)
end
@doc """
Validate the application configuration.
"""
def validate! do
output_dir = get(:output_dir)
unless output_dir, do: raise "Missing output_dir configuration"
# ...
:ok
end
end
# Usage
output_dir = Codexis.Config.get(:output_dir)
Basic Configuration Patterns
Defining Configuration
In Elixir, configuration is typically defined in the config/config.exs file:
# config/config.exs
import Config
config :elib,
output_dir: "output",
formats: [:pdf, :epub, :mobi],
default_format: :pdf,
timeout: 30_000
This is equivalent to the following Ruby YAML configuration:
# config/book.yml
output_dir: output
formats:
- pdf
- epub
- mobi
default_format: pdf
timeout: 30000
Accessing Configuration
In Elixir, you access configuration through the Application module:
# Direct access
output_dir = Application.get_env(:elib, :output_dir)
# With default value
timeout = Application.get_env(:elib, :timeout, 60_000)
This is equivalent to the following Ruby code:
# Direct access
output_dir = config['output_dir']
# With default value
timeout = config['timeout'] || 60_000
Creating a Configuration Module
It's a common pattern in Elixir to create a dedicated configuration module:
defmodule Codexis.Config do
@moduledoc """
Configuration management for Codexis.
"""
@doc """
Get a configuration value with optional default.
"""
def get(key, default \\ nil) do
Application.get_env(:elib, key, default)
end
@doc """
Get a nested configuration value with optional default.
"""
def get_in(keys, default \\ nil) when is_list(keys) do
config = Application.get_all_env(:elib)
case Kernel.get_in(config, keys) do
nil -> default
value -> value
end
end
end
# Usage
output_dir = Codexis.Config.get(:output_dir)
pdf_dpi = Codexis.Config.get_in([:pdf, :dpi], 300)
This is equivalent to our Ruby configuration access:
# In BookProcessing::Configuration
def self.get(config, key, default = nil)
config[key] || default
end
def self.get_nested(config, keys, default = nil)
result = keys.reduce(config) do |acc, key|
acc.is_a?(Hash) && acc.key?(key) ? acc[key] : nil
end
result || default
end
# Usage
output_dir = BookProcessing::Configuration.get(config, 'output_dir')
pdf_dpi = BookProcessing::Configuration.get_nested(config, ['pdf', 'dpi'], 300)
Environment-Specific Configuration
Elixir Environment Configuration
Elixir uses Mix environments (dev, test, prod) for environment-specific configuration:
# config/dev.exs
import Config
config :elib,
debug_mode: true,
log_level: :debug
# config/test.exs
import Config
config :elib,
output_dir: "test/output",
log_level: :warn
# config/prod.exs
import Config
config :elib,
debug_mode: false,
log_level: :info
These files are imported by config/config.exs:
# config/config.exs
import Config
# Base configuration...
import_config "#{config_env()}.exs"
Using Environment Variables
For configuration that should come from environment variables:
# config/runtime.exs
import Config
if config_env() == :prod do
config :elib,
output_dir: System.get_env("OUTPUT_DIR") || "output",
aws_key: System.fetch_env!("AWS_ACCESS_KEY")
end
This is equivalent to our Ruby environment handling:
def self.apply_environment_overrides(config)
config['output_dir'] = ENV['OUTPUT_DIR'] if ENV['OUTPUT_DIR']
config['aws_key'] = ENV.fetch('AWS_ACCESS_KEY')
config
end
Runtime vs. Compile-Time Configuration
Elixir distinguishes between compile-time and runtime configuration:
Compile-Time Configuration
Configuration in config/*.exs files is evaluated at compile time:
# config/config.exs
import Config
config :elib,
static_setting: "This is set at compile time"
Runtime Configuration
For configuration that needs to be determined at runtime:
# config/runtime.exs
import Config
config :elib,
dynamic_setting: System.get_env("DYNAMIC_SETTING", "default")
You can also update configuration at runtime:
# Update configuration at runtime
Application.put_env(:elib, :dynamic_setting, new_value)
Configuration Validation
Validating at Application Start
Validate configuration when your application starts:
defmodule Codexis.Application do
use Application
@impl true
def start(_type, _args) do
# Validate configuration
Codexis.Config.validate!()
children = [
# ...
]
opts = [strategy: :one_for_one, name: Codexis.Supervisor]
Supervisor.start_link(children, opts)
end
end
Configuration Validation Module
Create a dedicated module for configuration validation:
defmodule Codexis.Config do
# ... other functions ...
@doc """
Validate the application configuration.
Returns :ok or raises an error.
"""
def validate! do
validate_required_settings!()
validate_paths!()
validate_formats!()
:ok
end
defp validate_required_settings! do
required = [:output_dir, :formats]
missing = Enum.filter(required, fn key ->
get(key) == nil
end)
unless Enum.empty?(missing) do
raise "Missing required configuration: #{Enum.join(missing, ", ")}"
end
end
defp validate_paths! do
output_dir = get(:output_dir)
unless File.dir?(output_dir) do
case File.mkdir_p(output_dir) do
:ok -> :ok
{:error, reason} ->
raise "Cannot create output directory: #{reason}"
end
end
end
defp validate_formats! do
formats = get(:formats, [])
invalid = Enum.filter(formats, fn format ->
format not in [:pdf, :epub, :mobi, :html]
end)
unless Enum.empty?(invalid) do
raise "Invalid formats: #{Enum.join(invalid, ", ")}"
end
end
end
This is equivalent to our Ruby validation:
def self.validate_configuration(config)
validate_required_settings(config)
validate_paths(config)
validate_formats(config)
end
def self.validate_required_settings(config)
required = ['output_dir', 'formats']
missing = required.select { |key| config[key].nil? }
raise "Missing required configuration: #{missing.join(', ')}" unless missing.empty?
end
# ... other validation methods ...
Accessing Configuration
In Regular Modules
Access configuration in regular modules:
defmodule Codexis.FormatBuilder do
alias Codexis.Config
def build(manuscript, format) do
output_dir = Config.get(:output_dir)
timeout = Config.get(:timeout, 30_000)
# Use configuration values
# ...
end
end
In GenServers
Access configuration in GenServers:
defmodule Codexis.BuildManager do
use GenServer
alias Codexis.Config
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(opts) do
formats = Config.get(:formats, [:pdf])
state = %{
formats: formats,
output_dir: Config.get(:output_dir),
builds: %{}
}
{:ok, state}
end
# ... rest of GenServer implementation ...
end
Organizing Configuration
Nested Configuration
For complex configuration, use nested structures:
# config/config.exs
import Config
config :elib,
output_dir: "output",
formats: [:pdf, :epub, :mobi]
config :elib, :pdf,
engine: :xelatex,
dpi: 300,
paper_size: :letter
config :elib, :epub,
version: "3.0",
toc: true
# Access nested configuration
pdf_engine = Application.get_env(:elib, :pdf)[:engine]
# or
pdf_engine = Application.get_env(:elib, :pdf) |> Keyword.get(:engine)
Configuration by Module
You can also organize configuration by module:
# config/config.exs
import Config
config :elib, Codexis.FormatBuilder,
default_format: :pdf,
parallel: true,
timeout: 60_000
# Access module-specific configuration
default_format = Application.get_env(:elib, Codexis.FormatBuilder)[:default_format]
Handling Sensitive Configuration
Sensitive Data in Elixir
For sensitive configuration like API keys:
# config/runtime.exs
import Config
config :elib,
aws_key: System.get_env("AWS_ACCESS_KEY"),
aws_secret: System.get_env("AWS_SECRET_KEY")
Using Config Providers
For more complex sensitive configuration, use config providers:
# mix.exs
def application do
[
extra_applications: [:logger],
mod: {Codexis.Application, []},
env: [aws_key: nil],
# Config providers run before your application starts
start_phases: [load_runtime_config: []]
]
end
# In your application
defmodule Codexis.Application do
use Application
@impl true
def start(_type, _args) do
# ...
end
@impl true
def start_phase(:load_runtime_config, _start_type, _phase_args) do
# Load sensitive configuration from secure storage
Codexis.SecureConfig.load()
end
end
defmodule Codexis.SecureConfig do
def load do
# Load from secure storage, vault, etc.
aws_key = load_from_secure_source("aws_key")
Application.put_env(:elib, :aws_key, aws_key)
:ok
end
defp load_from_secure_source(key) do
# Implementation to load from secure storage
end
end
Testing Configuration-Dependent Code
Test Configuration
Create test-specific configuration:
# config/test.exs
import Config
config :elib,
output_dir: "test/output",
formats: [:pdf, :epub],
aws_key: "test_key"
Mocking Configuration in Tests
Mock configuration in tests:
defmodule Codexis.FormatBuilderTest do
use ExUnit.Case
alias Codexis.FormatBuilder
setup do
# Save original config
original_output_dir = Application.get_env(:elib, :output_dir)
# Set test config
Application.put_env(:elib, :output_dir, "test/temp_output")
on_exit(fn ->
# Restore original config
Application.put_env(:elib, :output_dir, original_output_dir)
end)
:ok
end
test "builds format with correct output directory" do
# Test with the temporary configuration
result = FormatBuilder.build(test_manuscript(), :pdf)
assert result.output_path =~ "test/temp_output"
end
end
Testing Configuration Module
Test your configuration module:
defmodule Codexis.ConfigTest do
use ExUnit.Case
alias Codexis.Config
setup do
# Save original config
original_config = Application.get_all_env(:elib)
on_exit(fn ->
# Restore original config
Enum.each(original_config, fn {k, v} ->
Application.put_env(:elib, k, v)
end)
end)
:ok
end
test "get returns configuration value" do
Application.put_env(:elib, :test_key, "test_value")
assert Config.get(:test_key) == "test_value"
end
test "get returns default when key not found" do
Application.delete_env(:elib, :missing_key)
assert Config.get(:missing_key, "default") == "default"
end
test "validate! raises error for missing required settings" do
Application.delete_env(:elib, :output_dir)
assert_raise RuntimeError, ~r/Missing required configuration/, fn ->
Config.validate!()
end
end
end
Migration Examples
Example 1: Basic Configuration Migration
Ruby YAML configuration:
# config/book.yml
output_dir: output
formats:
- pdf
- epub
- mobi
default_format: pdf
timeout: 30000
Ruby configuration loading:
module BookProcessing
class Configuration
def self.load(config_path)
YAML.load_file(config_path)
end
end
end
config = BookProcessing::Configuration.load('config/book.yml')
Elixir equivalent:
# config/config.exs
import Config
config :elib,
output_dir: "output",
formats: [:pdf, :epub, :mobi],
default_format: :pdf,
timeout: 30_000
# lib/elib/config.ex
defmodule Codexis.Config do
def get(key, default \\ nil) do
Application.get_env(:elib, key, default)
end
end
# Usage
output_dir = Codexis.Config.get(:output_dir)
Example 2: Environment-Specific Configuration
Ruby environment handling:
def self.apply_environment_overrides(config)
if ENV['ENVIRONMENT'] == 'production'
config['debug'] = false
config['log_level'] = 'info'
elsif ENV['ENVIRONMENT'] == 'development'
config['debug'] = true
config['log_level'] = 'debug'
end
config
end
Elixir equivalent:
# config/dev.exs
import Config
config :elib,
debug: true,
log_level: :debug
# config/prod.exs
import Config
config :elib,
debug: false,
log_level: :info
# config/config.exs
import Config
# Base configuration...
import_config "#{config_env()}.exs"
Example 3: Configuration Validation
Ruby validation:
def self.validate_configuration(config)
raise "Missing output_dir" unless config['output_dir']
raise "Invalid format" unless config['formats'].all? { |f| ['pdf', 'epub', 'mobi'].include?(f) }
unless Dir.exist?(config['output_dir'])
begin
FileUtils.mkdir_p(config['output_dir'])
rescue => e
raise "Cannot create output directory: #{e.message}"
end
end
end
Elixir equivalent:
defmodule Codexis.Config do
# ... other functions ...
def validate! do
output_dir = get(:output_dir)
unless output_dir, do: raise "Missing output_dir configuration"
formats = get(:formats, [])
invalid = Enum.filter(formats, fn format ->
format not in [:pdf, :epub, :mobi]
end)
unless Enum.empty?(invalid) do
raise "Invalid formats: #{Enum.join(invalid, ", ")}"
end
unless File.dir?(output_dir) do
case File.mkdir_p(output_dir) do
:ok -> :ok
{:error, reason} ->
raise "Cannot create output directory: #{reason}"
end
end
:ok
end
end
Example 4: Nested Configuration
Ruby nested configuration:
# config/book.yml
output_dir: output
formats:
- pdf
- epub
pdf_options:
engine: xelatex
dpi: 300
paper_size: letter
epub_options:
version: 3.0
toc: true
Ruby access:
pdf_engine = config['pdf_options']['engine']
Elixir equivalent:
# config/config.exs
import Config
config :elib,
output_dir: "output",
formats: [:pdf, :epub]
config :elib, :pdf,
engine: :xelatex,
dpi: 300,
paper_size: :letter
config :elib, :epub,
version: "3.0",
toc: true
# Access
pdf_engine = Application.get_env(:elib, :pdf)[:engine]
# Or with helper function
defmodule Codexis.Config do
def get_in(keys, default \\ nil) when is_list(keys) do
case keys do
[namespace, key] when is_atom(namespace) ->
namespace_config = Application.get_env(:elib, namespace, %{})
Keyword.get(namespace_config, key, default)
_ ->
config = Application.get_all_env(:elib)
Kernel.get_in(config, keys) || default
end
end
end
# Usage
pdf_engine = Codexis.Config.get_in([:pdf, :engine])