BUCKYVERSE

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

  1. Introduction
  2. Configuration System Comparison
  3. Basic Configuration Patterns
  4. Environment-Specific Configuration
  5. Runtime vs. Compile-Time Configuration
  6. Configuration Validation
  7. Accessing Configuration
  8. Organizing Configuration
  9. Handling Sensitive Configuration
  10. Testing Configuration-Dependent Code
  11. 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:

# 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:

# 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])